polish media details and mobile navigation

This commit is contained in:
ngfchl
2026-07-19 15:28:13 +08:00
parent 357731f265
commit 3b2935ca0b
6 changed files with 308 additions and 278 deletions
+17 -12
View File
@@ -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,
);
}
}
+1
View File
@@ -36,6 +36,7 @@ class StorageKeys {
'guangya.cloudIndexLastUpdatedAt';
static const String cloudScrapedBackupFolderID =
'guangya.cloudScrapedBackupFolderID';
static const String workspaceMode = 'guangya.workspaceMode';
}
class StorageManager {
+132 -262
View File
@@ -233,135 +233,6 @@ class _BackupActionsMenuState extends State<_BackupActionsMenu> {
);
}
class _MediaLibrarySwitcher extends StatefulWidget {
final List<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
final ValueChanged<String> 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<MediaLibraryPage> {
final String _tmdbApiKey =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
@@ -439,8 +310,10 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
_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<MediaLibraryPage> {
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<MediaLibraryPage> {
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<MediaLibraryPage> {
),
),
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<MediaLibraryPage> {
),
],
);
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<MediaLibraryPage> {
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<MediaLibraryPage> {
}
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<MediaLibraryPage> {
),
),
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 = <Widget>[
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<MediaLibraryPage> {
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<MediaLibraryPage> {
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<MediaLibraryItem> onDownload;
final ValueChanged<MediaLibraryItem> onPlay;
final ValueChanged<MediaLibraryItem> 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),
+14 -2
View File
@@ -208,6 +208,14 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
WorkspaceTool? _cloudActiveTool;
WorkspaceTool? _mediaActiveTool;
@override
void initState() {
super.initState();
if (StorageManager.get<String>(StorageKeys.workspaceMode) == 'media') {
_mode = WorkspaceMode.media;
}
}
void _openTool(WorkspaceTool tool) {
setState(() {
if (_mode == WorkspaceMode.cloud) {
@@ -326,6 +334,7 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
_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<WorkspacePage> {
}
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<WorkspacePage> {
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,
+141 -2
View File
@@ -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<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
final ValueChanged<String> 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),
@@ -734,12 +734,14 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
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<MediaLibraryState> {
if (forceRemote) {
_appendScanLog('正在获取云盘全量文件索引…');
await refreshGlobalCloudIndex(force: true);
if (_cancelScan) return;
final globalFiles = await _cachedGlobalFiles();
final seen = <String>{};
final mediaFiles = <CloudFile>[];