import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; import '../providers/file_provider.dart'; class MediaPlayerDialog extends ConsumerStatefulWidget { final CloudFile file; const MediaPlayerDialog({super.key, required this.file}); @override ConsumerState createState() => _MediaPlayerDialogState(); } class _MediaPlayerDialogState extends ConsumerState { late final Player _player; late final VideoController _controller; StreamSubscription? _videoParamsSubscription; Timer? _videoDimensionFallbackTimer; final _videoKey = GlobalKey(); late CloudFile _currentFile; String? _error; bool _loading = true; var _videoAspectRatio = 3 / 2; var _hasVideoDimensions = false; bool _showEpisodes = false; List _episodes = const []; List _subtitleCandidates = const []; @override void initState() { super.initState(); _player = Player(); _controller = VideoController(_player); unawaited( _controller.platform.future.then( (_) {}, onError: (Object error, StackTrace stackTrace) { if (!mounted) return; setState(() { _error = error is MissingPluginException ? '内置播放器插件未加载。请完全停止当前调试进程后重新运行应用。' : '内置视频输出初始化失败:$error'; _loading = false; }); }, ), ); _videoParamsSubscription = _player.stream.videoParams.listen( _updateVideoDimensions, ); _currentFile = widget.file; Future.microtask(_open); } Future _open([CloudFile? file]) async { if (file != null && mounted) { setState(() { _currentFile = file; _loading = true; _error = null; }); } try { final url = await ref .read(fileProvider.notifier) .playbackUrl(_currentFile); await _player.open(Media(url.toString()), play: true); _videoDimensionFallbackTimer?.cancel(); _videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () { if (mounted && _loading && _error == null) { setState(() => _loading = false); } }); final siblings = await ref .read(fileProvider.notifier) .siblingMediaFiles(_currentFile); _episodes = _matchingEpisodes(_currentFile, siblings); final folderFiles = await ref .read(fileProvider.notifier) .siblingFiles(_currentFile); _subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles); } catch (error) { if (mounted) { setState(() { _error = error.toString(); _loading = false; }); } } finally {} } void _updateVideoDimensions(VideoParams params) { final width = params.dw ?? params.w; final height = params.dh ?? params.h; if (width == null || height == null || width <= 0 || height <= 0) return; final aspect = params.aspect ?? width / height; if (aspect < 0.25 || aspect > 4 || !mounted) return; _videoDimensionFallbackTimer?.cancel(); setState(() { _videoAspectRatio = aspect; _hasVideoDimensions = true; }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _error == null) setState(() => _loading = false); }); } List _matchingEpisodes(CloudFile file, List siblings) { final current = ParsedMediaName.parse(file.name); final title = _normalizedTitle(current.title); final values = siblings.where((candidate) { final parsed = ParsedMediaName.parse(candidate.name); return parsed.isEpisode && _normalizedTitle(parsed.title) == title; }).toList(); if (!values.any((candidate) => candidate.id == file.id)) values.add(file); values.sort((a, b) { final first = ParsedMediaName.parse(a.name); final second = ParsedMediaName.parse(b.name); final season = (first.season ?? 1).compareTo(second.season ?? 1); if (season != 0) return season; return (first.episode ?? 9999).compareTo(second.episode ?? 9999); }); return values; } String _normalizedTitle(String title) => title.toLowerCase().replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fff]'), ''); List _matchingSubtitles(CloudFile file, List siblings) { const extensions = {'srt', 'ass', 'ssa', 'sub', 'vtt', 'sup'}; final title = _normalizedTitle(ParsedMediaName.parse(file.name).title); return siblings.where((candidate) { final extension = candidate.name.split('.').last.toLowerCase(); if (!extensions.contains(extension)) return false; final candidateTitle = _normalizedTitle( ParsedMediaName.parse(candidate.name).title, ); return candidateTitle.contains(title) || title.contains(candidateTitle); }).toList(); } Future _searchDirectorySubtitles() async { if (_subtitleCandidates.isEmpty) { await showShadDialog( context: context, builder: (context) => const ShadDialog( title: Text('同目录字幕'), description: Text('缓存的当前目录中没有找到匹配的字幕文件。'), ), ); return; } final selected = await showShadDialog( context: context, builder: (context) => _SubtitlePickerDialog( title: '同目录字幕 (${_subtitleCandidates.length})', subtitles: _subtitleCandidates, ), ); if (selected == null || !mounted) return; try { final url = await ref.read(fileProvider.notifier).playbackUrl(selected); await _player.setSubtitleTrack( SubtitleTrack.uri(url.toString(), title: selected.name), ); } catch (error) { if (mounted) setState(() => _error = '加载字幕失败:$error'); } } Future _loadLocalSubtitle() async { final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['srt', 'ass', 'ssa', 'sub', 'vtt', 'sup'], dialogTitle: '选择外挂字幕', ); final path = result?.files.single.path; if (path == null) return; await _player.setSubtitleTrack( SubtitleTrack.uri( Uri.file(path).toString(), title: result!.files.single.name, ), ); } @override void dispose() { _videoDimensionFallbackTimer?.cancel(); _videoParamsSubscription?.cancel(); _player.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final screen = MediaQuery.sizeOf(context); final sideWidth = _showEpisodes ? 250.0 : 0.0; final maxVideoWidth = math.max(360.0, screen.width - sideWidth - 120); final maxVideoHeight = math.max(260.0, screen.height - 290); final preferredWidth = _hasVideoDimensions ? math.min(900.0, _videoAspectRatio * maxVideoHeight) : 600.0; final videoWidth = preferredWidth.clamp(360.0, maxVideoWidth).toDouble(); final videoHeight = (videoWidth / _videoAspectRatio) .clamp(240.0, maxVideoHeight) .toDouble(); final contentWidth = videoWidth + sideWidth; return ShadDialog( constraints: BoxConstraints(maxWidth: contentWidth), title: Text( _currentFile.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), description: const Text('内置播放器'), actions: [ ShadButton.outline( onPressed: _episodes.length < 2 ? null : () => setState(() => _showEpisodes = !_showEpisodes), leading: const Icon(Icons.format_list_bulleted_rounded, size: 16), child: const Text('同目录剧集'), ), ShadButton.outline( onPressed: () => Navigator.of(context).pop(), child: const Text('关闭'), ), ], child: Material( color: Colors.transparent, child: AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, child: SizedBox( width: contentWidth, height: videoHeight, child: Row( children: [ SizedBox( width: videoWidth, child: ClipRRect( borderRadius: BorderRadius.circular(6), child: ColoredBox( color: Colors.black, child: _loading ? Center( child: CircularProgressIndicator( strokeWidth: 3, color: cs.primary, ), ) : _error != null ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( '无法播放:$_error', textAlign: TextAlign.center, style: TextStyle(color: cs.destructive), ), ), ) : Stack( children: [ Positioned.fill( child: Video( key: _videoKey, controller: _controller, controls: NoVideoControls, ), ), Positioned.fill( child: _MediaPlaybackControls( player: _player, controller: _controller, onSearchSubtitles: _searchDirectorySubtitles, onLoadLocalSubtitle: _loadLocalSubtitle, onToggleFullscreen: () => _videoKey.currentState ?.toggleFullscreen() ?? Future.value(), ), ), ], ), ), ), ), if (_showEpisodes) ...[ const SizedBox(width: 10), SizedBox(width: 240, child: _episodeList(cs)), ], ], ), ), ), ), ); } Widget _episodeList(ShadColorScheme cs) { return DecoratedBox( decoration: BoxDecoration( color: cs.card, borderRadius: BorderRadius.circular(6), border: Border.all(color: cs.border), ), child: ListView.builder( itemCount: _episodes.length, itemBuilder: (context, index) { final episode = _episodes[index]; final parsed = ParsedMediaName.parse(episode.name); final selected = episode.id == _currentFile.id; return Material( color: Colors.transparent, child: InkWell( onTap: selected ? null : () => _open(episode), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 9, ), color: selected ? cs.primary.withValues(alpha: 0.10) : null, child: Row( children: [ SizedBox( width: 42, child: Text( 'S${(parsed.season ?? 1).toString().padLeft(2, '0')}E${(parsed.episode ?? 0).toString().padLeft(2, '0')}', style: TextStyle(fontSize: 11, color: cs.primary), ), ), Expanded( child: Text( episode.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, color: cs.foreground, ), ), ), ], ), ), ), ); }, ), ); } } class _MediaPlaybackControls extends StatefulWidget { final Player player; final VideoController controller; final Future Function() onSearchSubtitles; final Future Function() onLoadLocalSubtitle; final Future Function() onToggleFullscreen; const _MediaPlaybackControls({ required this.player, required this.controller, required this.onSearchSubtitles, required this.onLoadLocalSubtitle, required this.onToggleFullscreen, }); @override State<_MediaPlaybackControls> createState() => _MediaPlaybackControlsState(); } class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { double? _scrubbingValue; Future _seekBy(int seconds) async { final position = widget.player.state.position + Duration(seconds: seconds); final duration = widget.player.state.duration; final target = position < Duration.zero ? Duration.zero : (duration > Duration.zero && position > duration ? duration : position); await widget.player.seek(target); } Future _selectRate() async { final rate = await showShadDialog( context: context, builder: (context) => ShadDialog( title: const Text('播放速度'), child: Wrap( spacing: 8, runSpacing: 8, children: [ for (final value in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) ShadButton.outline( onPressed: () => Navigator.of(context).pop(value), child: Text('${value}x'), ), ], ), ), ); if (rate != null) await widget.player.setRate(rate); } Future _selectAudio() async { final tracks = widget.player.state.tracks.audio; final track = await showShadDialog( context: context, builder: (context) => _TrackPickerDialog( title: '选择音轨', tracks: tracks, selectedID: widget.player.state.track.audio.id, label: _trackLabel, id: (track) => track.id, ), ); if (track != null) await widget.player.setAudioTrack(track); } Future _selectSubtitle() async { final tracks = widget.player.state.tracks.subtitle; final track = await showShadDialog( context: context, builder: (context) => _TrackPickerDialog( title: '选择字幕', tracks: tracks, selectedID: widget.player.state.track.subtitle.id, label: _trackLabel, id: (track) => track.id, ), ); if (track != null) await widget.player.setSubtitleTrack(track); } String _trackLabel(dynamic track) { if (track.id == 'no') return '关闭'; if (track.id == 'auto') return '自动'; final values = [ track.title, track.language, track.codec, ].whereType().where((value) => value.isNotEmpty).toSet(); return values.isEmpty ? '轨道 ${track.id}' : values.join(' · '); } @override Widget build(BuildContext context) { return StreamBuilder( stream: widget.player.stream.position, initialData: widget.player.state.position, builder: (context, snapshot) { final position = snapshot.data ?? Duration.zero; final duration = widget.player.state.duration; final max = duration.inMilliseconds.toDouble(); final current = _scrubbingValue ?? position.inMilliseconds.toDouble(); final value = max <= 0 ? 0.0 : current.clamp(0.0, max); final cs = ShadTheme.of(context).colorScheme; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.space): widget.player.playOrPause, const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _seekBy(-5), const SingleActivator(LogicalKeyboardKey.arrowRight): () => _seekBy(5), const SingleActivator(LogicalKeyboardKey.keyF): widget.onToggleFullscreen, }, child: Focus( autofocus: true, child: GestureDetector( behavior: HitTestBehavior.translucent, onDoubleTap: widget.onToggleFullscreen, onTap: () => widget.player.playOrPause(), child: Stack( children: [ Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: const EdgeInsets.fromLTRB(12, 28, 12, 10), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Color(0xE6000000)], ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 3, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 6, ), activeTrackColor: cs.primary, inactiveTrackColor: Colors.white.withValues( alpha: 0.28, ), thumbColor: cs.primary, ), child: Slider( value: value, min: 0, max: max <= 0 ? 1 : max, onChangeStart: max <= 0 ? null : (next) => setState(() => _scrubbingValue = next), onChanged: max <= 0 ? null : (next) => setState(() => _scrubbingValue = next), onChangeEnd: max <= 0 ? null : (next) async { await widget.player.seek( Duration(milliseconds: next.round()), ); if (mounted) { setState(() => _scrubbingValue = null); } }, ), ), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ _controlButton( widget.player.state.playing ? Icons.pause_rounded : Icons.play_arrow_rounded, widget.player.playOrPause, ), _controlButton( Icons.replay_10_rounded, () => _seekBy(-10), ), _controlButton( Icons.forward_10_rounded, () => _seekBy(10), ), const SizedBox(width: 6), Text( '${_formatTime(position)} / ${_formatTime(duration)}', style: const TextStyle( color: Colors.white, fontSize: 12, ), ), const SizedBox(width: 20), _menuButton( '${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x', _selectRate, ), _menuButton('音轨', _selectAudio), _menuButton('字幕', _selectSubtitle), _menuButton('搜字幕', widget.onSearchSubtitles), _menuButton('加载字幕', widget.onLoadLocalSubtitle), _controlButton( widget.player.state.volume <= 0 ? Icons.volume_off_rounded : Icons.volume_up_rounded, () => widget.player.setVolume( widget.player.state.volume <= 0 ? 100 : 0, ), ), SizedBox( width: 90, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, activeTrackColor: Colors.white, inactiveTrackColor: Colors.white .withValues(alpha: 0.28), thumbColor: Colors.white, ), child: Slider( value: widget.player.state.volume.clamp( 0.0, 100.0, ), min: 0, max: 100, onChanged: widget.player.setVolume, ), ), ), _controlButton( Icons.fullscreen_rounded, widget.onToggleFullscreen, ), ], ), ), ], ), ), ), ], ), ), ), ); }, ); } Widget _controlButton(IconData icon, Future Function() onPressed) { return IconButton( tooltip: '', onPressed: onPressed, icon: Icon(icon, color: Colors.white, size: 23), ); } Widget _menuButton(String label, Future Function() onPressed) { return TextButton( onPressed: onPressed, style: TextButton.styleFrom( foregroundColor: Colors.white, minimumSize: const Size(44, 36), padding: const EdgeInsets.symmetric(horizontal: 6), ), child: Text(label, style: const TextStyle(fontSize: 12)), ); } String _formatTime(Duration value) { final hours = value.inHours; final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); return hours > 0 ? '$hours:$minutes:$seconds' : '$minutes:$seconds'; } } class _TrackPickerDialog extends StatelessWidget { final String title; final List tracks; final String selectedID; final String Function(T track) label; final String Function(T track) id; const _TrackPickerDialog({ required this.title, required this.tracks, required this.selectedID, required this.label, required this.id, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return ShadDialog( title: Text(title), child: Material( color: Colors.transparent, child: SizedBox( width: 360, child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final track in tracks) ListTile( onTap: () => Navigator.of(context).pop(track), title: Text(label(track)), trailing: id(track) == selectedID ? Icon(Icons.check_rounded, color: cs.primary) : null, ), ], ), ), ), ); } } class _SubtitlePickerDialog extends StatelessWidget { final String title; final List subtitles; const _SubtitlePickerDialog({required this.title, required this.subtitles}); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return ShadDialog( title: Text(title), description: const Text('从当前视频同目录的缓存文件中匹配'), child: Material( color: Colors.transparent, child: SizedBox( width: 520, height: 360, child: ListView.separated( itemCount: subtitles.length, separatorBuilder: (_, _) => Divider(height: 1, color: cs.border), itemBuilder: (context, index) { final subtitle = subtitles[index]; return ListTile( onTap: () => Navigator.of(context).pop(subtitle), leading: Icon(Icons.closed_caption_rounded, color: cs.primary), title: Text( subtitle.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text(subtitle.formattedSize), ); }, ), ), ), ); } } class ExternalPlayerDialog extends ConsumerStatefulWidget { final CloudFile file; const ExternalPlayerDialog({super.key, required this.file}); @override ConsumerState createState() => _ExternalPlayerDialogState(); } class _ExternalPlayerDialogState extends ConsumerState { late final Future> _players; @override void initState() { super.initState(); _players = ref.read(fileProvider.notifier).availableExternalPlayers(); } @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return ShadDialog( title: const Text('选择外部播放器'), description: Text( widget.file.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), actions: [ ShadButton.outline( onPressed: () => Navigator.of(context).pop(), child: const Text('取消'), ), ], child: SizedBox( width: 420, child: FutureBuilder>( future: _players, builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox( height: 100, child: Center(child: ShadProgress()), ); } final players = snapshot.data!; if (players.isEmpty) { return Column( mainAxisSize: MainAxisSize.min, children: [ Text( '未发现支持的外部播放器', style: TextStyle(color: cs.mutedForeground), ), const SizedBox(height: 12), ShadButton.outline( onPressed: () { ref .read(fileProvider.notifier) .playWithExternalPlayer(widget.file); Navigator.of(context).pop(); }, leading: const Icon(Icons.play_arrow_rounded, size: 16), child: const Text('使用系统默认播放器'), ), ], ); } return Wrap( spacing: 8, runSpacing: 8, children: [ for (final player in players) ShadButton.outline( onPressed: () { ref .read(fileProvider.notifier) .playWithExternalPlayer(widget.file, player); Navigator.of(context).pop(); }, leading: const Icon( Icons.play_circle_outline_rounded, size: 16, ), child: Text(player.name), ), ], ); }, ), ), ); } }