import 'dart:async'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import '../../core/config/test_stream.dart'; import '../../core/network/api_exception.dart'; import '../../core/theme/app_theme.dart'; import '../../core/storage/watch_dedup_store.dart'; import '../../models/video_item.dart'; import '../../services/video_service.dart'; import '../feed/widgets/feed_overlays.dart'; /// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用): /// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek /// - 顶部返回 + 倍速 + 系列 /// - 点屏幕中央切换播放/暂停(图标短暂淡出) /// - 系列视频支持上下划切换上一集/下一集 class SimplePlayerScreen extends StatefulWidget { const SimplePlayerScreen({super.key, required this.item}); final VideoItem item; @override State createState() => _SimplePlayerScreenState(); } class _SimplePlayerScreenState extends State { late Player _player = Player(); late VideoController _controller = VideoController(_player); Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _playing = true; double _rate = 1.0; bool _seeking = false; Map? _seriesData; Timer? _pauseIconTimer; bool _showPauseIcon = false; StreamSubscription? _posSub; StreamSubscription? _durSub; // 系列切换(上下划) late PageController _pageController; int _currentPage = 0; List _episodeItems = []; @override void initState() { super.initState(); _open(widget.item); _reportWatch(); _loadSeries(); _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) { _player.open(Media(resolvePlayUrl(item)), play: true); } Future _loadSeries() async { final data = await VideoService.fetchVideoSeries(widget.item.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 == widget.item.videoId, ); if (_currentPage < 0) _currentPage = 0; _pageController = PageController(initialPage: _currentPage); } @override void dispose() { _pauseIconTimer?.cancel(); _posSub?.cancel(); _durSub?.cancel(); _player.dispose(); if (_hasSeries) _pageController.dispose(); super.dispose(); } // ---------- 播放控制 ---------- void _toggle() { setState(() => _playing = !_playing); _playing ? _player.play() : _player.pause(); setState(() => _showPauseIcon = true); _pauseIconTimer?.cancel(); _pauseIconTimer = Timer(const Duration(milliseconds: 600), () { if (mounted) setState(() => _showPauseIcon = false); }); } // ---------- 进度条 seek ---------- double get _sliderValue { final total = _duration.inMilliseconds; if (total <= 0) return 0; final cur = _position.inMilliseconds.clamp(0, total); return cur / total; } void _onSeekStart(double v) { _seeking = true; } void _onSeek(double v) { setState(() {}); } void _onSeekEnd(double v) { _seeking = false; final totalMs = _duration.inMilliseconds; if (totalMs > 0) { _player.seek(Duration(milliseconds: (v * totalMs).round())); } } // ---------- 倍速 ---------- static const _rates = [1.0, 1.25, 1.5, 2.0]; void _setRate(double r) { _player.setRate(r); setState(() => _rate = r); } // ---------- 系列 ---------- bool get _hasSeries => _seriesData != null && (_seriesData!['has_series'] as bool? ?? false); List get _episodes => (_seriesData?['episodes'] as List?) ?? const []; void _showSeriesSheet() { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => DraggableScrollableSheet( initialChildSize: 0.65, minChildSize: 0.4, maxChildSize: 0.9, builder: (_, controller) => Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ Center( child: 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: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: (_seriesData!['series']['cover_url'] as String?) ?.isNotEmpty == true ? Image.network( _seriesData!['series']['cover_url'], width: 64, height: 64, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const SizedBox(width: 64, height: 64), ) : Container( width: 64, height: 64, color: Colors.grey[200]), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _seriesData!['series']['title'] ?? '', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( '共 ${_seriesData!['series']['total_episodes']} 集', style: TextStyle( color: Colors.grey[600], fontSize: 12), ), ], ), ), ], ), ), const Divider(height: 1), Expanded( child: GridView.builder( controller: controller, padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: 1, ), itemCount: _episodes.length, itemBuilder: (_, i) { final ep = _episodes[i] as Map; final isCurrent = ep['video_id'] == widget.item.videoId; return GestureDetector( onTap: () => _switchToEpisode(ep), child: Container( decoration: BoxDecoration( color: isCurrent ? AppColors.primary.withValues(alpha: 0.15) : Colors.grey[100], border: Border.all( color: isCurrent ? AppColors.primary : Colors.transparent), borderRadius: BorderRadius.circular(8), ), child: Center( child: Text( ep['episode_no']?.toString() ?? '${i + 1}', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: isCurrent ? AppColors.primary : Colors.black87, ), ), ), ), ); }, ), ), ], ), ), ), ); } void _switchToEpisode(Map ep) { 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?; 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); _position = Duration.zero; _duration = Duration.zero; _playing = true; _open(newItem); }); _subscribePlayer(); _reportWatch(); _loadSeries(); } // ---------- 上下划切换(系列) ---------- void _onPageChanged(int index) { if (index == _currentPage) return; _currentPage = index; _switchToEpisodeById(_episodeItems[index].videoId); } // ---------- Watch ---------- Future _reportWatch() async { final dedup = WatchDedupStore.I; if (dedup.hasWatched(widget.item.videoId)) return; await dedup.markWatched(widget.item.videoId); try { final result = await VideoService.reportWatch(widget.item.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 (_) {} } @override Widget build(BuildContext context) { final content = GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggle, 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, ), ), ), // ---------- 顶部:返回 + 倍速 + 系列 ---------- SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const BackButton(color: Colors.white), Row( mainAxisSize: MainAxisSize.min, children: [ // 倍速 PopupMenuButton( initialValue: _rate, icon: const Icon(Icons.speed, color: Colors.white), tooltip: '倍速', onSelected: _setRate, 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( left: 0, right: 0, bottom: 0, child: _ThinProgressBar( value: _sliderValue, onChanged: _onSeek, onChangeStart: _onSeekStart, onChangeEnd: _onSeekEnd, ), ), ], ), ); // 系列视频:用 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, ); } } /// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白), /// 拖动时自动变厚显示 thumb,松开后恢复细线。 class _ThinProgressBar extends StatefulWidget { const _ThinProgressBar({ required this.value, required this.onChanged, required this.onChangeStart, required this.onChangeEnd, }); final double value; final ValueChanged onChanged; final ValueChanged onChangeStart; final ValueChanged onChangeEnd; @override State<_ThinProgressBar> createState() => _ThinProgressBarState(); } 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: Container( height: 32, // 触摸热区 32px(比视觉线条大,提升交互) alignment: Alignment.bottomCenter, child: AnimatedContainer( duration: _dragging ? Duration.zero : const Duration(milliseconds: 150), curve: Curves.easeOut, height: _dragging ? 4.0 : 2.0, // 拖动时变厚 color: Colors.transparent, child: CustomPaint( painter: _ProgressPainter( value: _dragging ? _dragValue : widget.value, dragging: _dragging, ), size: Size(w, _dragging ? 20 : 4), ), ), ), ); }, ); } } 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 = dragging ? 3.0 : 2.0; final y = (size.height - h) / 2; // 背景:未播放部分(20% 透明白) final trackPaint = Paint() ..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22) ..strokeCap = StrokeCap.round ..strokeWidth = h; canvas.drawLine(Offset(0, y), Offset(w, y), trackPaint); // 已播放部分(白色) if (value > 0) { final playedPaint = Paint() ..color = Colors.white ..strokeCap = StrokeCap.round ..strokeWidth = h; canvas.drawLine(Offset(0, y), Offset(value * w, y), playedPaint); } // 拖动时的 thumb if (dragging) { canvas.drawCircle( Offset(value * w, y), 6, Paint() ..color = Colors.white ..style = PaintingStyle.fill, ); } } @override bool shouldRepaint(covariant _ProgressPainter old) => value != old.value || dragging != old.dragging; }