From 3d757ad77f7b7bc553cde176c06ab233db86b3e4 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 14:02:16 +0800 Subject: [PATCH] feat: add media detail playback actions --- lib/pages/media_library_page.dart | 145 +++++++++++++++++++----------- lib/providers/file_provider.dart | 34 ++++++- 2 files changed, 122 insertions(+), 57 deletions(-) diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 30e3b02..73b385b 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -424,6 +424,9 @@ class _MediaLibraryPageState extends ConsumerState { onBack: () => setState(() => _detailWork = null), onDownload: (item) => ref.read(fileProvider.notifier).downloadFile(item.file), + onPlay: (item) => ref.read(fileProvider.notifier).playFile(item.file), + onPlayInBrowser: (item) => + ref.read(fileProvider.notifier).playInBrowser(item.file), onManualMatch: () => _showManualTMDBMatch(current ?? _detailWork!), onRescan: state.isScanning ? null @@ -1533,6 +1536,8 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { final _MediaWork work; final VoidCallback onBack; final ValueChanged onDownload; + final ValueChanged onPlay; + final ValueChanged onPlayInBrowser; final VoidCallback onManualMatch; final VoidCallback? onRescan; @@ -1540,6 +1545,8 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { required this.work, required this.onBack, required this.onDownload, + required this.onPlay, + required this.onPlayInBrowser, required this.onManualMatch, this.onRescan, }); @@ -1567,7 +1574,9 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ ShadButton.ghost( size: ShadButtonSize.sm, @@ -1575,11 +1584,10 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { leading: const Icon(Icons.arrow_back_rounded, size: 16), child: const Text('返回海报墙'), ), - const Spacer(), ShadButton.outline( onPressed: widget.onManualMatch, leading: const Icon(Icons.manage_search_rounded, size: 16), - child: const Text('手动匹配'), + child: const Text('重新识别'), ), const SizedBox(width: 8), ShadButton.outline( @@ -1589,9 +1597,19 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { ), const SizedBox(width: 8), ShadButton( - onPressed: () => widget.onDownload(_resource), + onPressed: () => widget.onPlay(_resource), leading: const Icon(Icons.play_arrow_rounded, size: 16), - child: const Text('打开资源'), + child: const Text('播放'), + ), + ShadButton.outline( + onPressed: () => widget.onPlayInBrowser(_resource), + leading: const Icon(Icons.open_in_browser_rounded, size: 16), + child: const Text('浏览器播放'), + ), + ShadButton.outline( + onPressed: () => widget.onDownload(_resource), + leading: const Icon(Icons.download_rounded, size: 16), + child: const Text('下载'), ), ], ), @@ -1778,59 +1796,78 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { final selected = resource.id == _resource.id; return Padding( padding: const EdgeInsets.only(bottom: 6), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => setState(() => _resource = resource), - borderRadius: BorderRadius.circular(6), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: selected - ? cs.primary.withValues(alpha: 0.10) - : cs.card, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: selected ? cs.primary : cs.border, + child: ShadContextMenuRegion( + items: [ + ShadContextMenuItem.inset( + leading: const Icon(LucideIcons.play, size: 16), + onPressed: () => widget.onPlay(resource), + child: const Text('播放'), + ), + ShadContextMenuItem.inset( + leading: const Icon(LucideIcons.externalLink, size: 16), + onPressed: () => widget.onPlayInBrowser(resource), + child: const Text('浏览器播放'), + ), + ShadContextMenuItem.inset( + leading: const Icon(LucideIcons.download, size: 16), + onPressed: () => widget.onDownload(resource), + child: const Text('下载'), + ), + ], + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _resource = resource), + borderRadius: BorderRadius.circular(6), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: selected + ? cs.primary.withValues(alpha: 0.10) + : cs.card, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected ? cs.primary : cs.border, + ), ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - resource.file.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: cs.foreground, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + resource.file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), ), ), - ), - if (selected) - Icon( - Icons.check_circle_rounded, - size: 16, - color: cs.primary, - ), - ], - ), - const SizedBox(height: 4), - Text( - '${resource.file.formattedSize} · ${resource.file.modifiedAt}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: cs.mutedForeground, + if (selected) + Icon( + Icons.check_circle_rounded, + size: 16, + color: cs.primary, + ), + ], ), - ), - ], + const SizedBox(height: 4), + Text( + '${resource.file.formattedSize} · ${resource.file.modifiedAt}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + ], + ), ), ), ), diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index 99e762b..0a1015e 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -640,14 +640,42 @@ class FileNotifier extends StateNotifier { } Future downloadFile(CloudFile file) async { + await _openRemoteFile( + file, + preparingMessage: '正在准备下载…', + completedMessage: '下载链接已交给系统处理', + ); + } + + Future playFile(CloudFile file) async { + await _openRemoteFile( + file, + preparingMessage: '正在准备播放…', + completedMessage: '已交给系统默认播放器', + ); + } + + Future playInBrowser(CloudFile file) async { + await _openRemoteFile( + file, + preparingMessage: '正在准备浏览器播放…', + completedMessage: '已在外部浏览器打开播放链接', + ); + } + + Future _openRemoteFile( + CloudFile file, { + required String preparingMessage, + required String completedMessage, + }) async { if (_api == null) return; try { - state = state.copyWith(statusMessage: '正在准备外部打开…'); + state = state.copyWith(statusMessage: preparingMessage); final url = await _resolveOpenUrl(file); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { - throw Exception('无法调用系统默认应用打开链接'); + throw Exception('无法调用系统默认应用打开签名链接'); } - state = state.copyWith(statusMessage: '已交给系统默认应用'); + state = state.copyWith(statusMessage: completedMessage); } catch (e) { state = state.copyWith(errorMessage: e.toString()); }