feat: add local caches and unified settings
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user