From 3b2935ca0b3d0a93e7670faf5dc50a321320d886 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sun, 19 Jul 2026 15:28:13 +0800 Subject: [PATCH] polish media details and mobile navigation --- lib/app/app_theme.dart | 29 +- lib/core/storage/storage_manager.dart | 1 + lib/pages/media_library_page.dart | 394 ++++++++-------------- lib/pages/workspace_page.dart | 16 +- lib/pages/workspace_tools_page.dart | 143 +++++++- lib/providers/media_library_provider.dart | 3 + 6 files changed, 308 insertions(+), 278 deletions(-) diff --git a/lib/app/app_theme.dart b/lib/app/app_theme.dart index 5fe814f..d0126f1 100644 --- a/lib/app/app_theme.dart +++ b/lib/app/app_theme.dart @@ -110,6 +110,7 @@ class OS26Glass extends StatelessWidget { final double radius; final double opacity; final Border? border; + final bool applyBlur; const OS26Glass({ super.key, @@ -118,26 +119,30 @@ class OS26Glass extends StatelessWidget { this.radius = 18, this.opacity = 0.48, this.border, + this.applyBlur = true, }); @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final cs = theme.colorScheme; + final surface = Container( + padding: padding, + decoration: BoxDecoration( + color: cs.card.withValues(alpha: opacity.clamp(0.0, 1.0)), + borderRadius: BorderRadius.circular(radius), + border: border ?? Border.all(color: cs.border, width: 1), + ), + child: child, + ); return ClipRRect( borderRadius: BorderRadius.circular(radius), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), - child: Container( - padding: padding, - decoration: BoxDecoration( - color: cs.card.withValues(alpha: opacity.clamp(0.0, 1.0)), - borderRadius: BorderRadius.circular(radius), - border: border ?? Border.all(color: cs.border, width: 1), - ), - child: child, - ), - ), + child: applyBlur + ? BackdropFilter( + filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: surface, + ) + : surface, ); } } diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart index ecd6be7..4cf19df 100644 --- a/lib/core/storage/storage_manager.dart +++ b/lib/core/storage/storage_manager.dart @@ -36,6 +36,7 @@ class StorageKeys { 'guangya.cloudIndexLastUpdatedAt'; static const String cloudScrapedBackupFolderID = 'guangya.cloudScrapedBackupFolderID'; + static const String workspaceMode = 'guangya.workspaceMode'; } class StorageManager { diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index e58c7eb..af8d910 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -233,135 +233,6 @@ class _BackupActionsMenuState extends State<_BackupActionsMenu> { ); } -class _MediaLibrarySwitcher extends StatefulWidget { - final List libraries; - final String? selectedLibraryID; - final ValueChanged onSelected; - final bool compact; - - const _MediaLibrarySwitcher({ - required this.libraries, - required this.selectedLibraryID, - required this.onSelected, - this.compact = false, - }); - - @override - State<_MediaLibrarySwitcher> createState() => _MediaLibrarySwitcherState(); -} - -class _MediaLibrarySwitcherState extends State<_MediaLibrarySwitcher> { - final _controller = ShadPopoverController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - final selected = widget.libraries - .where((library) => library.id == widget.selectedLibraryID) - .firstOrNull; - final title = selected?.name ?? '未选择媒体库'; - final label = Container( - constraints: const BoxConstraints(maxWidth: 180), - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: cs.muted.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(7), - border: Border.all(color: cs.border), - ), - child: Row( - children: [ - Icon(Icons.video_library_rounded, size: 16, color: cs.primary), - const SizedBox(width: 7), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: cs.foreground, - ), - ), - ), - ], - ), - ); - final control = Row( - mainAxisSize: widget.compact ? MainAxisSize.max : MainAxisSize.min, - children: [ - if (widget.compact) Expanded(child: label) else label, - const SizedBox(width: 6), - ShadTooltip( - builder: (_) => const Text('切换媒体库'), - child: ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: widget.libraries.isEmpty ? null : _controller.toggle, - child: const Icon(Icons.swap_horiz_rounded, size: 17), - ), - ), - ], - ); - return ShadPopover( - controller: _controller, - popover: (_) => SizedBox( - width: 240, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 280), - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.all(6), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), - child: Text( - '切换媒体库', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: cs.mutedForeground, - ), - ), - ), - for (final library in widget.libraries) - ShadButton.ghost( - width: double.infinity, - mainAxisAlignment: MainAxisAlignment.start, - leading: Icon( - library.kind == MediaLibraryKind.series - ? Icons.live_tv_rounded - : Icons.movie_rounded, - size: 16, - ), - trailing: library.id == widget.selectedLibraryID - ? Icon(Icons.check_rounded, size: 16, color: cs.primary) - : null, - onPressed: () { - _controller.hide(); - widget.onSelected(library.id); - }, - child: Text( - library.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - child: control, - ); - } -} - class _MediaLibraryPageState extends ConsumerState { final String _tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; @@ -439,8 +310,10 @@ class _MediaLibraryPageState extends ConsumerState { _buildHeader(context, state, compact: compact), SizedBox(height: compact ? 8 : 12), ], - _buildToolbar(context, state), - SizedBox(height: compact ? 8 : 12), + if (widget.showManagementToolbar || _detailWork != null) ...[ + _buildToolbar(context, state), + SizedBox(height: compact ? 8 : 12), + ], Expanded( child: widget.showLibrarySidebar && @@ -469,10 +342,12 @@ class _MediaLibraryPageState extends ConsumerState { required bool compact, }) { final cs = ShadTheme.of(context).colorScheme; + final statistics = state.statistics; + final library = state.selectedLibrary; final title = Row( children: [ Icon( - Icons.movie_filter_rounded, + Icons.video_library_rounded, size: compact ? 22 : 26, color: cs.primary, ), @@ -482,7 +357,7 @@ class _MediaLibraryPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.searchTitle ?? '光鸭影视', + library?.name ?? '未选择媒体库', style: TextStyle( fontSize: compact ? 18 : 21, fontWeight: FontWeight.w700, @@ -490,7 +365,7 @@ class _MediaLibraryPageState extends ConsumerState { ), ), Text( - state.selectedLibrary?.name ?? '创建媒体库后扫描云盘影视文件', + '${statistics.movies} 部电影 · ${statistics.series} 部剧集 · ${statistics.total} 个媒体文件', style: TextStyle(fontSize: 12, color: cs.mutedForeground), overflow: TextOverflow.ellipsis, ), @@ -499,7 +374,28 @@ class _MediaLibraryPageState extends ConsumerState { ), ], ); - return title; + final search = ShadInput( + controller: _searchController, + placeholder: const Text('搜索影视库或匹配 TMDB…'), + leading: Icon(Icons.search_rounded, size: 16, color: cs.mutedForeground), + onChanged: (value) => + ref.read(mediaLibraryProvider.notifier).setSearchQuery(value), + onSubmitted: _searchTMDB, + ); + if (_detailWork != null) return title; + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [title, const SizedBox(height: 8), search], + ); + } + return Row( + children: [ + Expanded(child: title), + const SizedBox(width: 20), + SizedBox(width: 400, child: search), + ], + ); } Widget _buildToolbar(BuildContext context, MediaLibraryState state) { @@ -543,66 +439,8 @@ class _MediaLibraryPageState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - if (detailWork != null && widget.showManagementToolbar) ...[ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _detailSyncing - ? null - : () => - unawaited(_refreshAndRecognizeDetail(detailWork)), - leading: const Icon(Icons.auto_awesome_rounded, size: 16), - child: const Text('媒体识别'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _manualMatchPreparing - ? null - : () => unawaited(_showManualTMDBMatch(detailWork)), - leading: const Icon(Icons.manage_search_rounded, size: 16), - child: const Text('手动匹配'), - ), - ] else if (widget.showManagementToolbar) ...[ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: () => _showCreateLibraryDialog(context, ref), - leading: const Icon(Icons.add_rounded, size: 16), - child: const Text('媒体库'), - ), - _backupActionsMenu(state, compact: true), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: state.selectedLibrary == null || state.isScanning - ? null - : () => ref - .read(mediaLibraryProvider.notifier) - .rescanSelectedLibrary(), - leading: const Icon(Icons.refresh_rounded, size: 16), - child: const Text('扫描'), - ), - if (state.isScanning) - ShadButton.destructive( - size: ShadButtonSize.sm, - onPressed: () => - ref.read(mediaLibraryProvider.notifier).cancelScan(), - leading: const Icon(Icons.stop_rounded, size: 16), - child: const Text('停止'), - ), - ShadButton( - size: ShadButtonSize.sm, - onPressed: _tmdbApiKey.isEmpty - ? null - : () => _searchTMDB(_searchController.text), - leading: const Icon(Icons.travel_explore_rounded, size: 16), - child: const Text('匹配'), - ), - _MediaLibrarySwitcher( - libraries: state.libraries, - selectedLibraryID: state.selectedLibraryID, - compact: true, - onSelected: (libraryID) => ref - .read(mediaLibraryProvider.notifier) - .selectLibrary(libraryID), - ), + if (detailWork == null && widget.showManagementToolbar) ...[ + _managementActionStrip(context, state, compact: true), ], ], ), @@ -612,7 +450,7 @@ class _MediaLibraryPageState extends ConsumerState { } return Row( children: [ - if (detailWork != null && widget.showManagementToolbar) ...[ + if (detailWork != null) ...[ ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () => setState(() => _detailWork = null), @@ -635,67 +473,66 @@ class _MediaLibraryPageState extends ConsumerState { ), ), const SizedBox(width: 8), - if (detailWork != null && widget.showManagementToolbar) ...[ - ShadButton.outline( - onPressed: _detailSyncing - ? null - : () => unawaited(_refreshAndRecognizeDetail(detailWork)), - leading: const Icon(Icons.auto_awesome_rounded, size: 16), - child: const Text('媒体识别'), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: _manualMatchPreparing - ? null - : () => unawaited(_showManualTMDBMatch(detailWork)), - leading: const Icon(Icons.manage_search_rounded, size: 16), - child: const Text('手动匹配'), - ), - ] else if (widget.showManagementToolbar) ...[ - ShadButton.outline( - onPressed: () => _showCreateLibraryDialog(context, ref), - leading: const Icon(Icons.add_rounded, size: 16), - child: const Text('媒体库'), - ), - const SizedBox(width: 8), - _backupActionsMenu(state), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: state.selectedLibrary == null || state.isScanning - ? null - : () => ref - .read(mediaLibraryProvider.notifier) - .rescanSelectedLibrary(), - leading: const Icon(Icons.refresh_rounded, size: 16), - child: const Text('重新扫描'), - ), - if (state.isScanning) ...[ - const SizedBox(width: 8), - ShadButton.destructive( + if (detailWork == null && widget.showManagementToolbar) ...[ + _managementActionStrip(context, state, compact: false), + ], + ], + ); + } + + Widget _managementActionStrip( + BuildContext context, + MediaLibraryState state, { + required bool compact, + }) { + final cs = ShadTheme.of(context).colorScheme; + final actions = [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: () => _showCreateLibraryDialog(context, ref), + leading: const Icon(Icons.add_rounded, size: 16), + child: const Text('新建媒体库'), + ), + _backupActionsMenu(state, compact: true), + state.isScanning + ? ShadButton.destructive( + size: ShadButtonSize.sm, onPressed: () => ref.read(mediaLibraryProvider.notifier).cancelScan(), leading: const Icon(Icons.stop_rounded, size: 16), - child: const Text('停止'), + child: const Text('停止扫描'), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: state.selectedLibrary == null + ? null + : () => ref + .read(mediaLibraryProvider.notifier) + .rescanSelectedLibrary(), + leading: const Icon(Icons.refresh_rounded, size: 16), + child: Text(compact ? '扫描' : '重新扫描'), ), - ], - const SizedBox(width: 8), - ShadButton( - onPressed: _tmdbApiKey.isEmpty - ? null - : () => _searchTMDB(_searchController.text), - leading: const Icon(Icons.travel_explore_rounded, size: 16), - child: const Text('匹配'), - ), - const SizedBox(width: 8), - _MediaLibrarySwitcher( - libraries: state.libraries, - selectedLibraryID: state.selectedLibraryID, - onSelected: (libraryID) => ref - .read(mediaLibraryProvider.notifier) - .selectLibrary(libraryID), - ), - ], - ], + ]; + final content = compact + ? Wrap(spacing: 4, runSpacing: 4, children: actions) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0) const SizedBox(width: 4), + actions[index], + ], + ], + ); + return Container( + width: compact ? double.infinity : null, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: cs.muted.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.border), + ), + child: content, ); } @@ -832,6 +669,9 @@ class _MediaLibraryPageState extends ConsumerState { context: context, builder: (_) => ExternalPlayerDialog(file: item.file), ), + onRecognize: () => + unawaited(_refreshAndRecognizeDetail(selectedWork)), + onManualMatch: () => unawaited(_showManualTMDBMatch(selectedWork)), ), if (_detailSyncing || _manualMatchPreparing) _detailLoadingOverlay( @@ -876,11 +716,16 @@ class _MediaLibraryPageState extends ConsumerState { onDownload: () => ref .read(fileProvider.notifier) .downloadFile(works[index].primary.file), - onRescan: state.isScanning + onRecognize: state.isScanning ? null - : () => ref - .read(mediaLibraryProvider.notifier) - .rescanSelectedLibrary(), + : () { + setState(() => _detailWork = works[index]); + unawaited(_refreshAndRecognizeDetail(works[index])); + }, + onManualMatch: () { + setState(() => _detailWork = works[index]); + unawaited(_showManualTMDBMatch(works[index])); + }, ), ); }, @@ -2240,13 +2085,15 @@ class _MediaPosterTile extends ConsumerWidget { final _MediaWork work; final VoidCallback onOpen; final VoidCallback onDownload; - final VoidCallback? onRescan; + final VoidCallback? onRecognize; + final VoidCallback onManualMatch; const _MediaPosterTile({ required this.work, required this.onOpen, required this.onDownload, - this.onRescan, + this.onRecognize, + required this.onManualMatch, }); @override @@ -2258,6 +2105,7 @@ class _MediaPosterTile extends ConsumerWidget { ? _tmdbImageURL(item.posterPath!, size: 'w342') : null; return ShadContextMenuRegion( + tapEnabled: false, items: [ ShadContextMenuItem.inset( leading: const Icon(LucideIcons.info, size: 16), @@ -2271,9 +2119,14 @@ class _MediaPosterTile extends ConsumerWidget { ), const Divider(height: 8), ShadContextMenuItem.inset( - leading: const Icon(LucideIcons.refreshCw, size: 16), - onPressed: onRescan, - child: const Text('重新扫描媒体库'), + leading: const Icon(LucideIcons.sparkles, size: 16), + onPressed: onRecognize, + child: const Text('媒体识别'), + ), + ShadContextMenuItem.inset( + leading: const Icon(LucideIcons.listFilter, size: 16), + onPressed: onManualMatch, + child: const Text('手动匹配'), ), ], child: Semantics( @@ -2378,12 +2231,16 @@ class _MediaDetailPanel extends ConsumerStatefulWidget { final ValueChanged onDownload; final ValueChanged onPlay; final ValueChanged onExternalPlay; + final VoidCallback onRecognize; + final VoidCallback onManualMatch; const _MediaDetailPanel({ required this.work, required this.onDownload, required this.onPlay, required this.onExternalPlay, + required this.onRecognize, + required this.onManualMatch, }); @override @@ -2752,6 +2609,18 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { leading: const Icon(Icons.download_rounded, size: 16), child: const Text('下载'), ), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: widget.onRecognize, + leading: const Icon(Icons.auto_awesome_rounded, size: 16), + child: const Text('媒体识别'), + ), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: widget.onManualMatch, + leading: const Icon(Icons.manage_search_rounded, size: 16), + child: const Text('手动匹配'), + ), ], ), const SizedBox(height: 14), @@ -3228,6 +3097,7 @@ class _MediaDetailPanelState extends ConsumerState<_MediaDetailPanel> { return Padding( padding: const EdgeInsets.only(bottom: 6), child: ShadContextMenuRegion( + tapEnabled: false, items: [ ShadContextMenuItem.inset( leading: const Icon(LucideIcons.play, size: 16), diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 1500b87..31ce48a 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -208,6 +208,14 @@ class _WorkspacePageState extends ConsumerState { WorkspaceTool? _cloudActiveTool; WorkspaceTool? _mediaActiveTool; + @override + void initState() { + super.initState(); + if (StorageManager.get(StorageKeys.workspaceMode) == 'media') { + _mode = WorkspaceMode.media; + } + } + void _openTool(WorkspaceTool tool) { setState(() { if (_mode == WorkspaceMode.cloud) { @@ -326,6 +334,7 @@ class _WorkspacePageState extends ConsumerState { _searchOpen = false; _searchController.clear(); }); + unawaited(StorageManager.set(StorageKeys.workspaceMode, mode.name)); if (mode == WorkspaceMode.media) { ref.read(mediaLibraryProvider.notifier).api = ref .read(authProvider.notifier) @@ -357,8 +366,8 @@ class _WorkspacePageState extends ConsumerState { } void _showMobileMenu(BuildContext context) { - final width = (MediaQuery.sizeOf(context).width * 0.78) - .clamp(252.0, 300.0) + final width = (MediaQuery.sizeOf(context).width * 0.72) + .clamp(236.0, 280.0) .toDouble(); showShadSheet( context: context, @@ -373,6 +382,7 @@ class _WorkspacePageState extends ConsumerState { backgroundColor: cs.background, border: const Border(), shadows: const [], + closeIcon: const SizedBox.shrink(), child: Material( color: cs.background, child: Column( @@ -1111,6 +1121,7 @@ class _CloudSidebar extends StatelessWidget { radius: showBrand ? 24 : 0, opacity: showBrand ? 0.56 : 0, border: showBrand ? null : const Border(), + applyBlur: showBrand, padding: EdgeInsets.fromLTRB(14, showBrand ? 18 : 14, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1225,6 +1236,7 @@ class _MediaSidebar extends ConsumerWidget { radius: showBrand ? 24 : 0, opacity: showBrand ? 0.56 : 0, border: showBrand ? null : const Border(), + applyBlur: showBrand, padding: EdgeInsets.fromLTRB(14, showBrand ? 18 : 14, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index e52b3ad..8949244 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -82,15 +82,18 @@ class WorkspaceToolsPage extends StatelessWidget { } } -class _ToolHeader extends StatelessWidget { +class _ToolHeader extends ConsumerWidget { final WorkspaceTool tool; final VoidCallback onClose; const _ToolHeader({required this.tool, required this.onClose}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final cs = ShadTheme.of(context).colorScheme; + final mediaState = tool == WorkspaceTool.tmdb + ? ref.watch(mediaLibraryProvider) + : null; return Container( height: 58, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -126,6 +129,17 @@ class _ToolHeader extends StatelessWidget { ), ), ), + if (mediaState != null) ...[ + const SizedBox(width: 8), + _ToolMediaLibrarySwitcher( + libraries: mediaState.libraries, + selectedLibraryID: mediaState.selectedLibraryID, + compact: compact, + onSelected: (libraryID) => ref + .read(mediaLibraryProvider.notifier) + .selectLibrary(libraryID), + ), + ], ], ); }, @@ -134,6 +148,122 @@ class _ToolHeader extends StatelessWidget { } } +class _ToolMediaLibrarySwitcher extends StatefulWidget { + final List libraries; + final String? selectedLibraryID; + final ValueChanged onSelected; + final bool compact; + + const _ToolMediaLibrarySwitcher({ + required this.libraries, + required this.selectedLibraryID, + required this.onSelected, + required this.compact, + }); + + @override + State<_ToolMediaLibrarySwitcher> createState() => + _ToolMediaLibrarySwitcherState(); +} + +class _ToolMediaLibrarySwitcherState extends State<_ToolMediaLibrarySwitcher> { + final _controller = ShadPopoverController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + final selected = widget.libraries + .where((library) => library.id == widget.selectedLibraryID) + .firstOrNull; + return ShadPopover( + controller: _controller, + popover: (_) => SizedBox( + width: 240, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(6), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), + child: Text( + '切换媒体库', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.mutedForeground, + ), + ), + ), + for (final library in widget.libraries) + ShadButton.ghost( + width: double.infinity, + mainAxisAlignment: MainAxisAlignment.start, + leading: Icon( + library.kind == MediaLibraryKind.series + ? Icons.live_tv_rounded + : Icons.movie_rounded, + size: 16, + ), + trailing: library.id == widget.selectedLibraryID + ? Icon(Icons.check_rounded, size: 16, color: cs.primary) + : null, + onPressed: () { + _controller.hide(); + widget.onSelected(library.id); + }, + child: Text( + library.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.compact ? 92 : 190), + child: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: widget.libraries.isEmpty ? null : _controller.toggle, + leading: Icon( + Icons.video_library_rounded, + size: 16, + color: cs.primary, + ), + child: Text( + selected?.name ?? '未选择媒体库', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 2), + ShadTooltip( + builder: (_) => const Text('切换媒体库'), + child: ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: widget.libraries.isEmpty ? null : _controller.toggle, + child: const Icon(Icons.swap_horiz_rounded, size: 17), + ), + ), + ], + ), + ); + } +} + class _FileScanTool extends ConsumerStatefulWidget { const _FileScanTool(); @@ -752,6 +882,8 @@ class _BatchRenameTool extends ConsumerStatefulWidget { class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { final _filter = TextEditingController(); + final _previewHorizontalController = ScrollController(); + final _previewVerticalController = ScrollController(); bool _preserveExtension = true; bool _running = false; int _completed = 0; @@ -793,6 +925,8 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { @override void dispose() { _filter.dispose(); + _previewHorizontalController.dispose(); + _previewVerticalController.dispose(); super.dispose(); } @@ -1311,7 +1445,9 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { ? 920.0 : constraints.maxWidth; return Scrollbar( + controller: _previewHorizontalController, child: SingleChildScrollView( + controller: _previewHorizontalController, scrollDirection: Axis.horizontal, child: SizedBox( width: tableWidth, @@ -1321,6 +1457,9 @@ class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> { const Divider(height: 1), Expanded( child: ListView.separated( + controller: + _previewVerticalController, + primary: false, itemCount: previews.length, separatorBuilder: (_, _) => const Divider(height: 1), diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index bf087ef..17acff9 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -734,12 +734,14 @@ class MediaLibraryNotifier extends StateNotifier { proxyPort: tmdbProxyPort, ) : existing.copyWith(file: file, updatedAt: DateTime.now()); + if (_cancelScan) return; // Do not lose an existing match solely because a transient TMDB // lookup failed while filling an incomplete record. if (item.tmdbID == null && existing?.tmdbID != null) { item = existing!.copyWith(file: file, updatedAt: DateTime.now()); } item = await _renameMatchedMediaFile(item); + if (_cancelScan) return; unique[file.id] = item; completed += 1; final visible = unique.values.toList() @@ -783,6 +785,7 @@ class MediaLibraryNotifier extends StateNotifier { if (forceRemote) { _appendScanLog('正在获取云盘全量文件索引…'); await refreshGlobalCloudIndex(force: true); + if (_cancelScan) return; final globalFiles = await _cachedGlobalFiles(); final seen = {}; final mediaFiles = [];