up
This commit is contained in:
@@ -25,3 +25,8 @@ export function offlineVideo(videoId, data) {
|
|||||||
export function updateVideo(videoId, data) {
|
export function updateVideo(videoId, data) {
|
||||||
return request.put(`/admin/videos/${videoId}`, data)
|
return request.put(`/admin/videos/${videoId}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报) */
|
||||||
|
export function deleteVideo(videoId) {
|
||||||
|
return request.delete(`/admin/videos/${videoId}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<el-button v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
|
<el-button v-if="row.status === 2" link type="danger" :icon="Delete" @click="offline(row)">
|
||||||
下架
|
下架
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button link type="danger" :icon="Delete" @click="deleteVideoRow(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无视频" /></template>
|
<template #empty><el-empty description="暂无视频" /></template>
|
||||||
@@ -108,11 +109,11 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Delete, Edit, Refresh, Search, View } from '@element-plus/icons-vue'
|
import { Delete, Edit, Refresh, Search, View } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
import { listVideos, offlineVideo, updateVideo } from '@/api/videos'
|
import { listVideos, offlineVideo, updateVideo, deleteVideo } from '@/api/videos'
|
||||||
import VideoPreview from '@/components/VideoPreview.vue'
|
import VideoPreview from '@/components/VideoPreview.vue'
|
||||||
import { askReason } from '@/utils/reason'
|
import { askReason } from '@/utils/reason'
|
||||||
|
|
||||||
@@ -199,6 +200,19 @@ async function offline(row) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteVideoRow(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除《${row.title}》?\n\n将级联删除:标签、点赞、收藏、评论、观看记录、收益、举报等所有关联数据。此操作不可恢复!`,
|
||||||
|
'删除视频',
|
||||||
|
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
await deleteVideo(row.video_id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 编辑弹窗 ----------
|
// ---------- 编辑弹窗 ----------
|
||||||
function openEdit(row) {
|
function openEdit(row) {
|
||||||
editingVideoId.value = row.video_id
|
editingVideoId.value = row.video_id
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ class AppConfig {
|
|||||||
AppConfig._();
|
AppConfig._();
|
||||||
|
|
||||||
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
||||||
static const String appVersion = '1.1.0';
|
/// ⚠️ 每次发版必须与 pubspec.yaml 的 version 同步递增,否则老用户会一直提示更新
|
||||||
static const int appVersionCode = 2;
|
static const String appVersion = '1.1.7';
|
||||||
|
static const int appVersionCode = 9;
|
||||||
|
|
||||||
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
||||||
static const String platformNameIOS = 'ios';
|
static const String platformNameIOS = 'ios';
|
||||||
|
|||||||
@@ -66,25 +66,33 @@ class FeedPlayerPool {
|
|||||||
|
|
||||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||||
// 1. 先把**所有**出窗/旧槽位强制停掉(不保留任何正在播放的声音)
|
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
|
||||||
// 只允许窗口内 [index-1, index, index+1] 这 3 个槽位持有资源
|
_currentIndex = index;
|
||||||
|
|
||||||
|
// 窗口内应保留的槽位集合 [index-1, index, index+1]
|
||||||
final activeSet = <int>{};
|
final activeSet = <int>{};
|
||||||
for (var offset = -1; offset <= 1; offset++) {
|
for (var offset = -1; offset <= 1; offset++) {
|
||||||
final i = index + offset;
|
final i = index + offset;
|
||||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 立即静音(同步执行,不等后续网络 open):
|
||||||
|
// - 出窗槽位:stop(断音 + 清缓存),并复位
|
||||||
|
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
|
||||||
|
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
||||||
for (var s = 0; s < slotCount; s++) {
|
for (var s = 0; s < slotCount; s++) {
|
||||||
if (activeSet.contains(s)) continue;
|
|
||||||
final slot = _slots[s];
|
final slot = _slots[s];
|
||||||
if (slot.videoId >= 0) {
|
if (slot.videoId < 0) continue;
|
||||||
// 确保旧视频彻底静音 + 停止解码(libmpv stop 不会立即断音 + 清缓存)
|
if (activeSet.contains(s)) {
|
||||||
try { await slot.player.stop(); } catch (_) {}
|
try { slot.player.pause(); } catch (_) {}
|
||||||
|
} else {
|
||||||
|
unawaited(slot.player.stop());
|
||||||
slot.videoId = -1;
|
slot.videoId = -1;
|
||||||
slot.completeNotified = false;
|
slot.completeNotified = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 窗口内槽位按需加载(open 完才 play)
|
// 3. 窗口内槽位按需加载(open 完才 play)
|
||||||
for (var offset = -1; offset <= 1; offset++) {
|
for (var offset = -1; offset <= 1; offset++) {
|
||||||
final i = index + offset;
|
final i = index + offset;
|
||||||
if (i < 0 || i >= items.length) continue;
|
if (i < 0 || i >= items.length) continue;
|
||||||
@@ -98,17 +106,6 @@ class FeedPlayerPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 窗口内非当前:pause 预缓冲(但确保不出声)
|
|
||||||
for (var offset = -1; offset <= 1; offset++) {
|
|
||||||
final i = index + offset;
|
|
||||||
if (i < 0 || i >= items.length) continue;
|
|
||||||
final slot = _slots[slotOf(i)];
|
|
||||||
if (i == index) continue; // 当前页留到最后统一 play
|
|
||||||
try {
|
|
||||||
slot.player.pause();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 当前槽:播放
|
// 4. 当前槽:播放
|
||||||
currentSlot.player.play();
|
currentSlot.player.play();
|
||||||
}
|
}
|
||||||
@@ -150,6 +147,14 @@ class FeedPlayerPool {
|
|||||||
void playCurrent() => currentSlot.player.play();
|
void playCurrent() => currentSlot.player.play();
|
||||||
void toggleCurrent() => currentSlot.player.playOrPause();
|
void toggleCurrent() => currentSlot.player.playOrPause();
|
||||||
|
|
||||||
|
/// 翻页瞬间静音:暂停除目标槽位外的所有槽位,防止多个视频同时出声
|
||||||
|
void pauseExcept({required int slotIndex}) {
|
||||||
|
for (var i = 0; i < slotCount; i++) {
|
||||||
|
if (i == slotIndex) continue;
|
||||||
|
try { _slots[i].player.pause(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
for (final sub in _subs) {
|
for (final sub in _subs) {
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
|||||||
|
|
||||||
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
||||||
void onPageChanged(int index) {
|
void onPageChanged(int index) {
|
||||||
|
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
|
||||||
|
pool.pauseExcept(slotIndex: pool.slotOf(index));
|
||||||
state = state.copyWith(currentIndex: index);
|
state = state.copyWith(currentIndex: index);
|
||||||
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
|
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
|
||||||
_bindAndReport(index);
|
_bindAndReport(index);
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
SimplePlayerScreen(item: state.extra! as VideoItem),
|
SimplePlayerScreen(item: state.extra! as VideoItem),
|
||||||
),
|
),
|
||||||
|
// 通用单视频播放(extra 传 VideoItem)— 系列切换/Feed 点击等场景
|
||||||
|
GoRoute(
|
||||||
|
path: '/player',
|
||||||
|
builder: (_, state) =>
|
||||||
|
SimplePlayerScreen(item: state.extra! as VideoItem),
|
||||||
|
),
|
||||||
// 他人公开主页(游客可)
|
// 他人公开主页(游客可)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/:id',
|
path: '/user/:id',
|
||||||
|
|||||||
@@ -118,24 +118,33 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
|
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
PageView.builder(
|
NotificationListener<ScrollNotification>(
|
||||||
controller: _pageController,
|
onNotification: (n) {
|
||||||
scrollDirection: Axis.vertical,
|
// 手指开始拖动即静音,避免划动过程中上一个视频仍在出声
|
||||||
itemCount: state.items.length,
|
if (n is ScrollStartNotification && n.dragDetails != null) {
|
||||||
onPageChanged: notifier.onPageChanged,
|
notifier.pool.pauseCurrent();
|
||||||
itemBuilder: (context, index) {
|
}
|
||||||
final item = state.items[index];
|
return false;
|
||||||
return FeedVideoPage(
|
|
||||||
key: ValueKey(item.videoId),
|
|
||||||
item: item,
|
|
||||||
slotIndex: notifier.pool.slotOf(index),
|
|
||||||
pool: notifier.pool,
|
|
||||||
isCurrent: index == state.currentIndex,
|
|
||||||
onLike: notifier.toggleLike,
|
|
||||||
onFavorite: notifier.toggleFavorite,
|
|
||||||
onComment: _openComments,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
itemCount: state.items.length,
|
||||||
|
onPageChanged: notifier.onPageChanged,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = state.items[index];
|
||||||
|
return FeedVideoPage(
|
||||||
|
key: ValueKey(item.videoId),
|
||||||
|
item: item,
|
||||||
|
slotIndex: notifier.pool.slotOf(index),
|
||||||
|
pool: notifier.pool,
|
||||||
|
isCurrent: index == state.currentIndex,
|
||||||
|
onLike: notifier.toggleLike,
|
||||||
|
onFavorite: notifier.toggleFavorite,
|
||||||
|
onComment: _openComments,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 顶部:策略 Tab + 搜索入口
|
// 顶部:策略 Tab + 搜索入口
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../core/player/feed_player_pool.dart';
|
|||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../models/video_item.dart';
|
import '../../../models/video_item.dart';
|
||||||
import '../../../providers/auth_provider.dart';
|
import '../../../providers/auth_provider.dart';
|
||||||
|
import '../../../services/video_service.dart';
|
||||||
import 'feed_overlays.dart';
|
import 'feed_overlays.dart';
|
||||||
import 'interaction_bar.dart';
|
import 'interaction_bar.dart';
|
||||||
import 'video_info_section.dart';
|
import 'video_info_section.dart';
|
||||||
@@ -40,8 +41,28 @@ class FeedVideoPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||||
int _likeBurstTrigger = 0;
|
int _likeBurstTrigger = 0;
|
||||||
|
Map<String, dynamic>? _seriesData;
|
||||||
|
|
||||||
static const _rates = [1.0, 1.25, 1.5, 2.0];
|
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSeries() async {
|
||||||
|
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _seriesData = data);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasSeries =>
|
||||||
|
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||||
|
|
||||||
|
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
|
||||||
|
|
||||||
|
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
|
||||||
|
|
||||||
void _onDoubleTap() {
|
void _onDoubleTap() {
|
||||||
if (!ref.read(authProvider).isLoggedIn) {
|
if (!ref.read(authProvider).isLoggedIn) {
|
||||||
@@ -52,69 +73,98 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
|
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 倍速标签:1.0→1X,1.5→1.5X
|
||||||
|
String _rateLabel(double r) =>
|
||||||
|
r == r.roundToDouble() ? '${r.toInt()}X' : '${r}X';
|
||||||
|
|
||||||
void _onLongPress() {
|
void _onLongPress() {
|
||||||
showModalBottomSheet<void>(
|
final player = widget.pool.slotAt(widget.slotIndex).player;
|
||||||
context: context,
|
final currentRate = player.state.rate;
|
||||||
backgroundColor: Colors.white,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (_) {
|
|
||||||
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.speed, color: AppColors.primary),
|
|
||||||
title: const Text('倍速播放'),
|
|
||||||
subtitle: Text('当前 ${currentRate.toStringAsFixed(2)}x'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_showRateSheet();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.share_outlined),
|
|
||||||
title: const Text('分享'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_share();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showRateSheet() {
|
|
||||||
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
|
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (_) => SafeArea(
|
builder: (sheetContext) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final r in _rates)
|
// 标题 + 当前倍速
|
||||||
ListTile(
|
Row(
|
||||||
title: Center(child: Text('${r}x')),
|
children: [
|
||||||
trailing: (currentRate - r).abs() < 0.01
|
const Icon(Icons.speed, color: AppColors.primary, size: 20),
|
||||||
? const Icon(Icons.check, color: AppColors.primary)
|
const SizedBox(width: 6),
|
||||||
: null,
|
const Text('倍速播放',
|
||||||
onTap: () {
|
style: TextStyle(
|
||||||
widget.pool.slotAt(widget.slotIndex).player.setRate(r);
|
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
Navigator.of(context).pop();
|
const Spacer(),
|
||||||
},
|
Text('当前 ${_rateLabel(currentRate)}',
|
||||||
),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||||
const SizedBox(height: 8),
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// 四档倍速:1X / 1.5X / 2X / 3X
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (final r in _rates) ...[
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
player.setRate(r);
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
height: 46,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (currentRate - r).abs() < 0.01
|
||||||
|
? AppColors.primary.withValues(alpha: 0.15)
|
||||||
|
: Colors.grey[100],
|
||||||
|
border: Border.all(
|
||||||
|
color: (currentRate - r).abs() < 0.01
|
||||||
|
? AppColors.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_rateLabel(r),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: (currentRate - r).abs() < 0.01
|
||||||
|
? AppColors.primary
|
||||||
|
: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (r != _rates.last) const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// 分享
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.share_outlined),
|
||||||
|
title: const Text('分享'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
_share();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -126,6 +176,121 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
await Share.share(text, subject: widget.item.title);
|
await Share.share(text, subject: widget.item.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showSeriesSheet() {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, controller) => Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12),
|
||||||
|
width: 40, height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_seriesTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'共 ${_episodes.length} 集',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: controller,
|
||||||
|
itemCount: _episodes.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final ep = _episodes[i] as Map<String, dynamic>;
|
||||||
|
final isCurrent = ep['video_id'] == widget.item.videoId;
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrent
|
||||||
|
? AppColors.primary.withValues(alpha: 0.15)
|
||||||
|
: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${ep['episode_no'] ?? i + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isCurrent ? AppColors.primary : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
ep['title'] as String? ?? '',
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight:
|
||||||
|
isCurrent ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isCurrent ? AppColors.primary : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isCurrent
|
||||||
|
? const Icon(Icons.play_circle_filled,
|
||||||
|
color: AppColors.primary, size: 20)
|
||||||
|
: null,
|
||||||
|
onTap: () => _switchToEpisode(ep),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
final newItem = VideoItem(
|
||||||
|
videoId: ep['video_id'] as int,
|
||||||
|
title: ep['title'] as String? ?? '',
|
||||||
|
coverUrl: ep['cover_url'] as String? ?? '',
|
||||||
|
videoUrl: ep['video_url'] as String? ?? '',
|
||||||
|
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
||||||
|
author: widget.item.author,
|
||||||
|
);
|
||||||
|
// 打开 SimplePlayerScreen 播放该集
|
||||||
|
context.push('/player', extra: newItem);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -159,7 +324,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
pool: widget.pool,
|
pool: widget.pool,
|
||||||
slotIndex: widget.slotIndex,
|
slotIndex: widget.slotIndex,
|
||||||
coverUrl: widget.item.coverUrl,
|
coverUrl: widget.item.coverUrl,
|
||||||
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
|
isCurrent: widget.isCurrent,
|
||||||
),
|
),
|
||||||
|
|
||||||
// 双击爱心动画
|
// 双击爱心动画
|
||||||
@@ -176,10 +341,47 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 左下信息区
|
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 12, right: 88, bottom: 24,
|
left: 12, right: 88, bottom: 24,
|
||||||
child: VideoInfoSection(item: widget.item),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
|
||||||
|
if (_hasSeries)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showSeriesSheet,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.playlist_play, color: Colors.white, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_seriesTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.white, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 视频信息(昵称/标题/描述)
|
||||||
|
VideoInfoSection(item: widget.item),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ class VideoPlayerView extends StatefulWidget {
|
|||||||
required this.pool,
|
required this.pool,
|
||||||
required this.slotIndex,
|
required this.slotIndex,
|
||||||
required this.coverUrl,
|
required this.coverUrl,
|
||||||
required this.playing,
|
required this.isCurrent,
|
||||||
});
|
});
|
||||||
|
|
||||||
final FeedPlayerPool pool;
|
final FeedPlayerPool pool;
|
||||||
final int slotIndex;
|
final int slotIndex;
|
||||||
final String coverUrl;
|
final String coverUrl;
|
||||||
final bool playing;
|
|
||||||
|
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
|
||||||
|
final bool isCurrent;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||||
@@ -30,6 +32,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
Duration _position = Duration.zero;
|
Duration _position = Duration.zero;
|
||||||
Duration _duration = Duration.zero;
|
Duration _duration = Duration.zero;
|
||||||
bool _seeking = false;
|
bool _seeking = false;
|
||||||
|
double _seekValue = 0; // 拖动中的视觉值(0~1),拖动期间直接控制 painter
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -43,16 +46,26 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double get _value {
|
double get _value {
|
||||||
|
if (_seeking) return _seekValue;
|
||||||
final total = _duration.inMilliseconds;
|
final total = _duration.inMilliseconds;
|
||||||
if (total <= 0) return 0;
|
if (total <= 0) return 0;
|
||||||
final cur = _position.inMilliseconds.clamp(0, total);
|
final cur = _position.inMilliseconds.clamp(0, total);
|
||||||
return cur / total;
|
return cur / total;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSeekStart(double v) => setState(() => _seeking = true);
|
void _onSeekStart(double v) {
|
||||||
void _onSeek(double v) => setState(() {});
|
setState(() {
|
||||||
|
_seeking = true;
|
||||||
|
_seekValue = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSeek(double v) {
|
||||||
|
setState(() => _seekValue = v);
|
||||||
|
}
|
||||||
|
|
||||||
void _onSeekEnd(double v) {
|
void _onSeekEnd(double v) {
|
||||||
_seeking = false;
|
setState(() => _seeking = false);
|
||||||
final totalMs = _duration.inMilliseconds;
|
final totalMs = _duration.inMilliseconds;
|
||||||
if (totalMs > 0) {
|
if (totalMs > 0) {
|
||||||
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
|
||||||
@@ -88,14 +101,22 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// 暂停态大播放图标
|
// 暂停态大播放图标:只在当前页、且该槽位确实处于暂停态时显示
|
||||||
if (!widget.playing)
|
if (widget.isCurrent)
|
||||||
const IgnorePointer(
|
IgnorePointer(
|
||||||
child: Center(
|
child: StreamBuilder<bool>(
|
||||||
child: Icon(
|
stream: _slot.player.stream.playing,
|
||||||
Icons.play_arrow_rounded,
|
initialData: _slot.player.state.playing,
|
||||||
size: 88, color: Colors.white70,
|
builder: (_, snapshot) {
|
||||||
),
|
final playing = snapshot.data ?? false;
|
||||||
|
if (playing) return const SizedBox.shrink();
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
size: 88, color: Colors.white70,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import '../../models/video_item.dart';
|
|||||||
import '../../services/video_service.dart';
|
import '../../services/video_service.dart';
|
||||||
import '../feed/widgets/feed_overlays.dart';
|
import '../feed/widgets/feed_overlays.dart';
|
||||||
|
|
||||||
/// 单视频播放页(搜索/用户主页/作品/收藏/历史共用):
|
/// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用):
|
||||||
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
|
/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek
|
||||||
/// - 顶部返回 + 倍速 + 系列
|
/// - 顶部返回 + 倍速 + 系列
|
||||||
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
|
/// - 点屏幕中央切换播放/暂停(图标短暂淡出)
|
||||||
|
/// - 系列视频支持上下划切换上一集/下一集
|
||||||
class SimplePlayerScreen extends StatefulWidget {
|
class SimplePlayerScreen extends StatefulWidget {
|
||||||
const SimplePlayerScreen({super.key, required this.item});
|
const SimplePlayerScreen({super.key, required this.item});
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
StreamSubscription<Duration>? _posSub;
|
StreamSubscription<Duration>? _posSub;
|
||||||
StreamSubscription<Duration>? _durSub;
|
StreamSubscription<Duration>? _durSub;
|
||||||
|
|
||||||
|
// 系列切换(上下划)
|
||||||
|
late PageController _pageController;
|
||||||
|
int _currentPage = 0;
|
||||||
|
List<VideoItem> _episodeItems = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -72,6 +78,32 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
|
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _seriesData = data);
|
setState(() => _seriesData = data);
|
||||||
|
_initEpisodePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initEpisodePage() {
|
||||||
|
if (!_hasSeries) return;
|
||||||
|
final episodes = _episodes;
|
||||||
|
if (episodes.isEmpty) return;
|
||||||
|
|
||||||
|
_episodeItems = episodes.map((ep) {
|
||||||
|
final m = ep as Map<String, dynamic>;
|
||||||
|
return VideoItem(
|
||||||
|
videoId: m['video_id'],
|
||||||
|
title: m['title'] ?? '',
|
||||||
|
coverUrl: m['cover_url'] ?? '',
|
||||||
|
videoUrl: m['video_url'] ?? '',
|
||||||
|
duration: (m['duration'] as num?)?.toInt() ?? 0,
|
||||||
|
author: widget.item.author,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_currentPage = _episodeItems.indexWhere(
|
||||||
|
(e) => e.videoId == widget.item.videoId,
|
||||||
|
);
|
||||||
|
if (_currentPage < 0) _currentPage = 0;
|
||||||
|
|
||||||
|
_pageController = PageController(initialPage: _currentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -80,6 +112,7 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
_posSub?.cancel();
|
_posSub?.cancel();
|
||||||
_durSub?.cancel();
|
_durSub?.cancel();
|
||||||
_player.dispose();
|
_player.dispose();
|
||||||
|
if (_hasSeries) _pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +121,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
void _toggle() {
|
void _toggle() {
|
||||||
setState(() => _playing = !_playing);
|
setState(() => _playing = !_playing);
|
||||||
_playing ? _player.play() : _player.pause();
|
_playing ? _player.play() : _player.pause();
|
||||||
// 短暂显示暂停/播放图标
|
|
||||||
setState(() => _showPauseIcon = true);
|
setState(() => _showPauseIcon = true);
|
||||||
_pauseIconTimer?.cancel();
|
_pauseIconTimer?.cancel();
|
||||||
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
|
_pauseIconTimer = Timer(const Duration(milliseconds: 600), () {
|
||||||
@@ -260,28 +292,55 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
|
|
||||||
void _switchToEpisode(Map<String, dynamic> ep) {
|
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
_switchToEpisodeById(ep['video_id'] as int);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _switchToEpisodeById(int videoId) {
|
||||||
|
if (videoId == widget.item.videoId) return;
|
||||||
|
|
||||||
|
// 从 episodes 里找到目标集,构造新的 VideoItem
|
||||||
|
final epData = _episodes.firstWhere(
|
||||||
|
(e) => (e as Map)['video_id'] == videoId,
|
||||||
|
orElse: () => null,
|
||||||
|
) as Map<String, dynamic>?;
|
||||||
|
if (epData == null) return;
|
||||||
|
|
||||||
final newItem = VideoItem(
|
final newItem = VideoItem(
|
||||||
videoId: ep['video_id'],
|
videoId: videoId,
|
||||||
title: ep['title'] ?? '',
|
title: epData['title'] ?? '',
|
||||||
coverUrl: ep['cover_url'] ?? '',
|
coverUrl: epData['cover_url'] ?? '',
|
||||||
videoUrl: ep['video_url'] ?? '',
|
videoUrl: epData['video_url'] ?? '',
|
||||||
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
duration: (epData['duration'] as num?)?.toInt() ?? 0,
|
||||||
author: widget.item.author,
|
author: widget.item.author,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 彻底销毁旧 Player,创建新 Player
|
||||||
|
_posSub?.cancel();
|
||||||
|
_durSub?.cancel();
|
||||||
|
_player.dispose();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_player.dispose();
|
|
||||||
_player = Player();
|
_player = Player();
|
||||||
_controller = VideoController(_player);
|
_controller = VideoController(_player);
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
_duration = Duration.zero;
|
_duration = Duration.zero;
|
||||||
|
_playing = true;
|
||||||
_open(newItem);
|
_open(newItem);
|
||||||
_seriesData = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_subscribePlayer();
|
_subscribePlayer();
|
||||||
_reportWatch();
|
_reportWatch();
|
||||||
_loadSeries();
|
_loadSeries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 上下划切换(系列) ----------
|
||||||
|
|
||||||
|
void _onPageChanged(int index) {
|
||||||
|
if (index == _currentPage) return;
|
||||||
|
_currentPage = index;
|
||||||
|
_switchToEpisodeById(_episodeItems[index].videoId);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Watch ----------
|
// ---------- Watch ----------
|
||||||
|
|
||||||
Future<void> _reportWatch() async {
|
Future<void> _reportWatch() async {
|
||||||
@@ -312,120 +371,150 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final content = GestureDetector(
|
||||||
backgroundColor: Colors.black,
|
behavior: HitTestBehavior.opaque,
|
||||||
body: GestureDetector(
|
onTap: _toggle,
|
||||||
behavior: HitTestBehavior.opaque,
|
child: Stack(
|
||||||
onTap: _toggle,
|
fit: StackFit.expand,
|
||||||
child: Stack(
|
children: [
|
||||||
fit: StackFit.expand,
|
// 视频画面
|
||||||
children: [
|
Center(
|
||||||
// 视频画面
|
child: Video(
|
||||||
Center(
|
controller: _controller,
|
||||||
child: Video(
|
controls: NoVideoControls,
|
||||||
controller: _controller,
|
fit: BoxFit.contain,
|
||||||
controls: NoVideoControls,
|
),
|
||||||
fit: BoxFit.contain,
|
),
|
||||||
|
|
||||||
|
// 中央播放/暂停图标(短暂显示)
|
||||||
|
if (_showPauseIcon)
|
||||||
|
const IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_circle_outline,
|
||||||
|
size: 72,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 中央播放/暂停图标(短暂显示)
|
// ---------- 顶部:返回 + 倍速 + 系列 ----------
|
||||||
if (_showPauseIcon)
|
SafeArea(
|
||||||
const IgnorePointer(
|
child: Row(
|
||||||
child: Center(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
child: Icon(
|
children: [
|
||||||
Icons.play_circle_outline,
|
const BackButton(color: Colors.white),
|
||||||
size: 72,
|
Row(
|
||||||
color: Colors.white70,
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
),
|
// 倍速
|
||||||
),
|
PopupMenuButton<double>(
|
||||||
|
initialValue: _rate,
|
||||||
// ---------- 顶部:返回 + 倍速 + 系列 ----------
|
icon: const Icon(Icons.speed, color: Colors.white),
|
||||||
SafeArea(
|
tooltip: '倍速',
|
||||||
child: Row(
|
onSelected: _setRate,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
itemBuilder: (_) => _rates
|
||||||
children: [
|
.map((r) => PopupMenuItem(
|
||||||
const BackButton(color: Colors.white),
|
value: r,
|
||||||
Row(
|
child: Text('${r}x'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
))
|
||||||
children: [
|
.toList(),
|
||||||
// 倍速
|
),
|
||||||
PopupMenuButton<double>(
|
if (_hasSeries)
|
||||||
initialValue: _rate,
|
IconButton(
|
||||||
icon: const Icon(Icons.speed, color: Colors.white),
|
icon: const Icon(Icons.menu_book, color: Colors.white),
|
||||||
tooltip: '倍速',
|
tooltip: '查看系列',
|
||||||
onSelected: _setRate,
|
onPressed: _showSeriesSheet,
|
||||||
itemBuilder: (_) => _rates
|
|
||||||
.map((r) => PopupMenuItem(
|
|
||||||
value: r,
|
|
||||||
child: Text('${r}x'),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
if (_hasSeries)
|
],
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.menu_book, color: Colors.white),
|
],
|
||||||
tooltip: '查看系列',
|
),
|
||||||
onPressed: _showSeriesSheet,
|
),
|
||||||
|
|
||||||
|
// ---------- 底部:标题 + 抖音风格细进度条 ----------
|
||||||
|
Positioned(
|
||||||
|
left: 0, right: 0, bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'@${widget.item.author.nickname}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.item.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: Colors.white70),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 底部:标题 + 抖音风格细进度条 ----------
|
// 进度条:抖音风格一条细线,覆盖视频底部
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0, right: 0, bottom: 0,
|
left: 0, right: 0, bottom: 0,
|
||||||
child: IgnorePointer(
|
child: _ThinProgressBar(
|
||||||
child: Column(
|
value: _sliderValue,
|
||||||
mainAxisSize: MainAxisSize.min,
|
onChanged: _onSeek,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onChangeStart: _onSeekStart,
|
||||||
children: [
|
onChangeEnd: _onSeekEnd,
|
||||||
// 标题信息(贴底,不占用视频观看空间)
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
],
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
);
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
// 系列视频:用 PageView 支持上下划切换集数
|
||||||
Text(
|
if (_hasSeries && _episodeItems.isNotEmpty) {
|
||||||
'@${widget.item.author.nickname}',
|
return Scaffold(
|
||||||
style: const TextStyle(
|
backgroundColor: Colors.black,
|
||||||
fontSize: 13,
|
body: PageView.builder(
|
||||||
fontWeight: FontWeight.w600,
|
controller: _pageController,
|
||||||
color: Colors.white),
|
scrollDirection: Axis.vertical,
|
||||||
),
|
itemCount: _episodeItems.length,
|
||||||
const SizedBox(height: 2),
|
onPageChanged: _onPageChanged,
|
||||||
Text(
|
itemBuilder: (_, index) {
|
||||||
widget.item.title,
|
// 只有当前页才渲染完整播放器(其他页只放占位)
|
||||||
style: const TextStyle(
|
if (index == _currentPage) {
|
||||||
fontSize: 12, color: Colors.white70),
|
return content;
|
||||||
maxLines: 1,
|
}
|
||||||
overflow: TextOverflow.ellipsis,
|
return Container(
|
||||||
),
|
color: Colors.black,
|
||||||
],
|
child: Center(
|
||||||
),
|
child: Text(
|
||||||
),
|
_episodeItems[index].title,
|
||||||
],
|
style: const TextStyle(color: Colors.white38),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
// 进度条:抖音风格一条细线,覆盖视频底部
|
|
||||||
Positioned(
|
|
||||||
left: 0, right: 0, bottom: 0,
|
|
||||||
child: _ThinProgressBar(
|
|
||||||
value: _sliderValue,
|
|
||||||
onChanged: _onSeek,
|
|
||||||
onChangeStart: _onSeekStart,
|
|
||||||
onChangeEnd: _onSeekEnd,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非系列视频:直接渲染
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,39 +617,29 @@ class _ProgressPainter extends CustomPainter {
|
|||||||
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
|
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
|
||||||
..strokeCap = StrokeCap.round
|
..strokeCap = StrokeCap.round
|
||||||
..strokeWidth = h;
|
..strokeWidth = h;
|
||||||
canvas.drawLine(
|
canvas.drawLine(Offset(0, y), Offset(w, y), trackPaint);
|
||||||
Offset(0, size.height / 2),
|
|
||||||
Offset(w, size.height / 2),
|
|
||||||
trackPaint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 已播放部分(纯白)
|
// 已播放部分(白色)
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
final playedPaint = Paint()
|
final playedPaint = Paint()
|
||||||
..color = Colors.white
|
..color = Colors.white
|
||||||
..strokeCap = StrokeCap.round
|
..strokeCap = StrokeCap.round
|
||||||
..strokeWidth = h;
|
..strokeWidth = h;
|
||||||
canvas.drawLine(
|
canvas.drawLine(Offset(0, y), Offset(value * w, y), playedPaint);
|
||||||
Offset(0, size.height / 2),
|
|
||||||
Offset(value * w, size.height / 2),
|
|
||||||
playedPaint,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖动时显示 thumb(小圆点)
|
// 拖动时的 thumb
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
final thumbPaint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawCircle(
|
canvas.drawCircle(
|
||||||
Offset(value * w, size.height / 2),
|
Offset(value * w, y), 6,
|
||||||
6,
|
Paint()
|
||||||
thumbPaint,
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.fill,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
|
bool shouldRepaint(covariant _ProgressPainter old) =>
|
||||||
value != oldDelegate.value || dragging != oldDelegate.dragging;
|
value != old.value || dragging != old.dragging;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: video_app
|
|||||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.1.0+2
|
version: 1.1.7+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -101,7 +101,7 @@ async def update_video(video_id: int, req: _VideoUpdateReq,
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin: AdminIdentity = Depends(require_super)):
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
from app.models.video import Video, Series
|
from app.models.video import Video, Series
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
v = await db.get(Video, video_id)
|
v = await db.get(Video, video_id)
|
||||||
if not v:
|
if not v:
|
||||||
@@ -147,6 +147,44 @@ async def update_video(video_id: int, req: _VideoUpdateReq,
|
|||||||
return ok({"video_id": v.id})
|
return ok({"video_id": v.id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/videos/{video_id}")
|
||||||
|
async def delete_video(video_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminIdentity = Depends(require_super)):
|
||||||
|
"""删除视频(级联删除所有关联数据:标签/点赞/收藏/评论/观看记录/收益/举报)"""
|
||||||
|
from app.models.video import Video, VideoTag, Series
|
||||||
|
from app.models.interaction import Like, Favorite, Comment
|
||||||
|
from app.models.billing import WatchRecord
|
||||||
|
from app.models.incentive import VideoEarning
|
||||||
|
from app.models.common import Report
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
v = await db.get(Video, video_id)
|
||||||
|
if not v:
|
||||||
|
raise BizException(errors.NOT_FOUND, "视频不存在")
|
||||||
|
|
||||||
|
# 如果属于系列,先解绑(保持系列数据干净)
|
||||||
|
if v.series_id:
|
||||||
|
v.series_id = None
|
||||||
|
v.episode_no = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 级联删除所有关联表
|
||||||
|
await db.execute(delete(VideoTag).where(VideoTag.video_id == video_id))
|
||||||
|
await db.execute(delete(Like).where(Like.video_id == video_id))
|
||||||
|
await db.execute(delete(Favorite).where(Favorite.video_id == video_id))
|
||||||
|
await db.execute(delete(Comment).where(Comment.video_id == video_id))
|
||||||
|
await db.execute(delete(WatchRecord).where(WatchRecord.video_id == video_id))
|
||||||
|
await db.execute(delete(VideoEarning).where(VideoEarning.video_id == video_id))
|
||||||
|
# 举报(target_type=1 表示视频)
|
||||||
|
await db.execute(delete(Report).where(Report.target_type == 1, Report.target_id == video_id))
|
||||||
|
|
||||||
|
# 删除视频本身
|
||||||
|
await db.delete(v)
|
||||||
|
await db.commit()
|
||||||
|
return ok({"video_id": video_id, "deleted": True})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos")
|
@router.post("/videos")
|
||||||
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
|
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user