feat: connect file actions and media recognition
This commit is contained in:
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user