From bee146ec05011dad4eac74f787e858084e730041 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 18:08:41 +0800 Subject: [PATCH] fix player fullscreen controls and episode picker --- lib/pages/media_library_page.dart | 10 +- lib/widgets/media_player_dialog.dart | 212 ++++++++++++++++----------- 2 files changed, 136 insertions(+), 86 deletions(-) diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 8aa149e..3fee66e 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -447,7 +447,15 @@ class _MediaLibraryPageState extends ConsumerState { onBack: () => setState(() => _detailWork = null), onDownload: (item) => ref.read(fileProvider.notifier).downloadFile(item.file), - onPlay: (item) => unawaited(showMediaPlayerDialog(context, item.file)), + onPlay: (item) => unawaited( + showMediaPlayerDialog( + context, + item.file, + episodeCandidates: (current ?? _detailWork!).resources + .map((resource) => resource.file) + .toList(), + ), + ), onExternalPlay: (item) => showShadDialog( context: context, builder: (_) => ExternalPlayerDialog(file: item.file), diff --git a/lib/widgets/media_player_dialog.dart b/lib/widgets/media_player_dialog.dart index 0f250f2..4ba04e2 100644 --- a/lib/widgets/media_player_dialog.dart +++ b/lib/widgets/media_player_dialog.dart @@ -14,7 +14,11 @@ import '../models/cloud_file.dart'; import '../models/media_library.dart'; import '../providers/file_provider.dart'; -Future showMediaPlayerDialog(BuildContext context, CloudFile file) async { +Future showMediaPlayerDialog( + BuildContext context, + CloudFile file, { + List episodeCandidates = const [], +}) async { Future openExternalPlayer() async { await Future.delayed(Duration.zero); if (!context.mounted) return; @@ -27,8 +31,11 @@ Future showMediaPlayerDialog(BuildContext context, CloudFile file) async { try { await showShadDialog( context: context, - builder: (_) => - MediaPlayerDialog(file: file, onPlaybackFailure: openExternalPlayer), + builder: (_) => MediaPlayerDialog( + file: file, + episodeCandidates: episodeCandidates, + onPlaybackFailure: openExternalPlayer, + ), ); } catch (_) { await openExternalPlayer(); @@ -37,11 +44,13 @@ Future showMediaPlayerDialog(BuildContext context, CloudFile file) async { class MediaPlayerDialog extends ConsumerStatefulWidget { final CloudFile file; + final List episodeCandidates; final Future Function()? onPlaybackFailure; const MediaPlayerDialog({ super.key, required this.file, + this.episodeCandidates = const [], this.onPlaybackFailure, }); @@ -99,6 +108,7 @@ class _MediaPlayerDialogState extends ConsumerState { _updateVideoDimensions, ); _currentFile = widget.file; + _episodes = _matchingEpisodes(widget.file, widget.episodeCandidates); Future.microtask(_open); } @@ -130,11 +140,20 @@ class _MediaPlayerDialogState extends ConsumerState { final siblings = await ref .read(fileProvider.notifier) .siblingMediaFiles(_currentFile); - _episodes = _matchingEpisodes(_currentFile, siblings); + final episodeSources = { + for (final candidate in widget.episodeCandidates) + candidate.id: candidate, + for (final candidate in siblings) candidate.id: candidate, + }.values.toList(); final folderFiles = await ref .read(fileProvider.notifier) .siblingFiles(_currentFile); - _subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles); + if (mounted) { + setState(() { + _episodes = _matchingEpisodes(_currentFile, episodeSources); + _subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles); + }); + } } catch (error) { _handlePlaybackFailure(error, '内置播放器打开失败'); } @@ -206,25 +225,7 @@ class _MediaPlayerDialogState extends ConsumerState { }).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; + Future _setDirectorySubtitle(CloudFile selected) async { try { final url = await ref.read(fileProvider.notifier).playbackUrl(selected); await _player.setSubtitleTrack( @@ -340,8 +341,8 @@ class _MediaPlayerDialogState extends ConsumerState { controller: _controller, controls: (videoState) => _MediaPlaybackControls( player: _player, - fullscreen: videoState.isFullscreen(), - onSearchSubtitles: _searchDirectorySubtitles, + directorySubtitles: _subtitleCandidates, + onSelectDirectorySubtitle: _setDirectorySubtitle, onLoadLocalSubtitle: _loadLocalSubtitle, onToggleFullscreen: videoState.toggleFullscreen, ), @@ -419,15 +420,15 @@ class _MediaPlayerDialogState extends ConsumerState { class _MediaPlaybackControls extends StatefulWidget { final Player player; - final bool fullscreen; - final Future Function() onSearchSubtitles; + final List directorySubtitles; + final Future Function(CloudFile subtitle) onSelectDirectorySubtitle; final Future Function() onLoadLocalSubtitle; final Future Function() onToggleFullscreen; const _MediaPlaybackControls({ required this.player, - required this.fullscreen, - required this.onSearchSubtitles, + required this.directorySubtitles, + required this.onSelectDirectorySubtitle, required this.onLoadLocalSubtitle, required this.onToggleFullscreen, }); @@ -523,20 +524,105 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { final tracks = widget.player.state.tracks.subtitle; return ShadPopover( controller: _subtitlePopover, - popover: (_) => _trackPopover( - title: '字幕', - tracks: tracks, - selectedID: widget.player.state.track.subtitle.id, - onSelect: (track) async { - await widget.player.setSubtitleTrack(track); - _subtitlePopover.hide(); - if (mounted) setState(() {}); - }, - ), + popover: (_) => _subtitlePopoverContent(tracks), child: _menuButton('字幕', () async => _subtitlePopover.toggle()), ); } + Widget _subtitlePopoverContent(List tracks) { + final selectedID = widget.player.state.track.subtitle.id; + return SizedBox( + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('字幕', style: TextStyle(fontWeight: FontWeight.w700)), + if (tracks.isNotEmpty) ...[ + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final selected = track.id == selectedID; + return ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () async { + await widget.player.setSubtitleTrack(track); + _subtitlePopover.hide(); + if (mounted) setState(() {}); + }, + leading: Icon( + selected + ? Icons.check_rounded + : Icons.closed_caption_rounded, + size: 15, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _trackLabel(track), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ], + if (widget.directorySubtitles.isNotEmpty) ...[ + const Divider(height: 16), + Text( + '同目录字幕 (${widget.directorySubtitles.length})', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.directorySubtitles.length, + itemBuilder: (context, index) { + final subtitle = widget.directorySubtitles[index]; + return ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () async { + await widget.onSelectDirectorySubtitle(subtitle); + _subtitlePopover.hide(); + }, + leading: const Icon(Icons.search_rounded, size: 15), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + subtitle.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ], + const Divider(height: 16), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () async { + _subtitlePopover.hide(); + await widget.onLoadLocalSubtitle(); + }, + leading: const Icon(Icons.folder_open_rounded, size: 15), + child: const Text('加载本地字幕'), + ), + ], + ), + ); + } + Widget _trackPopover({ required String title, required List tracks, @@ -697,8 +783,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { _rateMenu(), _audioMenu(), _subtitleMenu(), - _menuButton('搜字幕', widget.onSearchSubtitles), - _menuButton('加载字幕', widget.onLoadLocalSubtitle), _controlButton( widget.player.state.volume <= 0 ? Icons.volume_off_rounded @@ -729,9 +813,7 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { ), ), _controlButton( - widget.fullscreen - ? Icons.fullscreen_exit_rounded - : Icons.fullscreen_rounded, + Icons.fullscreen_rounded, widget.onToggleFullscreen, ), ], @@ -778,46 +860,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { } } -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;