From 357731f265b35835627a73a7401afc4cbc3e27bc Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sun, 19 Jul 2026 14:45:25 +0800 Subject: [PATCH] refine media library management layout --- lib/pages/media_library_page.dart | 261 ++++++++++++-------- lib/pages/workspace_page.dart | 382 ++++++++++++------------------ 2 files changed, 306 insertions(+), 337 deletions(-) diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index bac9095..e58c7eb 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -233,6 +233,135 @@ 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) ?? ''; @@ -306,12 +435,17 @@ class _MediaLibraryPageState extends ConsumerState { ), child: Column( children: [ - _buildHeader(context, state, compact: compact), - SizedBox(height: compact ? 8 : 12), + if (!widget.showManagementToolbar) ...[ + _buildHeader(context, state, compact: compact), + SizedBox(height: compact ? 8 : 12), + ], _buildToolbar(context, state), SizedBox(height: compact ? 8 : 12), Expanded( - child: widget.showLibrarySidebar && !compact + child: + widget.showLibrarySidebar && + !widget.showManagementToolbar && + !compact ? Row( children: [ _buildLibraryList(context, state), @@ -461,6 +595,14 @@ class _MediaLibraryPageState extends ConsumerState { 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), + ), ], ], ), @@ -470,7 +612,7 @@ class _MediaLibraryPageState extends ConsumerState { } return Row( children: [ - if (detailWork != null) ...[ + if (detailWork != null && widget.showManagementToolbar) ...[ ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () => setState(() => _detailWork = null), @@ -493,7 +635,7 @@ class _MediaLibraryPageState extends ConsumerState { ), ), const SizedBox(width: 8), - if (detailWork != null) ...[ + if (detailWork != null && widget.showManagementToolbar) ...[ ShadButton.outline( onPressed: _detailSyncing ? null @@ -544,6 +686,14 @@ class _MediaLibraryPageState extends ConsumerState { 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), + ), ], ], ); @@ -750,16 +900,7 @@ class _MediaLibraryPageState extends ConsumerState { Expanded(child: wallContent), ], ); - if (!state.isScanning) { - if (state.scanLogs.isEmpty) return content; - return Column( - children: [ - _recentScanLogs(context, state), - const SizedBox(height: 10), - Expanded(child: content), - ], - ); - } + if (!state.isScanning) return content; return Column( children: [ _scanProgress(context, state), @@ -840,102 +981,12 @@ class _MediaLibraryPageState extends ConsumerState { ), ], ), - if (state.scanLogs.isNotEmpty) ...[ - const SizedBox(height: 10), - Divider(height: 1, color: cs.border), - const SizedBox(height: 8), - Text( - '任务日志', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: cs.mutedForeground, - ), - ), - const SizedBox(height: 4), - SizedBox( - height: 84, - child: ListView.builder( - reverse: true, - itemCount: state.scanLogs.length, - itemBuilder: (context, index) { - final log = state.scanLogs.reversed.elementAt(index); - final time = TimeOfDay.fromDateTime( - log.createdAt, - ).format(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - '$time ${log.message}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: log.isError - ? cs.destructive - : cs.mutedForeground, - ), - ), - ); - }, - ), - ), - ], ], ), ), ); } - Widget _recentScanLogs(BuildContext context, MediaLibraryState state) { - final cs = ShadTheme.of(context).colorScheme; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - decoration: BoxDecoration( - color: cs.muted.withValues(alpha: 0.68), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: cs.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '最近扫描日志', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: cs.mutedForeground, - ), - ), - const SizedBox(height: 4), - SizedBox( - height: 54, - child: ListView.builder( - reverse: true, - itemCount: state.scanLogs.length, - itemBuilder: (context, index) { - final log = state.scanLogs.reversed.elementAt(index); - final time = TimeOfDay.fromDateTime( - log.createdAt, - ).format(context); - return Text( - '$time ${log.message}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: log.isError ? cs.destructive : cs.mutedForeground, - ), - ); - }, - ), - ), - ], - ), - ); - } - Widget _mainEmpty(BuildContext context, String title, String subtitle) { final cs = ShadTheme.of(context).colorScheme; return Center( diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index d3af049..1500b87 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -270,25 +270,7 @@ class _WorkspacePageState extends ConsumerState { children: [ topBar, const SizedBox(height: 8), - Expanded( - child: Stack( - children: [ - Positioned.fill(child: content), - Positioned( - right: 4, - bottom: 8, - child: _MobileFloatingSearch( - mode: _mode, - searchController: _searchController, - searchFocusNode: _searchFocusNode, - searchOpen: _searchOpen, - onSearch: _submitSearch, - onToggleSearch: _toggleSearch, - ), - ), - ], - ), - ), + Expanded(child: content), ], ), ), @@ -375,93 +357,100 @@ class _WorkspacePageState extends ConsumerState { } void _showMobileMenu(BuildContext context) { - final width = (MediaQuery.sizeOf(context).width * 0.86) - .clamp(280.0, 340.0) + final width = (MediaQuery.sizeOf(context).width * 0.78) + .clamp(252.0, 300.0) .toDouble(); showShadSheet( context: context, side: ShadSheetSide.left, builder: (sheetContext) => StatefulBuilder( - builder: (sheetContext, setSheetState) => ShadSheet( - constraints: BoxConstraints.tightFor(width: width), - padding: EdgeInsets.zero, - scrollable: false, - child: Material( - color: Colors.transparent, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Row( - children: [ - _ModeDisplayButton( - mode: _mode, - onTap: () { - _changeMode( - _mode == WorkspaceMode.cloud - ? WorkspaceMode.media - : WorkspaceMode.cloud, - ); - setSheetState(() {}); - }, - ), - const SizedBox(width: 4), - _TopBarIconButton( - tooltip: '设置', - icon: Icons.settings_rounded, - onTap: () { - Navigator.of(sheetContext).pop(); - _showSettings(context); - }, - ), - const Spacer(), - ], + builder: (sheetContext, setSheetState) { + final cs = ShadTheme.of(sheetContext).colorScheme; + return ShadSheet( + constraints: BoxConstraints.tightFor(width: width), + padding: EdgeInsets.zero, + scrollable: false, + backgroundColor: cs.background, + border: const Border(), + shadows: const [], + child: Material( + color: cs.background, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _SidebarBrand( + icon: _mode == WorkspaceMode.cloud + ? Icons.cloud_sync_rounded + : Icons.play_circle_fill_rounded, + title: _mode == WorkspaceMode.cloud ? '光鸭云盘' : '光鸭影视', + subtitle: _mode == WorkspaceMode.cloud + ? 'Cloud Workspace' + : 'Media Center', + imageAsset: _mode == WorkspaceMode.cloud + ? 'assets/branding/guangya_icon.png' + : null, + onSwitchMode: () { + _changeMode( + _mode == WorkspaceMode.cloud + ? WorkspaceMode.media + : WorkspaceMode.cloud, + ); + setSheetState(() {}); + }, + onSettings: () { + Navigator.of(sheetContext).pop(); + _showSettings(context); + }, + ), ), - ), - const ShadSeparator.horizontal(), - Expanded( - child: _mode == WorkspaceMode.cloud - ? _CloudSidebar( - state: ref.read(fileProvider), - width: width, - showBrand: false, - onModeChanged: _changeMode, - onSection: (section) { - Navigator.of(sheetContext).pop(); - ref.read(fileProvider.notifier).setSection(section); - }, - onSettings: () { - Navigator.of(sheetContext).pop(); - _showSettings(context); - }, - onSignOut: () { - Navigator.of(sheetContext).pop(); - ref.read(authProvider.notifier).signOut(); - }, - onTool: (tool) { - Navigator.of(sheetContext).pop(); - _openTool(tool); - }, - ) - : _MediaSidebar( - width: width, - showBrand: false, - onModeChanged: _changeMode, - onSettings: () => _showSettings(context), - onCreate: () { - Navigator.of(sheetContext).pop(); - MediaLibraryPage.showCreateDialog(context, ref); - }, - onTool: (tool) { - Navigator.of(sheetContext).pop(); - _openTool(tool); - }, - ), - ), - ], + const ShadSeparator.horizontal(), + Expanded( + child: _mode == WorkspaceMode.cloud + ? _CloudSidebar( + state: ref.read(fileProvider), + width: width, + showBrand: false, + onModeChanged: _changeMode, + onSection: (section) { + Navigator.of(sheetContext).pop(); + ref + .read(fileProvider.notifier) + .setSection(section); + }, + onSettings: () { + Navigator.of(sheetContext).pop(); + _showSettings(context); + }, + onSignOut: () { + Navigator.of(sheetContext).pop(); + ref.read(authProvider.notifier).signOut(); + }, + onTool: (tool) { + Navigator.of(sheetContext).pop(); + _openTool(tool); + }, + ) + : _MediaSidebar( + width: width, + showBrand: false, + onModeChanged: _changeMode, + onSettings: () => _showSettings(context), + onCreate: () { + Navigator.of(sheetContext).pop(); + MediaLibraryPage.showCreateDialog(context, ref); + }, + onTool: (tool) { + Navigator.of(sheetContext).pop(); + _openTool(tool); + }, + ), + ), + ], + ), ), - ), - ), + ); + }, ), ); } @@ -584,24 +573,67 @@ class _TopBar extends StatelessWidget { if (compact) { return SizedBox( height: 46, - child: Align( - alignment: Alignment.centerLeft, - child: OS26Glass( - radius: 12, - opacity: 0.42, - padding: const EdgeInsets.all(3), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _TopBarIconButton( - tooltip: '打开菜单', - icon: Icons.menu_rounded, - onTap: onOpenMenu, + child: searchOpen + ? OS26Glass( + radius: 12, + opacity: 0.58, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Icon( + Icons.search_rounded, + size: 18, + color: cs.mutedForeground, + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: searchController, + focusNode: searchFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: mode == WorkspaceMode.cloud + ? '搜索文件' + : '搜索影视资源', + ), + textInputAction: TextInputAction.search, + onSubmitted: onSearch, + ), + ), + _TopBarIconButton( + tooltip: '关闭搜索', + icon: Icons.close_rounded, + onTap: onToggleSearch, + ), + ], ), - ], - ), - ), - ), + ) + : Row( + children: [ + OS26Glass( + radius: 12, + opacity: 0.42, + padding: const EdgeInsets.all(3), + child: _TopBarIconButton( + tooltip: '打开菜单', + icon: Icons.menu_rounded, + onTap: onOpenMenu, + ), + ), + const Spacer(), + OS26Glass( + radius: 12, + opacity: 0.52, + padding: const EdgeInsets.all(3), + child: _TopBarIconButton( + tooltip: mode == WorkspaceMode.cloud ? '搜索文件' : '搜索影视资源', + icon: Icons.search_rounded, + onTap: onToggleSearch, + ), + ), + ], + ), ); } return SizedBox( @@ -672,54 +704,6 @@ class _TopBar extends StatelessWidget { } } -class _ModeDisplayButton extends StatelessWidget { - final WorkspaceMode mode; - final VoidCallback onTap; - - const _ModeDisplayButton({required this.mode, required this.onTap}); - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - final isCloud = mode == WorkspaceMode.cloud; - final label = isCloud ? '光鸭云盘' : '光鸭影视'; - final nextLabel = isCloud ? '光鸭影视' : '光鸭云盘'; - final icon = isCloud ? Icons.folder_rounded : Icons.movie_rounded; - return ShadTooltip( - builder: (_) => Text('切换到$nextLabel'), - child: Semantics( - button: true, - label: '$label,点击切换到$nextLabel', - child: ShadButton.ghost( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 8), - onPressed: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 20, color: cs.foreground), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: cs.foreground, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - class _TopBarIconButton extends StatelessWidget { final String tooltip; final IconData icon; @@ -777,74 +761,6 @@ class _UploadListTopButtonState extends State<_UploadListTopButton> { ); } -class _MobileFloatingSearch extends StatelessWidget { - final WorkspaceMode mode; - final TextEditingController searchController; - final FocusNode searchFocusNode; - final bool searchOpen; - final ValueChanged onSearch; - final VoidCallback onToggleSearch; - - const _MobileFloatingSearch({ - required this.mode, - required this.searchController, - required this.searchFocusNode, - required this.searchOpen, - required this.onSearch, - required this.onToggleSearch, - }); - - @override - Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - final maxWidth = MediaQuery.sizeOf(context).width - 40; - return AnimatedContainer( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - width: searchOpen ? maxWidth.clamp(220.0, 300.0).toDouble() : 52, - height: 52, - child: OS26Glass( - radius: 26, - opacity: 0.82, - padding: searchOpen - ? const EdgeInsets.symmetric(horizontal: 8, vertical: 5) - : EdgeInsets.zero, - child: searchOpen - ? Row( - children: [ - Expanded( - child: ShadInput( - controller: searchController, - focusNode: searchFocusNode, - autofocus: true, - placeholder: Text( - mode == WorkspaceMode.cloud ? '搜索文件' : '搜索影视资源', - ), - leading: Icon( - Icons.search_rounded, - size: 17, - color: cs.mutedForeground, - ), - onSubmitted: onSearch, - ), - ), - _TopBarIconButton( - tooltip: '关闭搜索', - icon: Icons.close_rounded, - onTap: onToggleSearch, - ), - ], - ) - : _TopBarIconButton( - tooltip: mode == WorkspaceMode.cloud ? '搜索文件' : '搜索影视资源', - icon: Icons.search_rounded, - onTap: onToggleSearch, - ), - ), - ); - } -} - class _MobileDrawerSwipeArea extends StatefulWidget { final Widget child; final VoidCallback onOpen; @@ -1194,6 +1110,7 @@ class _CloudSidebar extends StatelessWidget { child: OS26Glass( radius: showBrand ? 24 : 0, opacity: showBrand ? 0.56 : 0, + border: showBrand ? null : const Border(), padding: EdgeInsets.fromLTRB(14, showBrand ? 18 : 14, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1307,6 +1224,7 @@ class _MediaSidebar extends ConsumerWidget { child: OS26Glass( radius: showBrand ? 24 : 0, opacity: showBrand ? 0.56 : 0, + border: showBrand ? null : const Border(), padding: EdgeInsets.fromLTRB(14, showBrand ? 18 : 14, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start,