This commit is contained in:
lzcode
2026-10-16 20:03:58 +08:00
parent 9962136fc8
commit ebad02ce31
19 changed files with 622 additions and 240 deletions
+5
View File
@@ -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}`)
}
+16 -2
View File
@@ -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 -2
View File
@@ -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';
+23 -18
View File
@@ -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) {
+2
View File
@@ -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);
+6
View File
@@ -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',
+10 -1
View File
@@ -118,7 +118,15 @@ 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>(
onNotification: (n) {
// 手指开始拖动即静音,避免划动过程中上一个视频仍在出声
if (n is ScrollStartNotification && n.dragDetails != null) {
notifier.pool.pauseCurrent();
}
return false;
},
child: PageView.builder(
controller: _pageController, controller: _pageController,
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
itemCount: state.items.length, itemCount: state.items.length,
@@ -137,6 +145,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
); );
}, },
), ),
),
// 顶部:策略 Tab + 搜索入口 // 顶部:策略 Tab + 搜索入口
SafeArea( SafeArea(
+250 -48
View File
@@ -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() {
final player = widget.pool.slotAt(widget.slotIndex).player;
final currentRate = 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: (_) { builder: (sheetContext) => Padding(
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate; padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListTile( // 标题 + 当前倍速
leading: const Icon(Icons.speed, color: AppColors.primary), Row(
title: const Text('倍速播放'), children: [
subtitle: Text('当前 ${currentRate.toStringAsFixed(2)}x'), const Icon(Icons.speed, color: AppColors.primary, size: 20),
onTap: () { const SizedBox(width: 6),
Navigator.of(context).pop(); const Text('倍速播放',
_showRateSheet(); style: TextStyle(
}, fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
Text('当前 ${_rateLabel(currentRate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
), ),
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), const Divider(height: 1),
// 分享
ListTile( ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.share_outlined), leading: const Icon(Icons.share_outlined),
title: const Text('分享'), title: const Text('分享'),
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(sheetContext).pop();
_share(); _share();
}, },
), ),
const SizedBox(height: 8),
],
),
);
},
);
}
void _showRateSheet() {
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final r in _rates)
ListTile(
title: Center(child: Text('${r}x')),
trailing: (currentRate - r).abs() < 0.01
? const Icon(Icons.check, color: AppColors.primary)
: null,
onTap: () {
widget.pool.slotAt(widget.slotIndex).player.setRate(r);
Navigator.of(context).pop();
},
),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -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>(
stream: _slot.player.stream.playing,
initialData: _slot.player.state.playing,
builder: (_, snapshot) {
final playing = snapshot.data ?? false;
if (playing) return const SizedBox.shrink();
return const Center(
child: Icon( child: Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
size: 88, color: Colors.white70, size: 88, color: Colors.white70,
), ),
);
},
), ),
), ),
+112 -33
View File
@@ -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,
); );
setState(() {
// 彻底销毁旧 Player,创建新 Player
_posSub?.cancel();
_durSub?.cancel();
_player.dispose(); _player.dispose();
setState(() {
_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,9 +371,7 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final content = GestureDetector(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: _toggle, onTap: _toggle,
child: Stack( child: Stack(
@@ -383,7 +440,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 标题信息(贴底,不占用视频观看空间)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Column( child: Column(
@@ -425,7 +481,40 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
), ),
], ],
), ),
);
// 系列视频:用 PageView 支持上下划切换集数
if (_hasSeries && _episodeItems.isNotEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
itemCount: _episodeItems.length,
onPageChanged: _onPageChanged,
itemBuilder: (_, index) {
// 只有当前页才渲染完整播放器(其他页只放占位)
if (index == _currentPage) {
return content;
}
return Container(
color: Colors.black,
child: Center(
child: Text(
_episodeItems[index].title,
style: const TextStyle(color: Colors.white38),
), ),
),
);
},
),
);
}
// 非系列视频:直接渲染
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
View File
@@ -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.
+39 -1
View File
@@ -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.