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'; /// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用): /// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数) 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); bool _playing = true; Map? _seriesData; // null=未加载, {}=无系列 @override void initState() { super.initState(); _open(widget.item); _reportWatch(); _loadSeries(); } 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); } @override void dispose() { _player.dispose(); super.dispose(); } 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 (_) {} } void _toggle() { setState(() => _playing = !_playing); _playing ? _player.play() : _player.pause(); } 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(); // 关抽屉 final newItem = VideoItem( videoId: ep['video_id'], title: ep['title'] ?? '', coverUrl: ep['cover_url'] ?? '', videoUrl: ep['video_url'] ?? '', duration: (ep['duration'] as num?)?.toInt() ?? 0, author: widget.item.author, // 系列一般作者相同 ); setState(() { _player.dispose(); _player = Player(); _controller = VideoController(_player); _open(newItem); // 重置 seriesData 重新加载 _seriesData = null; }); _reportWatch(); _loadSeries(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: GestureDetector( onTap: _toggle, child: Stack( fit: StackFit.expand, children: [ Center( child: Video( controller: _controller, controls: NoVideoControls, fit: BoxFit.contain, ), ), if (!_playing) 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), if (_hasSeries) IconButton( icon: const Icon(Icons.menu_book, color: Colors.white), tooltip: '查看系列', onPressed: _showSeriesSheet, ), ], ), ), // 底部标题/作者 Positioned( left: 12, right: 12, bottom: 24, child: IgnorePointer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '@${widget.item.author.nickname}', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white, ), ), const SizedBox(height: 6), Text( widget.item.title, style: const TextStyle( fontSize: 14, color: Colors.white70), ), ], ), ), ), ], ), ), ); } }