This commit is contained in:
lz_db
2026-10-17 19:04:05 +08:00
parent 28b11b4365
commit 1253c159f5
43 changed files with 2652 additions and 114 deletions
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/media/download_manager.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
@@ -25,6 +26,7 @@ class FeedVideoPage extends ConsumerStatefulWidget {
required this.onLike,
this.onFavorite,
this.onComment,
this.onReturnFromPlayer,
});
final VideoItem item;
@@ -35,6 +37,9 @@ class FeedVideoPage extends ConsumerStatefulWidget {
final void Function(int videoId)? onFavorite;
final void Function(VideoItem item)? onComment;
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
final VoidCallback? onReturnFromPlayer;
@override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
}
@@ -89,7 +94,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Padding(
builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -165,8 +173,38 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
_share();
},
),
// 下载(已下载/下载中置灰)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded = dm.items
.any((e) => e.videoId == widget.item.videoId);
final downloading =
dm.progress.containsKey(widget.item.videoId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(widget.item)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
],
),
),
),
);
}
@@ -185,7 +223,9 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => DraggableScrollableSheet(
builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
@@ -273,6 +313,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
),
],
),
),
),
);
}
@@ -287,10 +328,13 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
duration: (ep['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
// 先暂停 Feed 页播放器,再打开 SimplePlayerScreen
widget.pool.pauseExcept(slotIndex: -1);
// 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
widget.pool.stopAll();
// ignore: use_build_context_synchronously
context.push('/player', extra: newItem);
context.push('/player', extra: newItem).then((_) {
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
if (mounted) widget.onReturnFromPlayer?.call();
});
}
@override
@@ -327,6 +371,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl,
isCurrent: widget.isCurrent,
durationSeconds: widget.item.duration,
),
// 双击爱心动画
@@ -18,6 +18,13 @@ class VideoInfoSection extends StatefulWidget {
class _VideoInfoSectionState extends State<VideoInfoSection> {
bool _expanded = false;
static String _fmtSeconds(int seconds) {
final h = seconds ~/ 3600;
final m = ((seconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
@override
Widget build(BuildContext context) {
final item = widget.item;
@@ -37,7 +44,7 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
),
const SizedBox(height: 8),
// 标题 + #标签(点击标签跳转标签视频页)
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
@@ -52,6 +59,25 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
// 标题后显示视频时长
if (item.duration > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white24, width: 0.5),
),
child: Text(
_fmtSeconds(item.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
),
for (final tag in item.tags)
GestureDetector(
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
@@ -14,6 +14,7 @@ class VideoPlayerView extends StatefulWidget {
required this.slotIndex,
required this.coverUrl,
required this.isCurrent,
this.durationSeconds = 0,
});
final FeedPlayerPool pool;
@@ -23,6 +24,10 @@ class VideoPlayerView extends StatefulWidget {
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
final bool isCurrent;
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
/// 保证进度条/拖动可用
final int durationSeconds;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
}
@@ -45,9 +50,15 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
});
}
/// 有效时长:流上报优先,异常(duration=0)回退 DB 时长
int get _totalMs {
final streamMs = _duration.inMilliseconds;
return streamMs > 0 ? streamMs : widget.durationSeconds * 1000;
}
double get _value {
if (_seeking) return _seekValue;
final total = _duration.inMilliseconds;
final total = _totalMs;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
@@ -66,7 +77,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
void _onSeekEnd(double v) {
setState(() => _seeking = false);
final totalMs = _duration.inMilliseconds;
final totalMs = _totalMs;
if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
}
@@ -120,7 +131,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
),
),
// 抖音风格细进度条(贴底,常驻)
// 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
@@ -128,6 +139,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _totalMs,
),
),
],
@@ -141,16 +153,29 @@ class _ThinProgressBar extends StatefulWidget {
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
this.totalMs = 0,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
final int totalMs;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false;
double _dragValue = 0;
@@ -187,15 +212,44 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
setState(() => _dragging = false);
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
},
child: Container(
child: SizedBox(
height: 32, // 触摸热区 32px(视觉 2px,好点)
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Align(
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
),
),
);