From 605a80d633734a46c21d6aaeb269156a0f76f7a2 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 17:19:09 +0800 Subject: [PATCH] fix media playback fallback and artwork hydration --- lib/core/storage/media_library_store.dart | 11 +- lib/pages/media_library_page.dart | 78 +--- lib/pages/workspace_page.dart | 7 +- lib/pages/workspace_tools_page.dart | 2 +- lib/providers/media_library_provider.dart | 98 ++++- lib/widgets/media_player_dialog.dart | 444 ++++++++++++---------- 6 files changed, 368 insertions(+), 272 deletions(-) diff --git a/lib/core/storage/media_library_store.dart b/lib/core/storage/media_library_store.dart index 177ccee..ee9de6b 100644 --- a/lib/core/storage/media_library_store.dart +++ b/lib/core/storage/media_library_store.dart @@ -238,7 +238,16 @@ class MediaLibraryStore { final removedArtwork = await db.rawUpdate('''UPDATE media_items SET poster = NULL, backdrop = NULL WHERE poster IS NOT NULL OR backdrop IS NOT NULL'''); - await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); + try { + await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); + } on DatabaseException catch (error) { + // sqflite_darwin can report the successful checkpoint result as + // "Code=0 not an error". It is not a failed database optimization. + final text = error.toString(); + if (!text.contains('Code=0') || !text.contains('not an error')) { + rethrow; + } + } await db.execute('PRAGMA optimize'); await db.execute('VACUUM'); final after = await _databaseBytes(db.path); diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index e5f273b..8aa149e 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; @@ -72,8 +74,8 @@ class MediaLibraryPage extends ConsumerStatefulWidget { } class _MediaLibraryPageState extends ConsumerState { - String _tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; - bool _showApiKeyInput = false; + final String _tmdbApiKey = + StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; bool _tmdbSearching = false; String? _tmdbError; List> _tmdbResults = []; @@ -81,13 +83,11 @@ class _MediaLibraryPageState extends ConsumerState { _MediaWork? _detailWork; _MediaWallFilter _wallFilter = _MediaWallFilter.all; String? _activeCollectionKey; - final _apiKeyController = TextEditingController(); final _searchController = TextEditingController(); @override void initState() { super.initState(); - _apiKeyController.text = _tmdbApiKey; Future.microtask(() { ref.read(mediaLibraryProvider.notifier).api = ref .read(authProvider.notifier) @@ -98,7 +98,6 @@ class _MediaLibraryPageState extends ConsumerState { @override void dispose() { - _apiKeyController.dispose(); _searchController.dispose(); super.dispose(); } @@ -253,7 +252,7 @@ class _MediaLibraryPageState extends ConsumerState { Expanded( child: ShadInput( controller: _searchController, - placeholder: const Text('搜索影视库或 TMDB…'), + placeholder: const Text('搜索影视库或匹配 TMDB…'), leading: Icon( Icons.search_rounded, size: 16, @@ -287,14 +286,6 @@ class _MediaLibraryPageState extends ConsumerState { child: const Text('导入数据'), ), const SizedBox(width: 8), - ShadButton.outline( - onPressed: _backupBusy || state.isScanning - ? null - : _optimizeScrapedStorage, - leading: const Icon(Icons.cleaning_services_rounded, size: 16), - child: const Text('数据库瘦身'), - ), - const SizedBox(width: 8), ShadButton.outline( onPressed: state.selectedLibrary == null || state.isScanning ? null @@ -314,15 +305,6 @@ class _MediaLibraryPageState extends ConsumerState { ), ], const SizedBox(width: 8), - ShadButton.outline( - onPressed: () => setState(() => _showApiKeyInput = !_showApiKeyInput), - leading: Icon( - _tmdbApiKey.isEmpty ? Icons.key_off_rounded : Icons.key_rounded, - size: 16, - ), - child: const Text('TMDB'), - ), - const SizedBox(width: 8), ShadButton( onPressed: _tmdbApiKey.isEmpty ? null @@ -363,7 +345,6 @@ class _MediaLibraryPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_showApiKeyInput) _buildTMDBConfig(context), Text( '媒体库', style: TextStyle( @@ -401,32 +382,6 @@ class _MediaLibraryPageState extends ConsumerState { ); } - Widget _buildTMDBConfig(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - border: Border.all(color: cs.border), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - ShadInput( - controller: _apiKeyController, - placeholder: const Text('TMDB API Key'), - obscureText: true, - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: ShadButton(onPressed: _saveApiKey, child: const Text('保存')), - ), - ], - ), - ); - } - Widget _emptyLibraryHint(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Center( @@ -492,10 +447,7 @@ class _MediaLibraryPageState extends ConsumerState { onBack: () => setState(() => _detailWork = null), onDownload: (item) => ref.read(fileProvider.notifier).downloadFile(item.file), - onPlay: (item) => showShadDialog( - context: context, - builder: (_) => MediaPlayerDialog(file: item.file), - ), + onPlay: (item) => unawaited(showMediaPlayerDialog(context, item.file)), onExternalPlay: (item) => showShadDialog( context: context, builder: (_) => ExternalPlayerDialog(file: item.file), @@ -903,15 +855,6 @@ class _MediaLibraryPageState extends ConsumerState { ); } - void _saveApiKey() { - final key = _apiKeyController.text.trim(); - StorageManager.set(StorageKeys.tmdbApiKey, key); - setState(() { - _tmdbApiKey = key; - _showApiKeyInput = false; - }); - } - Future _exportScrapedData() async { final directory = await FilePicker.getDirectoryPath( dialogTitle: '选择刮削数据导出目录', @@ -943,15 +886,6 @@ class _MediaLibraryPageState extends ConsumerState { } } - Future _optimizeScrapedStorage() async { - setState(() => _backupBusy = true); - try { - await ref.read(mediaLibraryProvider.notifier).optimizeLocalStorage(); - } finally { - if (mounted) setState(() => _backupBusy = false); - } - } - Future _searchTMDB(String query) async { final text = query.trim(); if (text.isEmpty || _tmdbApiKey.isEmpty) return; diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index c13bcf7..6c5492e 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -43,10 +43,7 @@ class _DraggedCloudFiles { void _openCloudFile(BuildContext context, WidgetRef ref, CloudFile file) { if (file.isVideo) { - showShadDialog( - context: context, - builder: (_) => MediaPlayerDialog(file: file), - ); + unawaited(showMediaPlayerDialog(context, file)); return; } ref.read(fileProvider.notifier).downloadFile(file); @@ -685,7 +682,7 @@ class _MediaSidebar extends ConsumerWidget { ), _SidebarTile( icon: Icons.auto_fix_high_rounded, - label: 'TMDB 整理', + label: '媒体库管理', selected: false, onTap: () => onTool(WorkspaceTool.tmdb), ), diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index 39044b2..951aac4 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -28,7 +28,7 @@ extension WorkspaceToolDetails on WorkspaceTool { case WorkspaceTool.fastTransfer: return '秒传工具'; case WorkspaceTool.tmdb: - return 'TMDB 整理'; + return '媒体库管理'; case WorkspaceTool.categories: return '分类管理'; } diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 1b7198e..3f4fdb3 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -90,6 +90,7 @@ class MediaLibraryNotifier extends StateNotifier { GuangyaAPI? _api; final _store = MediaLibraryStore(); final _tmdbDetailsRequests = >>{}; + final _artworkHydrationLibraries = {}; bool _loaded = false; bool _cancelScan = false; @@ -116,6 +117,9 @@ class MediaLibraryNotifier extends StateNotifier { items: items, scanLogs: logs, ); + if (selectedID != null) { + unawaited(_hydrateMissingArtwork(selectedID, items)); + } } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { @@ -124,12 +128,14 @@ class MediaLibraryNotifier extends StateNotifier { } Future selectLibrary(String id) async { + final items = await _loadItems(id); state = state.copyWith( selectedLibraryID: id, - items: await _loadItems(id), + items: items, searchQuery: '', clearError: true, ); + unawaited(_hydrateMissingArtwork(id, items)); } Future createLibrary({ @@ -222,6 +228,9 @@ class MediaLibraryNotifier extends StateNotifier { items: selectedID == null ? const [] : await _loadItems(selectedID), statusMessage: '刮削数据已导入,已回收 ${_formatBytes(stats.reclaimedBytes)}', ); + if (selectedID != null) { + unawaited(_hydrateMissingArtwork(selectedID, state.items)); + } } catch (error) { state = state.copyWith(errorMessage: '导入失败:$error'); } finally { @@ -498,6 +507,93 @@ class MediaLibraryNotifier extends StateNotifier { return updated; } + /// Old scrape backups stored artwork as BLOBs. The compact SQLite schema + /// deliberately drops those images, so restore the durable TMDB paths from + /// their retained IDs in the background after an import or first load. + Future _hydrateMissingArtwork( + String libraryID, + List items, + ) async { + if (_api == null || _artworkHydrationLibraries.contains(libraryID)) return; + final apiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; + if (apiKey.trim().isEmpty) return; + + final missingByTMDB = >{}; + for (final item in items) { + final id = item.tmdbID; + final kind = item.mediaKind; + final missingPoster = item.posterPath == null || item.posterPath!.isEmpty; + final missingBackdrop = + item.backdropPath == null || item.backdropPath!.isEmpty; + if (id == null || kind == null || (!missingPoster && !missingBackdrop)) { + continue; + } + missingByTMDB.putIfAbsent('${kind.name}:$id', () => []).add(item); + } + if (missingByTMDB.isEmpty) return; + + _artworkHydrationLibraries.add(libraryID); + final proxyHost = + StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; + final proxyPort = + StorageManager.get(StorageKeys.tmdbProxyPort) ?? ''; + final concurrency = + (int.tryParse( + StorageManager.get( + StorageKeys.mediaScanConcurrency, + ) ?? + '3', + ) ?? + 3) + .clamp(1, 12); + final groups = missingByTMDB.entries.toList(); + var completed = 0; + + try { + for (var start = 0; start < groups.length; start += concurrency) { + final batch = groups.sublist( + start, + (start + concurrency).clamp(0, groups.length), + ); + final results = await Future.wait( + batch.map((entry) async { + final prototype = entry.value.first; + try { + final details = await _tmdbDetails( + prototype.tmdbID!, + prototype.mediaKind, + apiKey: apiKey, + proxyHost: proxyHost, + proxyPort: proxyPort, + ); + return entry.value + .map((item) => _itemFromTMDBDetails(item, details)) + .toList(); + } catch (_) { + return const []; + } + }), + ); + final updates = results.expand((items) => items).toList(); + completed += batch.length; + if (updates.isNotEmpty) await _store.upsertItems(updates); + if (state.selectedLibraryID == libraryID) { + final updatedByID = {for (final item in updates) item.id: item}; + state = state.copyWith( + items: state.items + .map((item) => updatedByID[item.id] ?? item) + .toList(), + statusMessage: completed == groups.length + ? '已补齐 ${groups.length} 个影视条目的海报与横幅' + : '正在补齐影视图片 $completed/${groups.length}', + ); + } + } + } finally { + _artworkHydrationLibraries.remove(libraryID); + } + } + void clearError() { state = state.copyWith(clearError: true); } diff --git a/lib/widgets/media_player_dialog.dart b/lib/widgets/media_player_dialog.dart index 67f6f63..1099f07 100644 --- a/lib/widgets/media_player_dialog.dart +++ b/lib/widgets/media_player_dialog.dart @@ -13,10 +13,36 @@ import '../models/cloud_file.dart'; import '../models/media_library.dart'; import '../providers/file_provider.dart'; +Future showMediaPlayerDialog(BuildContext context, CloudFile file) async { + Future openExternalPlayer() async { + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await showShadDialog( + context: context, + builder: (_) => ExternalPlayerDialog(file: file), + ); + } + + try { + await showShadDialog( + context: context, + builder: (_) => + MediaPlayerDialog(file: file, onPlaybackFailure: openExternalPlayer), + ); + } catch (_) { + await openExternalPlayer(); + } +} + class MediaPlayerDialog extends ConsumerStatefulWidget { final CloudFile file; + final Future Function()? onPlaybackFailure; - const MediaPlayerDialog({super.key, required this.file}); + const MediaPlayerDialog({ + super.key, + required this.file, + this.onPlaybackFailure, + }); @override ConsumerState createState() => _MediaPlayerDialogState(); @@ -27,10 +53,10 @@ class _MediaPlayerDialogState extends ConsumerState { late final VideoController _controller; StreamSubscription? _videoParamsSubscription; Timer? _videoDimensionFallbackTimer; - final _videoKey = GlobalKey(); late CloudFile _currentFile; String? _error; bool _loading = true; + bool _externalFallbackOpened = false; var _videoAspectRatio = 3 / 2; var _hasVideoDimensions = false; bool _showEpisodes = false; @@ -41,21 +67,22 @@ class _MediaPlayerDialogState extends ConsumerState { 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; - }); - }, - ), - ); + try { + _controller = VideoController(_player); + unawaited( + _controller.platform.future.then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + _handlePlaybackFailure( + error, + error is MissingPluginException ? '内置播放器插件未加载' : '内置视频输出初始化失败', + ); + }, + ), + ); + } catch (error) { + _handlePlaybackFailure(error, '内置视频输出初始化失败'); + } _videoParamsSubscription = _player.stream.videoParams.listen( _updateVideoDimensions, ); @@ -64,11 +91,14 @@ class _MediaPlayerDialogState extends ConsumerState { } Future _open([CloudFile? file]) async { - if (file != null && mounted) { + _videoDimensionFallbackTimer?.cancel(); + if (mounted) { setState(() { - _currentFile = file; + if (file != null) _currentFile = file; _loading = true; _error = null; + _hasVideoDimensions = false; + _videoAspectRatio = 3 / 2; }); } try { @@ -91,13 +121,24 @@ class _MediaPlayerDialogState extends ConsumerState { .siblingFiles(_currentFile); _subtitleCandidates = _matchingSubtitles(_currentFile, folderFiles); } catch (error) { - if (mounted) { - setState(() { - _error = error.toString(); - _loading = false; - }); - } - } finally {} + _handlePlaybackFailure(error, '内置播放器打开失败'); + } + } + + void _handlePlaybackFailure(Object error, String message) { + if (!mounted) return; + setState(() { + _error = '$message:$error'; + _loading = false; + }); + if (_externalFallbackOpened || widget.onPlaybackFailure == null) return; + _externalFallbackOpened = true; + unawaited(() async { + await Future.delayed(Duration.zero); + if (!mounted) return; + await Navigator.of(context).maybePop(); + await widget.onPlaybackFailure!(); + }()); } void _updateVideoDimensions(VideoParams params) { @@ -208,7 +249,12 @@ class _MediaPlayerDialogState extends ConsumerState { 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); + const dialogPadding = 40.0; + final maxDialogWidth = math.max(400.0, screen.width - 32); + final maxVideoWidth = math.max( + 360.0, + maxDialogWidth - sideWidth - dialogPadding, + ); final maxVideoHeight = math.max(260.0, screen.height - 290); final preferredWidth = _hasVideoDimensions ? math.min(900.0, _videoAspectRatio * maxVideoHeight) @@ -219,7 +265,12 @@ class _MediaPlayerDialogState extends ConsumerState { .toDouble(); final contentWidth = videoWidth + sideWidth; return ShadDialog( - constraints: BoxConstraints(maxWidth: contentWidth), + constraints: BoxConstraints( + maxWidth: math.min(maxDialogWidth, contentWidth + dialogPadding), + maxHeight: math.max(360, screen.height - 24), + ), + padding: const EdgeInsets.all(20), + scrollable: false, title: Text( _currentFile.name, maxLines: 1, @@ -241,71 +292,53 @@ class _MediaPlayerDialogState extends ConsumerState { ], 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(), - ), - ), - ], + 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), + ), + ), + ) + : Video( + controller: _controller, + controls: (videoState) => _MediaPlaybackControls( + player: _player, + fullscreen: videoState.isFullscreen(), + onSearchSubtitles: _searchDirectorySubtitles, + onLoadLocalSubtitle: _loadLocalSubtitle, + onToggleFullscreen: videoState.toggleFullscreen, + ), + ), ), ), - if (_showEpisodes) ...[ - const SizedBox(width: 10), - SizedBox(width: 240, child: _episodeList(cs)), - ], + ), + if (_showEpisodes) ...[ + const SizedBox(width: 10), + SizedBox(width: 240, child: _episodeList(cs)), ], - ), + ], ), ), ), @@ -371,14 +404,14 @@ class _MediaPlayerDialogState extends ConsumerState { class _MediaPlaybackControls extends StatefulWidget { final Player player; - final VideoController controller; + final bool fullscreen; final Future Function() onSearchSubtitles; final Future Function() onLoadLocalSubtitle; final Future Function() onToggleFullscreen; const _MediaPlaybackControls({ required this.player, - required this.controller, + required this.fullscreen, required this.onSearchSubtitles, required this.onLoadLocalSubtitle, required this.onToggleFullscreen, @@ -390,6 +423,9 @@ class _MediaPlaybackControls extends StatefulWidget { class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { double? _scrubbingValue; + final _ratePopover = ShadPopoverController(); + final _audioPopover = ShadPopoverController(); + final _subtitlePopover = ShadPopoverController(); Future _seekBy(int seconds) async { final position = widget.player.state.position + Duration(seconds: seconds); @@ -402,57 +438,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { 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 '自动'; @@ -464,6 +449,125 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { return values.isEmpty ? '轨道 ${track.id}' : values.join(' · '); } + @override + void dispose() { + _ratePopover.dispose(); + _audioPopover.dispose(); + _subtitlePopover.dispose(); + super.dispose(); + } + + Widget _rateMenu() { + return ShadPopover( + controller: _ratePopover, + popover: (_) => SizedBox( + width: 210, + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final value in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: () async { + await widget.player.setRate(value); + _ratePopover.hide(); + if (mounted) setState(() {}); + }, + child: Text('${value}x'), + ), + ], + ), + ), + child: _menuButton( + '${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x', + () async => _ratePopover.toggle(), + ), + ); + } + + Widget _audioMenu() { + final tracks = widget.player.state.tracks.audio; + return ShadPopover( + controller: _audioPopover, + popover: (_) => _trackPopover( + title: '音轨', + tracks: tracks, + selectedID: widget.player.state.track.audio.id, + onSelect: (track) async { + await widget.player.setAudioTrack(track); + _audioPopover.hide(); + if (mounted) setState(() {}); + }, + ), + child: _menuButton('音轨', () async => _audioPopover.toggle()), + ); + } + + Widget _subtitleMenu() { + 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(() {}); + }, + ), + child: _menuButton('字幕', () async => _subtitlePopover.toggle()), + ); + } + + Widget _trackPopover({ + required String title, + required List tracks, + required String selectedID, + required Future Function(T track) onSelect, + }) { + return SizedBox( + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + final selected = (track as dynamic).id == selectedID; + return ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () => onSelect(track), + leading: Icon( + selected ? Icons.check_rounded : Icons.graphic_eq_rounded, + size: 15, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _trackLabel(track), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -575,12 +679,9 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { ), ), const SizedBox(width: 20), - _menuButton( - '${widget.player.state.rate.toStringAsFixed(widget.player.state.rate % 1 == 0 ? 0 : 2)}x', - _selectRate, - ), - _menuButton('音轨', _selectAudio), - _menuButton('字幕', _selectSubtitle), + _rateMenu(), + _audioMenu(), + _subtitleMenu(), _menuButton('搜字幕', widget.onSearchSubtitles), _menuButton('加载字幕', widget.onLoadLocalSubtitle), _controlButton( @@ -613,7 +714,9 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { ), ), _controlButton( - Icons.fullscreen_rounded, + widget.fullscreen + ? Icons.fullscreen_exit_rounded + : Icons.fullscreen_rounded, widget.onToggleFullscreen, ), ], @@ -660,49 +763,6 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> { } } -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;