import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:media_kit/media_kit.dart'; 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'; import '../../models/video_item.dart'; import '../../providers/auth_provider.dart'; import '../../services/video_service.dart'; import '../feed/widgets/comment_sheet.dart'; import '../feed/widgets/feed_overlays.dart'; import '../feed/widgets/interaction_bar.dart'; import '../feed/widgets/video_info_section.dart'; /// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用): /// 与 Feed 页体验一致:右侧互动栏、左下信息区、双击点赞、长按倍速/分享、 /// 抖音风格细进度条、系列视频支持上下划切换集数 class SimplePlayerScreen extends ConsumerStatefulWidget { const SimplePlayerScreen({super.key, required this.item}); final VideoItem item; @override ConsumerState createState() => _SimplePlayerScreenState(); } class _SimplePlayerScreenState extends ConsumerState { late Player _player = Player(); late VideoController _controller = VideoController(_player); Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _playing = true; bool _showPauseIcon = false; Timer? _pauseIconTimer; bool _seeking = false; double _seekValue = 0; // 系列 Map? _seriesData; List _episodeItems = []; late PageController _pageController; int _currentPage = 0; // 当前播放的 item(切集后更新,供互动栏/信息区用) late VideoItem _currentItem = widget.item; // 互动状态(切集后从后端重新拉取) bool _isLiked = false; bool _isFavorited = false; int _likeCount = 0; int _favoriteCount = 0; int _commentCount = 0; StreamSubscription? _posSub; StreamSubscription? _durSub; static const _rates = [1.0, 1.5, 2.0, 3.0]; double _rate = 1.0; bool get _hasSeries => _seriesData != null && (_seriesData!['has_series'] as bool? ?? false); List get _episodes => (_seriesData?['episodes'] as List?) ?? const []; String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? ''; @override void initState() { super.initState(); _open(widget.item); _reportWatch(); _loadSeries(); _loadInteraction(); _subscribePlayer(); } // ---------- 播放器 ---------- void _subscribePlayer() { _posSub = _player.stream.position.listen((p) { if (!mounted || _seeking) return; setState(() => _position = p); }); _durSub = _player.stream.duration.listen((d) { if (!mounted) return; setState(() => _duration = d); }); } void _open(VideoItem item) { // 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存 _player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))), play: true); } void _toggle() { _player.playOrPause(); setState(() { _showPauseIcon = !_playing; _playing = !_playing; }); _pauseIconTimer?.cancel(); _pauseIconTimer = Timer(const Duration(milliseconds: 600), () { if (mounted) setState(() => _showPauseIcon = false); }); } // ---------- 倍速 ---------- void _setRate(double r) { _player.setRate(r); setState(() => _rate = r); } String get _rateLabel => _rate == _rate.roundToDouble() ? '${_rate.toInt()}X' : '${_rate}X'; // ---------- 互动 ---------- Future _loadInteraction() async { // 从后端拉当前视频的点赞/收藏状态 try { final data = await VideoService.fetchDetail(_currentItem.videoId); if (!mounted) return; setState(() { _isLiked = (data['is_liked'] as bool?) ?? false; _isFavorited = (data['is_favorited'] as bool?) ?? false; _likeCount = (data['like_count'] as num?)?.toInt() ?? 0; _favoriteCount = (data['favorite_count'] as num?)?.toInt() ?? 0; _commentCount = (data['comment_count'] as num?)?.toInt() ?? 0; }); } catch (_) { // 游客或未登录时忽略,用默认值 } } void _onLike() { if (!ref.read(authProvider).isLoggedIn) { context.go('/login'); return; } setState(() { _isLiked = !_isLiked; _likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1; }); VideoService.toggleLike(_currentItem.videoId).catchError((_) { setState(() { _isLiked = !_isLiked; _likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1; }); return {}; }); } void _onFavorite() { if (!ref.read(authProvider).isLoggedIn) { context.go('/login'); return; } setState(() { _isFavorited = !_isFavorited; _favoriteCount = _isFavorited ? _favoriteCount + 1 : _favoriteCount - 1; }); VideoService.toggleFavorite(_currentItem.videoId).catchError((_) { setState(() { _isFavorited = !_isFavorited; _favoriteCount = _isFavorited ? _favoriteCount + 1 : _favoriteCount - 1; }); return {}; }); } Future _openComments() async { _player.pause(); await showCommentSheet( context, videoId: _currentItem.videoId, onCountChanged: (delta) => setState(() => _commentCount = (_commentCount + delta).clamp(0, 1 << 31)), ); if (mounted) _player.play(); } void _share() { Share.share( '${_currentItem.title}\n${_currentItem.videoUrl}', subject: _currentItem.title, ); } // ---------- 系列 ---------- Future _loadSeries() async { final data = await VideoService.fetchVideoSeries(_currentItem.videoId); if (!mounted) return; 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; 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 == _currentItem.videoId, ); if (_currentPage < 0) _currentPage = 0; _pageController = PageController(initialPage: _currentPage); } void _onPageChanged(int index) { if (index < 0 || index >= _episodeItems.length) return; _switchToEpisodeById(_episodeItems[index].videoId); } void _switchToEpisodeById(int videoId) { if (videoId == _currentItem.videoId) return; // 从 episodes 原始数据构造新 VideoItem final epData = _episodes.firstWhere( (e) => (e as Map)['video_id'] == videoId, orElse: () => null, ) as Map?; if (epData == null) return; final newItem = VideoItem( videoId: videoId, title: epData['title'] ?? '', coverUrl: epData['cover_url'] ?? '', videoUrl: epData['video_url'] ?? '', duration: (epData['duration'] as num?)?.toInt() ?? 0, author: widget.item.author, ); // 彻底销毁旧 Player,创建新 Player _posSub?.cancel(); _durSub?.cancel(); _player.dispose(); setState(() { _player = Player(); _controller = VideoController(_player); _currentItem = newItem; _position = Duration.zero; _duration = Duration.zero; _playing = true; _rate = 1.0; _open(newItem); }); _subscribePlayer(); _reportWatch(); _loadSeries(); _loadInteraction(); } // ---------- Watch ---------- Future _reportWatch() async { final dedup = WatchDedupStore.I; if (dedup.hasWatched(_currentItem.videoId)) return; await dedup.markWatched(_currentItem.videoId); try { final result = await VideoService.reportWatch(_currentItem.videoId); if (!mounted) return; if (result.isPointsCost) { showTopToast(context, '本次消耗 ${result.costPoints} 积分'); } } on ApiException catch (e) { if (!mounted) return; _player.pause(); if (e.isGuestLimit) { showGuestLimitDialog(context); } else if (e.isPointsInsufficient) { showPointsInsufficientDialog( context, (e.data?['points'] as num?)?.toInt() ?? 0, ); } else { showTopToast(context, e.message); } } catch (_) {} } // ---------- 长按 ---------- void _onLongPress() { showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetContext) => Theme( // 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题 data: ThemeData.light(), child: Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题 + 当前倍速 Row( children: [ const Icon(Icons.speed, color: AppColors.primary, size: 20), const SizedBox(width: 6), const Text('倍速播放', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), const Spacer(), Text('当前 $_rateLabel', 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: () { _setRate(r); Navigator.of(sheetContext).pop(); }, child: Container( height: 46, alignment: Alignment.center, decoration: BoxDecoration( color: (_rate - r).abs() < 0.01 ? AppColors.primary.withValues(alpha: 0.15) : Colors.grey[100], border: Border.all( color: (_rate - r).abs() < 0.01 ? AppColors.primary : Colors.transparent, ), borderRadius: BorderRadius.circular(8), ), child: Text( r == r.roundToDouble() ? '${r.toInt()}X' : '${r}X', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: (_rate - 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(); }, ), // 下载(已下载/下载中置灰;本地已下载视频不再重复下载) 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 (_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; _seekValue = v; }); } void _onSeek(double v) { setState(() => _seekValue = v); } void _onSeekEnd(double v) { setState(() => _seeking = false); if (_effectiveDuration.inMilliseconds > 0) { final target = Duration( milliseconds: (v * _effectiveDuration.inMilliseconds).round(), ); _player.seek(target); } } // ---------- 生命周期 ---------- @override void dispose() { _pauseIconTimer?.cancel(); _posSub?.cancel(); _durSub?.cancel(); _player.dispose(); if (_hasSeries && _episodeItems.isNotEmpty) { _pageController.dispose(); } super.dispose(); } // ---------- 构建 ---------- @override Widget build(BuildContext context) { // 互动栏 item(用当前播放的 item + 本地互动状态) final interactionItem = _currentItem.copyWith( isLiked: _isLiked, isFavorited: _isFavorited, likeCount: _likeCount, favoriteCount: _favoriteCount, commentCount: _commentCount, ); final content = GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggle, onDoubleTap: _onLike, onLongPress: _onLongPress, child: Stack( fit: StackFit.expand, children: [ // 视频画面 Center( child: Video( controller: _controller, controls: NoVideoControls, fit: BoxFit.contain, ), ), // 中央播放/暂停图标(短暂显示) if (_showPauseIcon) const IgnorePointer( child: Center( child: Icon( Icons.play_circle_outline, size: 72, color: Colors.white70, ), ), ), // 底部渐变(提升文字可读性) const Positioned( left: 0, right: 0, bottom: 0, height: 200, child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black54], ), ), ), ), ), // ---------- 顶部:返回 + 倍速 ---------- SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const BackButton(color: Colors.white), // 倍速按钮 GestureDetector( onTap: _onLongPress, child: Container( margin: const EdgeInsets.only(right: 16), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.black45, borderRadius: BorderRadius.circular(8), ), child: Text( _rateLabel, style: const TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), // ---------- 右侧互动栏 ---------- Positioned( right: 8, bottom: 96, child: InteractionBar( item: interactionItem, onLike: (_) => _onLike(), onFavorite: (_) => _onFavorite(), onComment: (_) => _openComments(), ), ), // ---------- 左下信息区 ---------- Positioned( left: 12, right: 88, bottom: 24, 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: _currentItem), ], ), ), // ---------- 进度条:抖音风格一条细线 + 时间刻度 ---------- Positioned( left: 0, right: 0, bottom: 0, 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, ), ], ), ), ], ), ); // 系列视频:用 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, ); } // ---------- 系列列表弹窗 ---------- void _showSeriesSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) => Theme( data: ThemeData.light(), child: 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; final isCurrent = ep['video_id'] == _currentItem.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: () { Navigator.of(context).pop(); _switchToEpisodeById(ep['video_id'] as int); }, ); }, ), ), ], ), ), ), ); } } /// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白), /// 拖动时自动变厚显示 thumb,松开后恢复细线。 class _ThinProgressBar extends StatefulWidget { const _ThinProgressBar({ required this.value, required this.onChanged, required this.onChangeStart, required this.onChangeEnd, this.totalMs = 0, }); final double value; final ValueChanged onChanged; final ValueChanged onChangeStart; final ValueChanged 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; void _start(Offset local, double width) { setState(() => _dragging = true); widget.onChangeStart(widget.value); _update(local, width); } void _update(Offset local, double width) { final v = (local.dx / width).clamp(0.0, 1.0); _dragValue = v; widget.onChanged(v); } void _end(double width) { setState(() => _dragging = false); final v = _dragValue.clamp(0.0, 1.0); widget.onChangeEnd(v); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (_, constraints) { final w = constraints.maxWidth; return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: (d) => _start(d.localPosition, w), onHorizontalDragUpdate: (d) => _update(d.localPosition, w), onHorizontalDragEnd: (_) => _end(w), onTapDown: (d) { setState(() => _dragging = true); _start(d.localPosition, w); }, onTapUp: (_) => _end(w), child: SizedBox( height: 32, // 触摸热区 32px(比视觉线条大,提升交互) 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), ), ), ), ], ), ), ); }, ); } } class _ProgressPainter extends CustomPainter { _ProgressPainter({required this.value, required this.dragging}); final double value; final bool dragging; @override void paint(Canvas canvas, Size size) { final w = size.width; final h = size.height; final y = h / 2; // 背景(未播放) final bgPaint = Paint() ..color = Colors.white.withValues(alpha: 0.2) ..strokeWidth = h ..strokeCap = StrokeCap.round; canvas.drawLine(Offset(0, y), Offset(w, y), bgPaint); // 已播放 final fgPaint = Paint() ..color = Colors.white ..strokeWidth = h ..strokeCap = StrokeCap.round; final playedX = (w * value).clamp(0.0, w); canvas.drawLine(Offset(0, y), Offset(playedX, y), fgPaint); // 拖动时显示 thumb if (dragging) { final thumbPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; canvas.drawCircle(Offset(playedX, y), 6, thumbPaint); } } @override bool shouldRepaint(covariant _ProgressPainter oldDelegate) => oldDelegate.value != value || oldDelegate.dragging != dragging; }