1
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/download_manager.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
|
||||
/// 已下载视频列表(我的 → 已下载):
|
||||
/// 下载中任务(进度/取消)+ 已完成列表(点击播放 / 左滑或长按删除)
|
||||
class DownloadsScreen extends ConsumerWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
|
||||
static String _fmtSize(int bytes) {
|
||||
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(2)} GB';
|
||||
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(1)} MB';
|
||||
return '${(bytes / 1024).toStringAsFixed(0)} KB';
|
||||
}
|
||||
|
||||
static String _fmtDuration(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return '$m:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _play(BuildContext context, DownloadItem item) {
|
||||
context.push('/player',
|
||||
extra: VideoItem(
|
||||
videoId: item.videoId,
|
||||
title: item.title,
|
||||
coverUrl: item.coverUrl,
|
||||
videoUrl: item.playUrl,
|
||||
duration: item.duration,
|
||||
author: VideoAuthor(userId: 0),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, WidgetRef ref, DownloadItem item) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除下载'),
|
||||
content: Text('删除「${item.title}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('删除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
ref.read(downloadManagerProvider.notifier).delete(item.videoId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(downloadManagerProvider);
|
||||
final downloading = state.progress.entries.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('已下载')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 下载中
|
||||
for (final e in downloading)
|
||||
ListTile(
|
||||
leading: const SizedBox(
|
||||
width: 56, height: 56,
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
title: Text('视频 #${e.key}',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: LinearProgressIndicator(value: e.value),
|
||||
),
|
||||
trailing: Text('${(e.value * 100).toStringAsFixed(0)}%'),
|
||||
onLongPress: () =>
|
||||
ref.read(downloadManagerProvider.notifier).cancel(e.key),
|
||||
),
|
||||
|
||||
if (downloading.isNotEmpty) const Divider(),
|
||||
|
||||
// 已完成
|
||||
if (state.items.isEmpty && downloading.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 120),
|
||||
child: Center(
|
||||
child: Text('暂无下载\n长按视频可下载',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textWeak)),
|
||||
),
|
||||
),
|
||||
for (final item in state.items)
|
||||
ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 72, height: 48,
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.play_circle_outline,
|
||||
color: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(item.title.isEmpty ? '视频 #${item.videoId}' : item.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
'${_fmtDuration(item.duration)} · ${_fmtSize(item.sizeBytes)}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: AppColors.textWeak),
|
||||
onPressed: () => _confirmDelete(context, ref, item),
|
||||
),
|
||||
onTap: () => _play(context, item),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,30 +32,46 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池
|
||||
ref.read(activeFeedStrategyProvider.notifier).state = _strategy;
|
||||
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
// 从登录页等返回时恢复播放:provider 全局常驻保留了列表,
|
||||
// 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_eventSub?.cancel();
|
||||
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
||||
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 退后台暂停、回前台恢复播放
|
||||
/// App 退后台挂起、回前台恢复播放(suspend/resume 防在途 open 竞态)
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
notifier.pool.pauseCurrent();
|
||||
notifier.pool.suspend();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
notifier.pool.playCurrent();
|
||||
// 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播)
|
||||
if (ref.read(feedTabVisibleProvider)) {
|
||||
unawaited(() async {
|
||||
await notifier.pool.resume();
|
||||
await notifier.rebindCurrent();
|
||||
}());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +89,23 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
|
||||
void _switchStrategy(FeedStrategy next) {
|
||||
if (next == _strategy) return;
|
||||
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
|
||||
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
|
||||
// 挂起旧策略池(防在途 open 完成后自动出声)
|
||||
ref.read(feedProvider(_strategy).notifier).pool.suspend();
|
||||
setState(() => _strategy = next);
|
||||
ref.read(activeFeedStrategyProvider.notifier).state = next;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
_pageController.jumpToPage(0);
|
||||
// jumpToPage 触发新策略 bindWindow 后再 resume:等其在途 open 完成再播放,
|
||||
// 同时解除新策略池可能残留的挂起态
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
unawaited(ref.read(feedProvider(_strategy).notifier).pool.resume());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
||||
@@ -142,6 +166,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
onLike: notifier.toggleLike,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
onComment: _openComments,
|
||||
onReturnFromPlayer: notifier.rebindCurrent,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -26,16 +28,24 @@ class _HomeShellState extends ConsumerState<HomeShell> {
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
if (i == _index) return;
|
||||
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
|
||||
// 切到非首页:挂起所有 Feed 策略的播放器池(IndexedStack 保活,
|
||||
// suspend 会同时阻止在途 open 完成后自动播放,杜绝后台幽灵声)
|
||||
if (i != 0) {
|
||||
for (final s in _feedStrategies) {
|
||||
ref.read(feedProvider(s).notifier).pool.pauseCurrent();
|
||||
ref.read(feedProvider(s).notifier).pool.suspend();
|
||||
}
|
||||
}
|
||||
ref.read(feedTabVisibleProvider.notifier).state = i == 0;
|
||||
setState(() => _index = i);
|
||||
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
|
||||
// 切回首页:只恢复当前选中策略的池(其余保持挂起)
|
||||
if (i == 0) {
|
||||
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent();
|
||||
final active = ref.read(activeFeedStrategyProvider);
|
||||
final notifier = ref.read(feedProvider(active).notifier);
|
||||
unawaited(() async {
|
||||
await notifier.pool.resume();
|
||||
// 兜底:挂起期间首屏 bind 可能尚未发生,补一次当前页绑定播放
|
||||
await notifier.rebindCurrent();
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/config/test_stream.dart';
|
||||
import '../../core/media/download_manager.dart';
|
||||
import '../../core/media/local_media_server.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/storage/watch_dedup_store.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
@@ -100,7 +102,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
}
|
||||
|
||||
void _open(VideoItem item) {
|
||||
_player.open(Media(resolvePlayUrl(item)), play: true);
|
||||
// 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存
|
||||
_player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))),
|
||||
play: true);
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
@@ -317,7 +321,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
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,
|
||||
@@ -395,21 +402,64 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
_share();
|
||||
},
|
||||
),
|
||||
|
||||
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
|
||||
Builder(builder: (_) {
|
||||
final dm = ref.read(downloadManagerProvider);
|
||||
final downloaded =
|
||||
dm.items.any((e) => e.videoId == _currentItem.videoId);
|
||||
final downloading =
|
||||
dm.progress.containsKey(_currentItem.videoId);
|
||||
final isLocal = _currentItem.videoUrl.contains('127.0.0.1');
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(downloaded || isLocal
|
||||
? Icons.check_circle_outline
|
||||
: Icons.download_outlined),
|
||||
title: Text(downloaded || isLocal
|
||||
? '已下载'
|
||||
: (downloading ? '下载中…' : '下载')),
|
||||
enabled: !downloaded && !downloading && !isLocal,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
|
||||
ref
|
||||
.read(downloadManagerProvider.notifier)
|
||||
.start(_currentItem)
|
||||
.catchError((_) {
|
||||
if (mounted) showTopToast(context, '下载失败,请重试');
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 进度条 ----------
|
||||
|
||||
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
|
||||
Duration get _effectiveDuration => _duration.inMilliseconds > 0
|
||||
? _duration
|
||||
: Duration(seconds: _currentItem.duration);
|
||||
|
||||
double get _sliderValue {
|
||||
if (_seeking) return _seekValue;
|
||||
if (_duration.inMilliseconds <= 0) return 0;
|
||||
return (_position.inMilliseconds / _duration.inMilliseconds)
|
||||
if (_effectiveDuration.inMilliseconds <= 0) return 0;
|
||||
return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
|
||||
.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
static String _fmtDuration(Duration d) {
|
||||
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';
|
||||
}
|
||||
|
||||
void _onSeekStart(double v) {
|
||||
setState(() {
|
||||
_seeking = true;
|
||||
@@ -423,9 +473,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
|
||||
void _onSeekEnd(double v) {
|
||||
setState(() => _seeking = false);
|
||||
if (_duration.inMilliseconds > 0) {
|
||||
if (_effectiveDuration.inMilliseconds > 0) {
|
||||
final target = Duration(
|
||||
milliseconds: (v * _duration.inMilliseconds).round(),
|
||||
milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
|
||||
);
|
||||
_player.seek(target);
|
||||
}
|
||||
@@ -591,14 +641,45 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- 进度条:抖音风格一条细线 ----------
|
||||
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0,
|
||||
child: _ThinProgressBar(
|
||||
value: _sliderValue,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 时间刻度:左当前(拖动时为目标位置),右总时长
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_fmtDuration(_seeking
|
||||
? Duration(
|
||||
milliseconds: (_seekValue *
|
||||
_effectiveDuration.inMilliseconds)
|
||||
.round())
|
||||
: _position),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
Text(
|
||||
_fmtDuration(_effectiveDuration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ThinProgressBar(
|
||||
value: _sliderValue,
|
||||
onChanged: _onSeek,
|
||||
onChangeStart: _onSeekStart,
|
||||
onChangeEnd: _onSeekEnd,
|
||||
totalMs: _effectiveDuration.inMilliseconds,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -648,7 +729,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
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,
|
||||
@@ -744,6 +827,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -757,6 +841,7 @@ class _ThinProgressBar extends StatefulWidget {
|
||||
required this.onChanged,
|
||||
required this.onChangeStart,
|
||||
required this.onChangeEnd,
|
||||
this.totalMs = 0,
|
||||
});
|
||||
|
||||
final double value;
|
||||
@@ -764,10 +849,22 @@ class _ThinProgressBar extends StatefulWidget {
|
||||
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;
|
||||
@@ -805,19 +902,48 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
|
||||
_start(d.localPosition, w);
|
||||
},
|
||||
onTapUp: (_) => _end(w),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: _dragging ? 4 : 2,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: _dragging ? 4 : 2,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressPainter(
|
||||
value: _dragging ? _dragValue : widget.value,
|
||||
dragging: _dragging,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 拖动中的时间气泡(跟随 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -142,6 +142,11 @@ class _MineScreenState extends ConsumerState<MineScreen> {
|
||||
label: '观看历史',
|
||||
onTap: () => context.push('/mine/history'),
|
||||
),
|
||||
_Entry(
|
||||
icon: Icons.download_outlined,
|
||||
label: '已下载',
|
||||
onTap: () => context.push('/mine/downloads'),
|
||||
),
|
||||
_Entry(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: '钱包',
|
||||
|
||||
Reference in New Issue
Block a user