feat: connect file actions and media recognition

This commit is contained in:
ngfchl
2026-07-18 11:16:02 +08:00
parent 676f5057cb
commit c37fbe9e7f
4 changed files with 207 additions and 25 deletions
+39 -3
View File
@@ -311,6 +311,37 @@ class _TopBar extends StatelessWidget {
}
}
void _showRenameDialog(BuildContext context, WidgetRef ref, CloudFile file) {
final controller = TextEditingController(text: file.name);
showShadDialog(
context: context,
builder: (dialogContext) => ShadDialog(
title: const Text('重命名'),
description: Text(file.name),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: () {
final name = controller.text.trim();
if (name.isEmpty || name == file.name) return;
ref.read(fileProvider.notifier).renameFile(file, name);
Navigator.of(dialogContext).pop();
},
child: const Text('保存'),
),
],
child: ShadInput(
controller: controller,
autofocus: true,
placeholder: const Text('文件名称'),
),
),
);
}
class _SegmentButton extends StatelessWidget {
final IconData icon;
final String label;
@@ -1041,12 +1072,15 @@ class _PrimaryFilePane extends ConsumerWidget {
onOpen: file.isDirectory
? () => notifier.navigateToFolder(file)
: () => notifier.downloadFile(file),
onRename: () {},
onRename: () => _showRenameDialog(context, ref, file),
onCopy: () => notifier.copyToClipboard([file]),
onCut: () => notifier.cutToClipboard([file]),
onDownload: () => notifier.downloadFile(file),
onShare: () {},
onDelete: () => notifier.deleteFiles([file]),
onShare: () => notifier.createShare(file),
isRecycleItem: state.section == WorkspaceSection.recycle,
onDelete: () => state.section == WorkspaceSection.recycle
? notifier.restoreFiles([file])
: notifier.deleteFiles([file]),
);
return Draggable<_DraggedCloudFiles>(
data: _DraggedCloudFiles([file], _PaneIdentity.primary),
@@ -1241,8 +1275,10 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> {
ref.read(fileProvider.notifier).copyToClipboard([file]),
onCut: () =>
ref.read(fileProvider.notifier).cutToClipboard([file]),
onRename: () => _showRenameDialog(context, ref, file),
onDownload: () =>
ref.read(fileProvider.notifier).downloadFile(file),
onShare: () => ref.read(fileProvider.notifier).createShare(file),
onDelete: () =>
ref.read(fileProvider.notifier).deleteFiles([file]),
);
+70 -6
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api/guangya_api.dart';
import '../models/cloud_file.dart';
@@ -430,20 +432,82 @@ class FileNotifier extends StateNotifier<FileState> {
Future<void> downloadFile(CloudFile file) async {
if (_api == null) return;
try {
final result = await _api!.downloadURL(file.id);
final url = _findStringDeep(result, [
state = state.copyWith(statusMessage: '正在准备外部打开…');
final url = await _resolveOpenUrl(file);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('无法调用系统默认应用打开链接');
}
state = state.copyWith(statusMessage: '已交给系统默认应用');
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> createShare(CloudFile file) async {
if (_api == null) return;
try {
state = state.copyWith(statusMessage: '正在创建分享…');
final result = await _api!.shareCreate([file.id], title: file.name);
final link = _findStringDeep(result, const [
'url',
'downloadUrl',
'download_url',
'shareUrl',
'share_url',
'link',
]);
if (url != null) {
state = state.copyWith(statusMessage: '下载链接: $url');
if (link != null) {
await Clipboard.setData(ClipboardData(text: link));
state = state.copyWith(statusMessage: '分享链接已复制');
} else {
state = state.copyWith(statusMessage: '分享已创建');
}
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> restoreFiles(List<CloudFile> files) async {
if (_api == null || files.isEmpty) return;
try {
state = state.copyWith(statusMessage: '正在恢复 ${files.length} 个项目…');
await _api!.fsRecycle(files.map((file) => file.id).toList());
state = state.copyWith(statusMessage: '已恢复 ${files.length} 个项目');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<Uri> _resolveOpenUrl(CloudFile file) async {
String? url;
if (file.isVideo) {
final detail = await _api!.fsDetail(file.id);
final gcid = file.gcid ?? _findStringDeep(detail, const ['gcid', 'gcId']);
if (gcid != null && gcid.isNotEmpty) {
final videoResult = await _api!.vodDownloadURL(file.id, gcid);
url = _findStringDeep(videoResult, const [
'signedURL',
'signedUrl',
'url',
'downloadUrl',
'download_url',
'dlink',
]);
}
}
if (url == null) {
final result = await _api!.downloadURL(file.id);
url = _findStringDeep(result, const [
'url',
'downloadUrl',
'download_url',
'dlink',
]);
}
final uri = url == null ? null : Uri.tryParse(url);
if (uri == null || !uri.hasScheme) throw Exception('响应缺少可打开的签名链接');
return uri;
}
Future<void> uploadLocalFiles(List<File> files, {String? parentID}) async {
if (_api == null || files.isEmpty) return;
final targetParentID = parentID ?? _currentParentID;
+69 -1
View File
@@ -203,8 +203,19 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
}
final unique = <String, MediaLibraryItem>{};
final tmdbApiKey =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
for (final file in discovered) {
unique[file.id] = MediaLibraryItem.fromFile(library.id, file);
if (_cancelScan) break;
final fallback = MediaLibraryItem.fromFile(library.id, file);
unique[file.id] = await _recognizeMediaItem(fallback, tmdbApiKey);
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: tmdbApiKey.isEmpty ? '正在建立本地索引' : '正在识别 ${file.name}',
completed: unique.length,
total: discovered.length,
),
);
}
final items = unique.values.toList()
..sort(
@@ -264,6 +275,63 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
state = state.copyWith(clearError: true);
}
Future<MediaLibraryItem> _recognizeMediaItem(
MediaLibraryItem fallback,
String apiKey,
) async {
if (_api == null || apiKey.trim().isEmpty) return fallback;
try {
final result = await _api!.tmdbSearch(fallback.title, apiKey: apiKey);
final values = result['results'];
if (values is! List) return fallback;
Map<String, dynamic>? candidate;
for (final value in values) {
if (value is! Map) continue;
final map = Map<String, dynamic>.from(value);
final type = map['media_type']?.toString();
if (type == 'movie' || type == 'tv') {
candidate = map;
break;
}
}
if (candidate == null) return fallback;
final type = candidate['media_type']?.toString();
final title = (candidate['title'] ?? candidate['name'])
?.toString()
.trim();
final originalTitle =
(candidate['original_title'] ?? candidate['original_name'])
?.toString()
.trim();
final releaseDate =
(candidate['release_date'] ?? candidate['first_air_date'])
?.toString() ??
'';
return MediaLibraryItem(
libraryID: fallback.libraryID,
file: fallback.file,
tmdbID: _toInt(candidate['id']),
title: title == null || title.isEmpty ? fallback.title : title,
originalTitle: originalTitle == null || originalTitle.isEmpty
? fallback.originalTitle
: originalTitle,
mediaKind: type == 'tv' ? TMDBMediaKind.tv : TMDBMediaKind.movie,
releaseDate: releaseDate,
overview: candidate['overview']?.toString() ?? '',
posterPath: candidate['poster_path']?.toString(),
backdropPath: candidate['backdrop_path']?.toString(),
updatedAt: DateTime.now(),
);
} catch (_) {
return fallback;
}
}
int? _toInt(dynamic value) {
if (value is int) return value;
return int.tryParse(value?.toString() ?? '');
}
Future<List<CloudFile>> _scanSource(
String? rootID,
String rootPath, {
+29 -15
View File
@@ -15,6 +15,7 @@ class FileListTile extends StatelessWidget {
final VoidCallback? onDownload;
final VoidCallback? onShare;
final VoidCallback? onDelete;
final bool isRecycleItem;
const FileListTile({
super.key,
@@ -28,6 +29,7 @@ class FileListTile extends StatelessWidget {
this.onDownload,
this.onShare,
this.onDelete,
this.isRecycleItem = false,
});
@override
@@ -43,8 +45,9 @@ class FileListTile extends StatelessWidget {
onPressed: onOpen,
child: const Text('打开'),
),
const ShadContextMenuItem.inset(
enabled: false,
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.pencil, size: 16),
onPressed: onRename,
child: Text('重命名'),
),
ShadContextMenuItem.inset(
@@ -74,12 +77,27 @@ class FileListTile extends StatelessWidget {
),
const Divider(height: 8),
ShadContextMenuItem.inset(
leading: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.destructive),
trailing: Icon(LucideIcons.chevronRight, color: theme.colorScheme.destructive),
leading: Icon(
isRecycleItem ? LucideIcons.rotateCcw : LucideIcons.trash2,
size: 16,
color: isRecycleItem
? theme.colorScheme.primary
: theme.colorScheme.destructive,
),
trailing: Icon(
LucideIcons.chevronRight,
color: isRecycleItem
? theme.colorScheme.primary
: theme.colorScheme.destructive,
),
onPressed: onDelete,
child: Text(
'删除',
style: TextStyle(color: theme.colorScheme.destructive),
isRecycleItem ? '恢复' : '删除',
style: TextStyle(
color: isRecycleItem
? theme.colorScheme.primary
: theme.colorScheme.destructive,
),
),
),
],
@@ -92,11 +110,11 @@ class FileListTile extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected
? (isDark
? theme.colorScheme.primary.withAlpha(30)
: theme.colorScheme.primary.withAlpha(15))
? theme.colorScheme.primary.withAlpha(30)
: theme.colorScheme.primary.withAlpha(15))
: (isDark
? Colors.white.withAlpha(3)
: Colors.black.withAlpha(2)),
? Colors.white.withAlpha(3)
: Colors.black.withAlpha(2)),
border: Border(
bottom: BorderSide(
color: isDark
@@ -120,11 +138,7 @@ class FileListTile extends StatelessWidget {
),
// File icon
SizedBox(
width: 32,
height: 32,
child: FileIcon(file: file),
),
SizedBox(width: 32, height: 32, child: FileIcon(file: file)),
const SizedBox(width: 12),
// File name