feat: add local caches and unified settings

This commit is contained in:
ngfchl
2026-07-18 11:32:00 +08:00
parent fa810793d0
commit 901c980462
6 changed files with 334 additions and 56 deletions
+8
View File
@@ -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 {
+13
View File
@@ -162,6 +162,19 @@ class CloudFile {
);
}
Map<String, dynamic> 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<String, dynamic> json) {
for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) {
final v = json[key];
+190 -36
View File
@@ -15,6 +15,12 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
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<SettingsDialog> {
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
_tmdbProxyPortController.text =
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '';
_httpProxyHostController.text =
StorageManager.get<String>(StorageKeys.httpProxyHost) ?? '';
_httpProxyPortController.text =
StorageManager.get<String>(StorageKeys.httpProxyPort) ?? '';
_scanConcurrencyController.text =
StorageManager.get<String>(StorageKeys.mediaScanConcurrency) ?? '3';
_transferConcurrencyController.text =
StorageManager.get<String>(StorageKeys.fastTransferConcurrency) ?? '3';
_cacheTTLController.text =
StorageManager.get<String>(StorageKeys.fileCacheTTLMinutes) ?? '3';
_pageSizeController.text =
StorageManager.get<String>(StorageKeys.defaultFilePageSize) ?? '50';
}
@override
@@ -32,6 +50,12 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
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,
],
+18
View File
@@ -126,6 +126,7 @@ class _WorkspacePageState extends ConsumerState<WorkspacePage> {
});
}
},
onSettings: () => _showSettings(context),
),
const SizedBox(height: 14),
Expanded(
@@ -210,6 +211,7 @@ class _TopBar extends StatelessWidget {
final bool searchOpen;
final ValueChanged<String> 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,
),
),
),
),
],
),
),
+51 -1
View File
@@ -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<FileState> {
Future<void> 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<FileState> {
}
}
List<CloudFile>? _readCachedFiles(String key) {
final raw = StorageManager.get<dynamic>(StorageKeys.fileListCache);
if (raw is! Map || raw[key] is! Map) return null;
final entry = Map<dynamic, dynamic>.from(raw[key] as Map);
final cachedAt = int.tryParse(entry['cachedAt']?.toString() ?? '');
final files = entry['files'];
final ttlMinutes =
int.tryParse(
StorageManager.get<String>(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>()
.map((value) => CloudFile.fromJson(Map<String, dynamic>.from(value)))
.toList();
} catch (_) {
return null;
}
}
Future<void> _writeCachedFiles(String key, List<CloudFile> files) async {
final raw = StorageManager.get<dynamic>(StorageKeys.fileListCache);
final cache = raw is Map
? Map<dynamic, dynamic>.from(raw)
: <dynamic, dynamic>{};
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<Map<String, dynamic>> _fetchFiles(String? parentID) async {
switch (state.section) {
case WorkspaceSection.files:
+54 -19
View File
@@ -209,31 +209,66 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
final tmdbProxyPort =
StorageManager.get<String>(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 = <String, CloudFile>{
for (final file in discovered) file.id: file,
}.values.toList();
var nextIndex = 0;
var completed = 0;
Future<void> pendingPersistence = Future.value();
final initialItems = _loadAllItems()
..removeWhere((item) => item.libraryID == library.id);
Future<void> 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<String>(
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)