From 901c980462502fd0d1efec0a5cf1802dc6a6d636 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 11:32:00 +0800 Subject: [PATCH] feat: add local caches and unified settings --- lib/core/storage/storage_manager.dart | 8 + lib/models/cloud_file.dart | 13 ++ lib/pages/settings_page.dart | 226 ++++++++++++++++++---- lib/pages/workspace_page.dart | 18 ++ lib/providers/file_provider.dart | 52 ++++- lib/providers/media_library_provider.dart | 73 +++++-- 6 files changed, 334 insertions(+), 56 deletions(-) diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart index 7433bc4..60ea8de 100644 --- a/lib/core/storage/storage_manager.dart +++ b/lib/core/storage/storage_manager.dart @@ -15,6 +15,14 @@ class StorageKeys { static const String mediaLibraries = 'guangya.mediaLibraries'; static const String mediaLibraryItems = 'guangya.mediaLibraryItems'; static const String mediaCategoryRules = 'guangya.mediaCategoryRules'; + static const String fileListCache = 'guangya.fileListCache'; + static const String httpProxyHost = 'guangya.httpProxyHost'; + static const String httpProxyPort = 'guangya.httpProxyPort'; + static const String mediaScanConcurrency = 'guangya.mediaScanConcurrency'; + static const String fastTransferConcurrency = + 'guangya.fastTransferConcurrency'; + static const String fileCacheTTLMinutes = 'guangya.fileCacheTTLMinutes'; + static const String defaultFilePageSize = 'guangya.defaultFilePageSize'; } class StorageManager { diff --git a/lib/models/cloud_file.dart b/lib/models/cloud_file.dart index 71015f2..d3804cd 100644 --- a/lib/models/cloud_file.dart +++ b/lib/models/cloud_file.dart @@ -162,6 +162,19 @@ class CloudFile { ); } + Map toJson() => { + 'id': id, + 'name': name, + 'isDir': isDirectory, + 'size': size, + 'gcid': gcid, + 'subDirCount': subDirectoryCount, + 'subFileCount': subFileCount, + 'updateTime': modifiedAt, + 'path': cloudPath, + 'fileType': fileType, + }; + static String _extractId(Map json) { for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) { final v = json[key]; diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index b3fc24b..e8a40fe 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -15,6 +15,12 @@ class _SettingsDialogState extends ConsumerState { final _tmdbApiKeyController = TextEditingController(); final _tmdbProxyHostController = TextEditingController(); final _tmdbProxyPortController = TextEditingController(); + final _httpProxyHostController = TextEditingController(); + final _httpProxyPortController = TextEditingController(); + final _scanConcurrencyController = TextEditingController(); + final _transferConcurrencyController = TextEditingController(); + final _cacheTTLController = TextEditingController(); + final _pageSizeController = TextEditingController(); @override void initState() { @@ -25,6 +31,18 @@ class _SettingsDialogState extends ConsumerState { StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; _tmdbProxyPortController.text = StorageManager.get(StorageKeys.tmdbProxyPort) ?? ''; + _httpProxyHostController.text = + StorageManager.get(StorageKeys.httpProxyHost) ?? ''; + _httpProxyPortController.text = + StorageManager.get(StorageKeys.httpProxyPort) ?? ''; + _scanConcurrencyController.text = + StorageManager.get(StorageKeys.mediaScanConcurrency) ?? '3'; + _transferConcurrencyController.text = + StorageManager.get(StorageKeys.fastTransferConcurrency) ?? '3'; + _cacheTTLController.text = + StorageManager.get(StorageKeys.fileCacheTTLMinutes) ?? '3'; + _pageSizeController.text = + StorageManager.get(StorageKeys.defaultFilePageSize) ?? '50'; } @override @@ -32,6 +50,12 @@ class _SettingsDialogState extends ConsumerState { _tmdbApiKeyController.dispose(); _tmdbProxyHostController.dispose(); _tmdbProxyPortController.dispose(); + _httpProxyHostController.dispose(); + _httpProxyPortController.dispose(); + _scanConcurrencyController.dispose(); + _transferConcurrencyController.dispose(); + _cacheTTLController.dispose(); + _pageSizeController.dispose(); super.dispose(); } @@ -62,11 +86,14 @@ class _SettingsDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('外观', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: cs.foreground)), + Text( + '外观', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), const SizedBox(height: 12), _SettingsRow( icon: Icons.light_mode_rounded, @@ -77,26 +104,35 @@ class _SettingsDialogState extends ConsumerState { placeholder: const Text('选择主题'), options: [ ShadOption( - value: 'light', - child: const Row(children: [ + value: 'light', + child: const Row( + children: [ Icon(Icons.light_mode_rounded, size: 14), SizedBox(width: 8), - Text('浅色') - ])), + Text('浅色'), + ], + ), + ), ShadOption( - value: 'dark', - child: const Row(children: [ + value: 'dark', + child: const Row( + children: [ Icon(Icons.dark_mode_rounded, size: 14), SizedBox(width: 8), - Text('深色') - ])), + Text('深色'), + ], + ), + ), ShadOption( - value: 'system', - child: const Row(children: [ + value: 'system', + child: const Row( + children: [ Icon(Icons.brightness_auto_rounded, size: 14), SizedBox(width: 8), - Text('跟随系统') - ])), + Text('跟随系统'), + ], + ), + ), ], selectedOptionBuilder: (ctx, value) => Text(_themeModeToString(_stringToThemeMode(value))), @@ -112,11 +148,92 @@ class _SettingsDialogState extends ConsumerState { const SizedBox(height: 16), const ShadSeparator.horizontal(), const SizedBox(height: 16), - Text('TMDB 配置', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: cs.foreground)), + Text( + '网络与任务', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + const SizedBox(height: 12), + _SettingsRow( + icon: Icons.http_rounded, + label: 'HTTP 代理地址', + child: SizedBox( + width: 200, + child: ShadInput( + controller: _httpProxyHostController, + placeholder: const Text('例: 127.0.0.1'), + ), + ), + ), + _SettingsRow( + icon: Icons.numbers_rounded, + label: 'HTTP 代理端口', + child: SizedBox( + width: 200, + child: ShadInput( + controller: _httpProxyPortController, + placeholder: const Text('例: 7890'), + ), + ), + ), + _SettingsRow( + icon: Icons.memory_rounded, + label: '媒体扫描并发', + child: SizedBox( + width: 100, + child: ShadInput( + controller: _scanConcurrencyController, + keyboardType: TextInputType.number, + ), + ), + ), + _SettingsRow( + icon: Icons.bolt_rounded, + label: '秒传并发', + child: SizedBox( + width: 100, + child: ShadInput( + controller: _transferConcurrencyController, + keyboardType: TextInputType.number, + ), + ), + ), + _SettingsRow( + icon: Icons.cached_rounded, + label: '文件缓存分钟', + child: SizedBox( + width: 100, + child: ShadInput( + controller: _cacheTTLController, + keyboardType: TextInputType.number, + ), + ), + ), + _SettingsRow( + icon: Icons.format_list_numbered_rounded, + label: '默认分页大小', + child: SizedBox( + width: 100, + child: ShadInput( + controller: _pageSizeController, + keyboardType: TextInputType.number, + ), + ), + ), + const SizedBox(height: 16), + const ShadSeparator.horizontal(), + const SizedBox(height: 16), + Text( + 'TMDB 配置', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), const SizedBox(height: 12), _SettingsRow( icon: Icons.key_rounded, @@ -156,17 +273,22 @@ class _SettingsDialogState extends ConsumerState { const SizedBox(height: 16), const ShadSeparator.horizontal(), const SizedBox(height: 16), - Text('关于', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: cs.foreground)), + Text( + '关于', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), const SizedBox(height: 12), _SettingsRow( icon: Icons.cloud_rounded, label: '版本', - child: Text('v1.0.0', - style: TextStyle(fontSize: 13, color: cs.mutedForeground)), + child: Text( + 'v1.0.0', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), ), ], ), @@ -177,11 +299,41 @@ class _SettingsDialogState extends ConsumerState { void _saveSettings() { StorageManager.set( - StorageKeys.tmdbApiKey, _tmdbApiKeyController.text.trim()); + StorageKeys.tmdbApiKey, + _tmdbApiKeyController.text.trim(), + ); StorageManager.set( - StorageKeys.tmdbProxyHost, _tmdbProxyHostController.text.trim()); + StorageKeys.tmdbProxyHost, + _tmdbProxyHostController.text.trim(), + ); StorageManager.set( - StorageKeys.tmdbProxyPort, _tmdbProxyPortController.text.trim()); + StorageKeys.tmdbProxyPort, + _tmdbProxyPortController.text.trim(), + ); + StorageManager.set( + StorageKeys.httpProxyHost, + _httpProxyHostController.text.trim(), + ); + StorageManager.set( + StorageKeys.httpProxyPort, + _httpProxyPortController.text.trim(), + ); + StorageManager.set( + StorageKeys.mediaScanConcurrency, + _scanConcurrencyController.text.trim(), + ); + StorageManager.set( + StorageKeys.fastTransferConcurrency, + _transferConcurrencyController.text.trim(), + ); + StorageManager.set( + StorageKeys.fileCacheTTLMinutes, + _cacheTTLController.text.trim(), + ); + StorageManager.set( + StorageKeys.defaultFilePageSize, + _pageSizeController.text.trim(), + ); } String _themeModeToString(ThemeMode mode) { @@ -212,8 +364,11 @@ class _SettingsRow extends StatelessWidget { final String label; final Widget child; - const _SettingsRow( - {required this.icon, required this.label, required this.child}); + const _SettingsRow({ + required this.icon, + required this.label, + required this.child, + }); @override Widget build(BuildContext context) { @@ -225,8 +380,7 @@ class _SettingsRow extends StatelessWidget { children: [ Icon(icon, size: 18, color: cs.mutedForeground), const SizedBox(width: 12), - Text(label, - style: TextStyle(fontSize: 14, color: cs.foreground)), + Text(label, style: TextStyle(fontSize: 14, color: cs.foreground)), const Spacer(), child, ], diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 9c3ed4a..5fd3a91 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -126,6 +126,7 @@ class _WorkspacePageState extends ConsumerState { }); } }, + onSettings: () => _showSettings(context), ), const SizedBox(height: 14), Expanded( @@ -210,6 +211,7 @@ class _TopBar extends StatelessWidget { final bool searchOpen; final ValueChanged onSearch; final VoidCallback onToggleSearch; + final VoidCallback onSettings; const _TopBar({ required this.mode, @@ -219,6 +221,7 @@ class _TopBar extends StatelessWidget { required this.searchOpen, required this.onSearch, required this.onToggleSearch, + required this.onSettings, }); @override @@ -249,6 +252,21 @@ class _TopBar extends StatelessWidget { selected: mode == WorkspaceMode.media, onTap: () => onModeChanged(WorkspaceMode.media), ), + ShadTooltip( + builder: (_) => const Text('设置'), + child: SizedBox( + width: 34, + height: 32, + child: GestureDetector( + onTap: onSettings, + child: Icon( + Icons.settings_rounded, + size: 17, + color: cs.mutedForeground, + ), + ), + ), + ), ], ), ), diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index b53a60d..b479dca 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:url_launcher/url_launcher.dart'; import '../api/guangya_api.dart'; +import '../core/storage/storage_manager.dart'; import '../models/cloud_file.dart'; enum FileSort { name, size, modifiedAt, createdAt, type } @@ -158,13 +159,22 @@ class FileNotifier extends StateNotifier { Future loadFiles({String? parentID}) async { if (_api == null) return; + final resolvedParentID = parentID ?? _currentParentID; + final cacheKey = + '${state.section.name}:${resolvedParentID ?? 'root'}:${state.currentPage}:${state.pageSize}'; + final cached = _readCachedFiles(cacheKey); + if (cached != null) { + state = state.copyWith(files: cached, clearError: true); + return; + } state = state.copyWith(isLoading: true, clearError: true); try { - final result = await _fetchFiles(parentID ?? _currentParentID); + final result = await _fetchFiles(resolvedParentID); final extracted = _extractFiles(result); final totalPages = _extractTotalPages(result, extracted.length); state = state.copyWith(files: extracted, totalPages: totalPages); + await _writeCachedFiles(cacheKey, extracted); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { @@ -172,6 +182,46 @@ class FileNotifier extends StateNotifier { } } + List? _readCachedFiles(String key) { + final raw = StorageManager.get(StorageKeys.fileListCache); + if (raw is! Map || raw[key] is! Map) return null; + final entry = Map.from(raw[key] as Map); + final cachedAt = int.tryParse(entry['cachedAt']?.toString() ?? ''); + final files = entry['files']; + final ttlMinutes = + int.tryParse( + StorageManager.get(StorageKeys.fileCacheTTLMinutes) ?? '3', + ) ?? + 3; + if (cachedAt == null || + files is! List || + DateTime.now().millisecondsSinceEpoch - cachedAt > + Duration(minutes: ttlMinutes.clamp(0, 60)).inMilliseconds) { + return null; + } + try { + return files + .whereType() + .map((value) => CloudFile.fromJson(Map.from(value))) + .toList(); + } catch (_) { + return null; + } + } + + Future _writeCachedFiles(String key, List files) async { + final raw = StorageManager.get(StorageKeys.fileListCache); + final cache = raw is Map + ? Map.from(raw) + : {}; + cache[key] = { + 'cachedAt': DateTime.now().millisecondsSinceEpoch, + 'files': files.map((file) => file.toJson()).toList(), + }; + if (cache.length > 80) cache.remove(cache.keys.first); + await StorageManager.set(StorageKeys.fileListCache, cache); + } + Future> _fetchFiles(String? parentID) async { switch (state.section) { case WorkspaceSection.files: diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index f67107d..c59fe8e 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -209,31 +209,66 @@ class MediaLibraryNotifier extends StateNotifier { StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; final tmdbProxyPort = StorageManager.get(StorageKeys.tmdbProxyPort) ?? ''; - for (final file in discovered) { - if (_cancelScan) break; - final fallback = MediaLibraryItem.fromFile(library.id, file); - unique[file.id] = await _recognizeMediaItem( - fallback, - tmdbApiKey, - proxyHost: tmdbProxyHost, - proxyPort: tmdbProxyPort, - ); - state = state.copyWith( - progress: MediaLibraryScanProgress( - phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}', - completed: unique.length, - total: discovered.length, - ), - ); + final input = { + for (final file in discovered) file.id: file, + }.values.toList(); + var nextIndex = 0; + var completed = 0; + Future pendingPersistence = Future.value(); + final initialItems = _loadAllItems() + ..removeWhere((item) => item.libraryID == library.id); + + Future worker() async { + while (!_cancelScan) { + if (nextIndex >= input.length) return; + final file = input[nextIndex++]; + final fallback = MediaLibraryItem.fromFile(library.id, file); + final item = await _recognizeMediaItem( + fallback, + tmdbApiKey, + proxyHost: tmdbProxyHost, + proxyPort: tmdbProxyPort, + ); + unique[file.id] = item; + completed += 1; + final visible = unique.values.toList() + ..sort( + (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), + ); + // Serialize writes so an earlier, smaller snapshot cannot overwrite + // a later batch while recognition workers finish out of order. + pendingPersistence = pendingPersistence.then( + (_) => _saveAllItems([...initialItems, ...unique.values]), + ); + await pendingPersistence; + state = state.copyWith( + items: visible, + progress: MediaLibraryScanProgress( + phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}', + completed: completed, + total: input.length, + ), + ); + } } + + final concurrency = + (int.tryParse( + StorageManager.get( + StorageKeys.mediaScanConcurrency, + ) ?? + '3', + ) ?? + 3) + .clamp(1, 20); + await Future.wait(List.generate(concurrency, (_) => worker())); + await pendingPersistence; final items = unique.values.toList() ..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); - final allItems = _loadAllItems() - ..removeWhere((item) => item.libraryID == library.id) - ..addAll(items); + final allItems = [...initialItems, ...items]; final updatedLibrary = library.copyWith(updatedAt: DateTime.now()); final libraries = state.libraries .map((item) => item.id == library.id ? updatedLibrary : item)