diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index e14ba80..9c3ed4a 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -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]), ); diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index e2f0882..b53a60d 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -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 { Future 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 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 restoreFiles(List 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 _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 uploadLocalFiles(List files, {String? parentID}) async { if (_api == null || files.isEmpty) return; final targetParentID = parentID ?? _currentParentID; diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 660041a..feb9c76 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -203,8 +203,19 @@ class MediaLibraryNotifier extends StateNotifier { } final unique = {}; + final tmdbApiKey = + StorageManager.get(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 { state = state.copyWith(clearError: true); } + Future _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? candidate; + for (final value in values) { + if (value is! Map) continue; + final map = Map.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> _scanSource( String? rootID, String rootPath, { diff --git a/lib/widgets/file_list_tile.dart b/lib/widgets/file_list_tile.dart index dad89cd..084259b 100644 --- a/lib/widgets/file_list_tile.dart +++ b/lib/widgets/file_list_tile.dart @@ -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