feat(tasks): group extension folder downloads (#1594)

This commit is contained in:
WuFeng authored and GitHub committed 2026-11-02 15:39:08 +08:00
1 parent fc0a1e0ef9
commit 1e36599d52
84 files changed
+6690 -881

No files matched your search

+15
View File
@@ -94,6 +94,8 @@ State broadcasting must observe the owning Riverpod state centrally. Do not broa
- Storage RPC methods expose business capabilities such as create-history operations, not raw `box.get` or `box.put` access.
- Prefer batch mutations across the window boundary. For example, save all parsed URLs in one `saveCreateHistory(List<String>)` call.
- New child-window storage requirements must be added to `StorageMethods` and bound in the main registry.
- Config reads, full config saves, and create-history mutations share one main-window queue. Each history mutation reads, changes, and writes the config within that queue. History reads wait for earlier saves.
- `StorageMethods` owns `extra.createHistory`; full config saves preserve its latest value inside the queue so stale settings or child-window snapshots cannot overwrite history.
## 7. Gopeed Backend Migration
@@ -128,3 +130,16 @@ Child-window code must not:
- Parse RPC JSON directly inside a page or widget.
These constraints keep all backend and persistence ownership in the main window and allow multiple child windows to operate concurrently without database locks or duplicated Gopeed runtimes.
## 10. Batch Task Creation
`GopeedMethods.createTaskBatch` carries `CreateTaskBatch` through the existing
codec registry. Each member accepts either `rid` from resolve or `req`, plus
creation `opts`. A nonempty `rid` takes priority. BT selections retain their
original file indexes in `opts.selectFiles`.
Both ordinary and grouped creation return `CreateTaskBatchResult`, an object
with `taskIds` and an optional `parentId`. A successful response completes the
batch, including partial success. The preview tracks completed batches so a
failed batch can be retried without repeating successful batches. Creation does
not accept a group ID to append members to an existing group.
+3 -3
View File
@@ -85,10 +85,10 @@ Future<String> createTask(CreateTask createTask) {
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
}
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<List<String>>(
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<CreateTaskBatchResult>(
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
(json) => CreateTaskBatchResult.fromJson(json as Map<String, dynamic>),
);
}
@@ -10,7 +10,10 @@ class CreateTaskBatch {
List<CreateTaskBatchItem>? reqs;
Options? opts;
CreateTaskBatch({this.reqs, this.opts});
bool group;
String groupName;
CreateTaskBatch({this.reqs, this.opts, this.group = false, this.groupName = ''});
factory CreateTaskBatch.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchFromJson(json);
@@ -19,11 +22,23 @@ class CreateTaskBatch {
@JsonSerializable()
class CreateTaskBatchItem {
String? rid;
Request? req;
Options? opts;
CreateTaskBatchItem({this.req, this.opts});
CreateTaskBatchItem({this.rid, this.req, this.opts});
factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchItemFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);
}
@JsonSerializable()
class CreateTaskBatchResult {
final List<String> taskIds;
final String? parentId;
CreateTaskBatchResult({required this.taskIds, this.parentId});
factory CreateTaskBatchResult.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchResultFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchResultToJson(this);
}
@@ -6,22 +6,55 @@ part of 'create_task_batch.dart';
// JsonSerializableGenerator
// **************************************************************************
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) => CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>)).toList(),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>
CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)
?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))
.toList(),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
group: json['group'] as bool? ?? false,
groupName: json['groupName'] as String? ?? '',
);
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) => <String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
};
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) =>
<String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
'group': instance.group,
'groupName': instance.groupName,
};
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) => CreateTaskBatchItem(
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>
CreateTaskBatchItem(
rid: json['rid'] as String?,
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => <String, dynamic>{
Map<String, dynamic> _$CreateTaskBatchItemToJson(
CreateTaskBatchItem instance,
) => <String, dynamic>{
'rid': ?instance.rid,
'req': ?instance.req,
'opts': ?instance.opts,
};
CreateTaskBatchResult _$CreateTaskBatchResultFromJson(
Map<String, dynamic> json,
) => CreateTaskBatchResult(
taskIds: (json['taskIds'] as List<dynamic>).map((e) => e as String).toList(),
parentId: json['parentId'] as String?,
);
Map<String, dynamic> _$CreateTaskBatchResultToJson(
CreateTaskBatchResult instance,
) => <String, dynamic>{
'taskIds': instance.taskIds,
'parentId': ?instance.parentId,
};
+17
View File
@@ -28,7 +28,10 @@ enum ExtractStatus {
class Task {
String id;
String name;
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
Protocol? protocol;
TaskGroup? group;
String parentId;
Meta meta;
Status status;
bool uploading;
@@ -47,6 +50,8 @@ class Task {
required this.createdAt,
required this.updatedAt,
this.doneAt,
this.group,
this.parentId = '',
});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
@@ -54,6 +59,18 @@ class Task {
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
@JsonSerializable()
class TaskGroup {
final String id;
final String name;
final String path;
final DateTime createdAt;
TaskGroup({required this.id, required this.name, required this.path, required this.createdAt});
factory TaskGroup.fromJson(Map<String, dynamic> json) => _$TaskGroupFromJson(json);
Map<String, dynamic> toJson() => _$TaskGroupToJson(this);
}
@JsonSerializable()
class Progress {
int used;
+19 -1
View File
@@ -16,12 +16,16 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
group: json['group'] == null ? null : TaskGroup.fromJson(json['group'] as Map<String, dynamic>),
parentId: json['parentId'] as String? ?? '',
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol'], unknownValue: JsonKey.nullForUndefinedEnumValue);
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'protocol': ?_$ProtocolEnumMap[instance.protocol],
'group': ?instance.group?.toJson(),
'parentId': instance.parentId,
'meta': instance.meta.toJson(),
'status': _$StatusEnumMap[instance.status]!,
'uploading': instance.uploading,
@@ -48,6 +52,20 @@ const _$ProtocolEnumMap = {
Protocol.ftp: 'ftp',
};
TaskGroup _$TaskGroupFromJson(Map<String, dynamic> json) => TaskGroup(
id: json['id'] as String,
name: json['name'] as String,
path: json['path'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$TaskGroupToJson(TaskGroup instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'path': instance.path,
'createdAt': instance.createdAt.toIso8601String(),
};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: (json['used'] as num).toInt(),
speed: (json['speed'] as num).toInt(),
@@ -34,8 +34,9 @@ class AppCapabilities {
class LocalAppCapabilities {
LocalAppCapabilities._() : codecs = createAppCapabilityCodecs() {
registry = CapabilityRegistry(codecs);
_bindGopeed(registry);
_bindStorage(registry);
final config = _ConfigStore();
_bindGopeed(registry, config);
_bindStorage(registry, config);
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
AppRouter.showDownloadingTasks();
return const RpcUnit();
@@ -62,6 +63,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
..register<ResolveResult>((json) => ResolveResult.fromJson(json! as Map<String, dynamic>))
..register<CreateTask>((json) => CreateTask.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatch>((json) => CreateTaskBatch.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatchResult>((json) => CreateTaskBatchResult.fromJson(json! as Map<String, dynamic>))
..register<DownloaderConfig>((json) => DownloaderConfig.fromJson(json! as Map<String, dynamic>))
..register<TaskRuntimeStatus>((json) => TaskRuntimeStatus.fromJson(json! as Map<String, dynamic>))
..register<List<Task>>(
@@ -80,7 +82,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
return codecs;
}
void _bindGopeed(CapabilityRegistry registry) {
void _bindGopeed(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(GopeedMethods.triggerProtocolJob, (p) async {
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
@@ -127,9 +129,9 @@ void _bindGopeed(CapabilityRegistry registry) {
await api.deleteTasks(_optionalStringList(params['ids']), params['force'] as bool? ?? false);
return const RpcUnit();
})
..bind(GopeedMethods.getConfig, (_) => api.getConfig())
..bind(GopeedMethods.getConfig, (_) => configStore.get())
..bind(GopeedMethods.putConfig, (config) async {
await api.putConfig(config);
await configStore.put(config);
return const RpcUnit();
})
..bind(GopeedMethods.installExtension, api.installExtension)
@@ -163,35 +165,59 @@ void _bindGopeed(CapabilityRegistry registry) {
});
}
void _bindStorage(CapabilityRegistry registry) {
// Shared by local and child-window handlers. History owns its config field;
// full config snapshots must preserve the latest history inside the same queue.
class _ConfigStore {
Future<void> _tail = Future<void>.value();
Future<T> _run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
Future<DownloaderConfig> get() => _run(api.getConfig);
Future<void> put(DownloaderConfig config) {
final snapshot = DownloaderConfig.fromJson(config.toJson());
return _run(() async {
final latest = await api.getConfig();
snapshot.extra.createHistory = latest.extra.createHistory;
await api.putConfig(snapshot);
});
}
Future<void> updateHistory(void Function(List<String>) mutation) => _run(() async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
mutation(history);
config.extra.createHistory = history;
await api.putConfig(config);
});
}
void _bindStorage(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(StorageMethods.getCreateHistory, (_) async {
final config = await api.getConfig();
final config = await configStore.get();
return config.extra.createHistory;
})
..bind(StorageMethods.saveCreateHistory, (urls) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) {
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
});
return const RpcUnit();
})
..bind(StorageMethods.removeCreateHistory, (url) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory)..remove(url);
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) => history.remove(url));
return const RpcUnit();
})
..bind(StorageMethods.clearCreateHistory, (_) async {
final config = await api.getConfig();
config.extra.createHistory = const [];
await api.putConfig(config);
await configStore.updateHistory((history) => history.clear());
return const RpcUnit();
});
}
@@ -17,7 +17,7 @@ abstract final class GopeedMethods {
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
static const createTaskBatch = RpcMethod<CreateTaskBatch, CreateTaskBatchResult>('gopeed.task.createBatch');
static const patchTask = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.task.patch');
static const getTasks = RpcMethod<List<String>, List<Task>>('gopeed.task.list');
static const getTaskStatus = RpcMethod<String, TaskRuntimeStatus>('gopeed.task.status');
@@ -53,7 +53,7 @@ class GopeedService {
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
Future<List<String>> createTaskBatch(CreateTaskBatch request) =>
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch request) =>
_invoker.invoke(GopeedMethods.createTaskBatch, request);
Future<void> patchTask(String id, PatchTask request) async {
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform;
import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -48,10 +49,10 @@ class _BatchActionAvailability {
_BatchActionAvailability _batchActionAvailability(Iterable<TaskRecord> tasks, TaskBatchSelectionController selection) {
var canPause = false;
var canResume = false;
for (final task in tasks) {
for (final task in tasks.expand((task) => [task, ...task.members])) {
if (!selection.contains(task.id)) continue;
canPause = canPause || task.status == TaskStatus.downloading;
canResume = canResume || task.status == TaskStatus.paused || task.status == TaskStatus.failed;
canPause = canPause || task.canPause;
canResume = canResume || task.canResume;
if (canPause && canResume) break;
}
return _BatchActionAvailability(canPause: canPause, canResume: canResume);
@@ -69,6 +70,7 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter _activeFilter = _TaskFilter.downloading;
TaskRecord? _selectedTask;
bool _batchMode = false;
final Set<String> _expandedGroupIds = {};
final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController();
@override
@@ -96,11 +98,16 @@ class _HomePageState extends ConsumerState<HomePage> {
final tasksState = tasksAsync.value;
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
final filteredTasks = _filteredTasks(tasks);
final visibleTasks = _visibleTasks(filteredTasks);
final memberIds = {
for (final task in tasks)
for (final member in task.members) member.id,
};
final emptyStateMessage = _searchController.text.trim().isEmpty
? context.l10n.emptyTaskList
: context.l10n.noMatchingTasks;
final selectedTask = _latestSelectedTask(tasks);
final visibleTaskIds = filteredTasks.map((task) => task.id).toList(growable: false);
final selectedTask = _latestSelectedTask();
final visibleTaskIds = visibleTasks.map((task) => task.id).toList(growable: false);
final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider);
final navigationItems = _taskNavigationItems(tasks);
final desktopBody = shad.Scaffold(
@@ -158,7 +165,7 @@ class _HomePageState extends ConsumerState<HomePage> {
listenable: _batchSelection,
builder: (context, _) => _TaskBatchRow(
selectedCount: _batchSelection.count,
totalCount: filteredTasks.length,
totalCount: visibleTasks.length,
allSelected: _batchSelection.areAllSelected(visibleTaskIds),
someSelected:
_batchSelection.isNotEmpty &&
@@ -179,16 +186,19 @@ class _HomePageState extends ConsumerState<HomePage> {
)
: ListView.separated(
padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredTasks.length,
itemCount: visibleTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = filteredTasks[index];
final task = visibleTasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: _batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: _expandedGroupIds.contains(task.id),
selected: _selectedTask?.id == task.id,
batchMode: _batchMode,
selectedInBatch: selectedInBatch,
@@ -242,6 +252,8 @@ class _HomePageState extends ConsumerState<HomePage> {
final mobileBody = tasksAsync.when(
loading: () => _MobileTasksView(
tasks: const [],
memberIds: const {},
expandedGroupIds: _expandedGroupIds,
batchMode: _batchMode,
batchSelection: _batchSelection,
loading: true,
@@ -262,7 +274,9 @@ class _HomePageState extends ConsumerState<HomePage> {
),
error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()),
data: (_) => _MobileTasksView(
tasks: filteredTasks,
tasks: visibleTasks,
memberIds: memberIds,
expandedGroupIds: _expandedGroupIds,
emptyStateMessage: emptyStateMessage,
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
batchMode: _batchMode,
@@ -331,20 +345,20 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter.completed => task.status == TaskStatus.completed,
_TaskFilter.failed => task.status == TaskStatus.failed,
};
final passesSearch =
query.isEmpty || task.name.toLowerCase().contains(query) || task.url.toLowerCase().contains(query);
final passesSearch = task.matchesSearch(query);
return passesFilter && passesSearch;
})
.toList(growable: false);
}
TaskRecord? _latestSelectedTask(List<TaskRecord> tasks) {
List<TaskRecord> _visibleTasks(List<TaskRecord> tasks) => [
for (final task in tasks) ...[task, if (_expandedGroupIds.contains(task.id)) ...task.members],
];
TaskRecord? _latestSelectedTask() {
final selectedTask = _selectedTask;
if (selectedTask == null) return null;
for (final task in tasks) {
if (task.id == selectedTask.id) return task;
}
return selectedTask;
return ref.read(tasksControllerProvider).value?.findTask(selectedTask.id);
}
List<ResponsiveNavigationItem<_TaskFilter>> _taskNavigationItems(List<TaskRecord> tasks) {
@@ -376,7 +390,13 @@ class _HomePageState extends ConsumerState<HomePage> {
if (_batchMode) {
_toggleBatchTask(task.id);
} else {
_showTaskDetails(task);
if (task.isGroup) {
setState(() {
if (!_expandedGroupIds.remove(task.id)) _expandedGroupIds.add(task.id);
});
} else {
_showTaskDetails(task);
}
}
}
@@ -406,8 +426,8 @@ class _HomePageState extends ConsumerState<HomePage> {
void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId);
void _toggleSelectAll() {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((task) => task.id).toList(growable: false);
if (_batchSelection.areAllSelected(visibleIds)) {
_batchSelection.clear();
@@ -430,22 +450,20 @@ class _HomePageState extends ConsumerState<HomePage> {
void _pauseSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.status == TaskStatus.downloading) : null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canPause) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).pauseAll(ids)));
}
void _resumeSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode
? _selectedTaskIdsFor((task) => task.status == TaskStatus.paused || task.status == TaskStatus.failed)
: null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canResume) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).resumeAll(ids)));
}
List<String> _selectedTaskIdsFor(bool Function(TaskRecord task) test) {
final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
final tasks = ref.read(tasksControllerProvider).value?.allTasks ?? const <TaskRecord>[];
return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList();
}
@@ -479,8 +497,8 @@ class _HomePageState extends ConsumerState<HomePage> {
}
List<String> _contextTaskIds(TaskRecord task) {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((item) => item.id).toSet();
return <String>{..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false);
}
@@ -502,9 +520,27 @@ class _HomePageState extends ConsumerState<HomePage> {
if (ids.isEmpty) return;
final runtime = ref.read(appRuntimeControllerProvider).value;
final config = runtime?.downloaderConfig;
final selected =
ref.read(tasksControllerProvider).value?.allTasks.where((task) => ids.contains(task.id)).toList() ??
<TaskRecord>[];
final groupedMemberIds = {
for (final task in selected)
for (final member in task.members) member.id,
};
ids = ids.where((id) => !groupedMemberIds.contains(id)).toList();
selected.removeWhere((task) => groupedMemberIds.contains(task.id));
final hasGroups = selected.any((task) => task.isGroup);
final keepFiles = await showTaskDeleteDialog(
context,
taskCount: ids.length,
memberCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.isGroup ? task.members.length : 1)),
// A task always downloads at least one file; records whose resource
// has no file list (plain single-file downloads) still count as one.
fileCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.files.isEmpty ? 1 : task.files.length)),
keepFiles: config?.extra.lastDeleteTaskKeep ?? false,
);
if (keepFiles == null || !mounted) return;
@@ -518,10 +554,11 @@ class _HomePageState extends ConsumerState<HomePage> {
await ref.read(appRuntimeControllerProvider.notifier).reloadConfig();
});
if (!mounted) return;
_batchSelection.removeAll(ids);
_batchSelection.removeAll({...ids, ...groupedMemberIds});
setState(() {
_batchMode = false;
if (_selectedTask != null && ids.contains(_selectedTask!.id)) {
_expandedGroupIds.removeAll(ids);
if (_selectedTask != null && (ids.contains(_selectedTask!.id) || groupedMemberIds.contains(_selectedTask!.id))) {
_selectedTask = null;
}
});
@@ -569,14 +606,8 @@ class _HomePageState extends ConsumerState<HomePage> {
Future<void> _updateTaskUrl(String id, String url, {Map<String, String> headers = const {}}) async {
try {
await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers);
final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
for (final task in updatedTasks) {
if (task.id == id) {
if (mounted) {
setState(() => _selectedTask = task);
}
break;
}
if (mounted) {
setState(() => _selectedTask = ref.read(tasksControllerProvider).value?.findTask(id));
}
} catch (error) {
if (!mounted) rethrow;
@@ -586,6 +617,16 @@ class _HomePageState extends ConsumerState<HomePage> {
}
Future<void> _openTask(TaskRecord task) async {
final nativeDirectory =
!Util.isWeb() &&
switch (defaultTargetPlatform) {
TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
_ => false,
};
if (task.isGroup && !nativeDirectory) {
await browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile);
return;
}
final opened = task.isFolder
? await FileExplorer.reveal(task.storagePath)
: await FileExplorer.open(task.storagePath);
@@ -674,6 +715,8 @@ class _TaskBatchRow extends StatelessWidget {
class _MobileTasksView extends StatelessWidget {
const _MobileTasksView({
required this.tasks,
required this.memberIds,
required this.expandedGroupIds,
required this.batchMode,
required this.batchSelection,
required this.onToggleSelectAll,
@@ -695,6 +738,8 @@ class _MobileTasksView extends StatelessWidget {
});
final List<TaskRecord> tasks;
final Set<String> memberIds;
final Set<String> expandedGroupIds;
final bool batchMode;
final TaskBatchSelectionController batchSelection;
final VoidCallback onToggleSelectAll;
@@ -760,11 +805,14 @@ class _MobileTasksView extends StatelessWidget {
itemBuilder: (context, index) {
final task = tasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: expandedGroupIds.contains(task.id),
selected: false,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
@@ -0,0 +1,166 @@
import 'package:path/path.dart' as path;
import '../../../api/model/create_task_batch.dart';
import '../../../api/model/options.dart';
import '../../../api/model/request.dart';
import '../../../api/model/resource.dart';
import '../../../api/model/resolve_result.dart';
import '../../../api/model/resolve_task.dart';
import '../../../core/capabilities/gopeed_capability.dart';
/// IDs identify sources within one preview and are never sent as group IDs.
class PendingDownload {
PendingDownload({required this.id, required this.request, required this.options});
final String id;
final Request request;
final Options options;
ResolveResult? result;
String? resolveError;
String? createError;
final Set<int> selected = {};
bool created = false;
bool resolving = false;
bool get loading => result == null && resolveError == null;
bool get isGroup => result != null && result!.id.isEmpty && result!.res.name.trim().isNotEmpty && files.length > 1;
bool get isFtp => RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(request.url.trim());
bool get isBt {
if (result?.id.isEmpty == true) return false;
final url = request.url.trim().toLowerCase();
if (url.startsWith('http:') || url.startsWith('https:') || isFtp) return false;
return url.startsWith('magnet:') ||
url.startsWith('data:application/x-bittorrent;base64,') ||
url.split(RegExp(r'[?#]')).first.endsWith('.torrent');
}
String get name {
if (!isBt &&
(!isFtp || result?.res.name.trim().isEmpty == true) &&
result?.id.isNotEmpty == true &&
files.length == 1 &&
files.single.name.isNotEmpty) {
return files.single.name;
}
if (result?.res.name.trim().isNotEmpty == true) return result!.res.name;
return request.url;
}
List<FileInfo> get files => result?.res.files ?? const [];
bool get hasSelection => !created && result != null && selected.isNotEmpty;
Future<void> resolve(GopeedService service) async {
if (result != null || resolving) return;
resolving = true;
resolveError = null;
try {
result = await service.resolve(ResolveTask(req: request));
selected.addAll(List.generate(files.isEmpty ? 1 : files.length, (index) => index));
} catch (error) {
resolveError = error.toString();
} finally {
resolving = false;
}
}
List<CreateTaskBatchItem> creationItems() {
final indexes = selected.toList()..sort();
if (result!.id.isNotEmpty || files.isEmpty) {
final opts = Options.fromJson(options.toJson());
if (files.isNotEmpty) opts.selectFiles = indexes;
if (indexes.length > 1) _omitChecksum(opts);
return [
CreateTaskBatchItem(
rid: result!.id.isEmpty ? null : result!.id,
req: result!.id.isEmpty ? request : null,
opts: opts,
),
];
}
final group = isGroup;
final resourceName = result!.res.name;
final parentSegments = [
if (!group && options.path.isNotEmpty) options.path,
if (!group && resourceName.trim().isNotEmpty) resourceName,
];
final skipAutoCategorize = options.skipAutoCategorize || resourceName.isNotEmpty || files.length > 1;
final httpUrl = RegExp(r'^https?:', caseSensitive: false);
return indexes.map((index) {
final file = files[index];
final member = Options.fromJson(options.toJson());
member.name = files.length == 1 && options.name.isNotEmpty ? options.name : file.name;
final segments = [...parentSegments, if (file.path.isNotEmpty) file.path];
member.path = segments.isEmpty ? '' : path.joinAll(segments);
// The selected parent directory is persisted separately from members.
member.asDefaultPath = false;
member.skipAutoCategorize = skipAutoCategorize;
final req = file.req ?? request;
member.size = httpUrl.hasMatch(req.url) ? file.size : 0;
if (RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(req.url)) {
final extra = OptsExtraFtp.fromJson(Map<String, dynamic>.from(member.extra as Map? ?? const {}));
extra.connections = extra.connections.clamp(0, 32);
member.extra = extra.toJson();
}
if (indexes.length > 1) _omitChecksum(member);
return CreateTaskBatchItem(req: req, opts: member);
}).toList();
}
void _omitChecksum(Options opts) {
if (opts.extra is Map) opts.extra = Map<String, dynamic>.from(opts.extra! as Map)..remove('checksum');
}
}
/// A successful response completes the entire batch, including partial success.
class DownloadSubmission {
DownloadSubmission({required this.sources, required this.request});
final List<PendingDownload> sources;
final CreateTaskBatch request;
Future<void> submit(GopeedService service) async {
for (final source in sources) {
source.createError = null;
}
try {
await service.createTaskBatch(request);
for (final source in sources) {
source.created = true;
}
} catch (error) {
for (final source in sources) {
source.createError = error.toString();
}
}
}
}
List<DownloadSubmission> planDownloads(List<PendingDownload> sources) {
final groups = <DownloadSubmission>[];
final independent = <PendingDownload>[];
for (final source in sources.where((source) => source.hasSelection)) {
if (source.isGroup) {
groups.add(
DownloadSubmission(
sources: [source],
request: CreateTaskBatch(
group: true,
groupName: source.options.name.isNotEmpty ? source.options.name : source.result!.res.name.trim(),
opts: source.options,
reqs: source.creationItems(),
),
),
);
} else {
independent.add(source);
}
}
return [
...groups,
if (independent.isNotEmpty)
DownloadSubmission(
sources: independent,
request: CreateTaskBatch(reqs: [for (final source in independent) ...source.creationItems()]),
),
];
}
@@ -23,6 +23,10 @@ class TasksState {
final int uploadSpeedBytesPerSecond;
final Object? lastError;
Iterable<TaskRecord> get allTasks => tasks.expand((task) => [task, ...task.members]);
TaskRecord? findTask(String id) => allTasks.where((task) => task.id == id).firstOrNull;
TasksState copyWith({
List<TaskRecord>? tasks,
int? downloadSpeedBytesPerSecond,
@@ -122,13 +126,14 @@ class TasksController extends AsyncNotifier<TasksState> {
Future<TasksState> _fetch() async {
final tasks = await ref.read(gopeedServiceProvider).getTasks(api_model.Status.values);
final transferSpeeds = aggregateTaskTransferSpeeds(tasks);
tasks.sort((a, b) {
if (a.status == api_model.Status.running && b.status != api_model.Status.running) return -1;
if (a.status != api_model.Status.running && b.status == api_model.Status.running) return 1;
return b.updatedAt.compareTo(a.updatedAt);
});
return TasksState(
tasks: tasks.map(TaskRecord.fromApi).toList(growable: false),
tasks: taskRecordsFromApi(tasks)
..sort((a, b) {
final aRunning = a.status == TaskStatus.downloading && !a.waiting;
final bRunning = b.status == TaskStatus.downloading && !b.waiting;
if (aRunning != bRunning) return aRunning ? -1 : 1;
return (b.updatedAt ?? b.createdAt!).compareTo(a.updatedAt ?? a.createdAt!);
}),
downloadSpeedBytesPerSecond: transferSpeeds.downloadBytesPerSecond,
uploadSpeedBytesPerSecond: transferSpeeds.uploadBytesPerSecond,
);
@@ -42,6 +42,10 @@ class TaskFileNode {
required this.sizeBytes,
this.downloadedBytes,
this.resourceIndex,
this.status,
this.taskId,
this.accessPath,
this.localPath,
});
final int? resourceIndex;
@@ -49,6 +53,10 @@ class TaskFileNode {
final String name;
final int sizeBytes;
final int? downloadedBytes;
final TaskStatus? status;
final String? taskId;
final String? accessPath;
final String? localPath;
double? get progress {
final downloaded = downloadedBytes;
@@ -68,6 +76,8 @@ class TaskRecord {
required this.files,
required this.uploading,
this.isFolder = false,
this.isGroup = false,
this.members = const [],
this.protocol,
this.requestHeaders = const {},
this.total,
@@ -84,6 +94,8 @@ class TaskRecord {
this.totalBytes,
this.speedBytes,
this.uploadedBytes,
this.uploadSpeedBytes,
this.updatedAt,
this.remainingSeconds,
this.waiting = false,
}) : assert(!uploading || uploadSpeed != null);
@@ -108,16 +120,31 @@ class TaskRecord {
final int? totalBytes;
final int? speedBytes;
final int? uploadedBytes;
final int? uploadSpeedBytes;
final DateTime? updatedAt;
final int? remainingSeconds;
final bool waiting;
final List<TaskFileNode> files;
final bool uploading;
final bool isFolder;
final bool isGroup;
final List<TaskRecord> members;
final api_task.Protocol? protocol;
final Map<String, String> requestHeaders;
bool get canUpdateUrl =>
protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
!isGroup && protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
bool get canPause => isGroup ? members.any((task) => task.canPause) : status == TaskStatus.downloading;
bool get canResume =>
isGroup ? members.any((task) => task.canResume) : status == TaskStatus.paused || status == TaskStatus.failed;
bool matchesSearch(String query) =>
query.isEmpty ||
name.toLowerCase().contains(query) ||
url.toLowerCase().contains(query) ||
files.any((file) => '${file.path}/${file.name}'.toLowerCase().contains(query)) ||
members.any((member) => member.matchesSearch(query));
bool get isIndeterminate => status == TaskStatus.downloading && !waiting && total == null;
@@ -147,6 +174,7 @@ class TaskRecord {
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
doneAt: task.doneAt,
waiting: task.status == api_task.Status.wait,
progress: progress,
@@ -156,6 +184,7 @@ class TaskRecord {
totalBytes: totalBytes > 0 ? totalBytes : null,
speedBytes: task.progress.speed,
uploadedBytes: task.progress.uploaded,
uploadSpeedBytes: task.progress.uploadSpeed,
files: _fileNodes(task.meta.res?.files, task.meta.opts.selectFiles),
uploading: task.uploading,
isFolder: task.meta.res?.name.isNotEmpty ?? false,
@@ -165,6 +194,186 @@ class TaskRecord {
}
}
List<TaskRecord> taskRecordsFromApi(List<api_task.Task> tasks) {
final members = <String, List<TaskRecord>>{};
final metadata = <String, api_task.TaskGroup>{};
final records = <String, TaskRecord>{};
for (final task in tasks) {
final record = TaskRecord.fromApi(task);
records[task.id] = record;
if (task.parentId.isNotEmpty) {
members.putIfAbsent(task.parentId, () => []).add(record);
if (task.group != null) metadata[task.parentId] = task.group!;
}
}
final emitted = <String>{};
return [
for (final task in tasks)
if (task.parentId.isEmpty || !metadata.containsKey(task.parentId))
records[task.id]!
else if (emitted.add(task.parentId))
_groupRecord(metadata[task.parentId]!, members[task.parentId]!),
];
}
TaskRecord _groupRecord(api_task.TaskGroup group, List<TaskRecord> members) {
final running = members.any((task) => task.status == TaskStatus.downloading && !task.waiting);
final failed = members.any((task) => task.status == TaskStatus.failed);
final paused = members.isEmpty || members.any((task) => task.status == TaskStatus.paused);
final completed = members.isNotEmpty && members.every((task) => task.status == TaskStatus.completed);
final status = running
? TaskStatus.downloading
: failed
? TaskStatus.failed
: paused
? TaskStatus.paused
: completed
? TaskStatus.completed
: TaskStatus.downloading;
final downloaded = members.fold(0, (total, task) => total + (task.downloadedBytes ?? 0));
final total = members.every((task) => task.totalBytes != null || task.status == TaskStatus.completed)
? members.fold(0, (total, task) => total + (task.totalBytes ?? 0))
: null;
final speed = members.fold(
0,
(total, task) => total + (task.status == TaskStatus.downloading && !task.waiting ? task.speedBytes ?? 0 : 0),
);
// The backend freezes a finished single task's speed at bytes over active
// time (markTaskDone); a finished group reports the same ratio across its
// members instead of collapsing to "0 B/s" when the last member completes.
final reportedSpeed = speed > 0 ? speed : (completed ? _averageSpeed(members) ?? 0 : 0);
final uploadSpeed = members.fold(0, (total, task) => total + (task.uploading ? task.uploadSpeedBytes ?? 0 : 0));
final uploading = members.any((task) => task.uploading);
final uploaded = members.fold(0, (total, task) => total + (task.uploadedBytes ?? 0));
final pathContext = path.Context(
style: group.path.contains('\\') || members.any((task) => task.storagePath.contains('\\'))
? path.Style.windows
: path.Style.posix,
);
final root = group.path;
final files = <TaskFileNode>[];
for (final member in members) {
final sourceFiles = member.isFolder
? member.files
: [TaskFileNode(path: '', name: member.name, sizeBytes: member.totalBytes ?? 0)];
// Folder members do not carry per-file progress from the backend; spread
// the member's overall ratio over its files so the tree keeps moving
// instead of showing flat zeros.
final folderTotal = member.isFolder ? member.files.fold<int>(0, (total, file) => total + file.sizeBytes) : 0;
final folderRatio = folderTotal > 0 && member.downloadedBytes != null
? (member.downloadedBytes! / folderTotal).clamp(0.0, 1.0)
: null;
for (final file in sourceFiles) {
final fullPath = member.isFolder
? pathContext.join(member.storagePath, file.path, file.name)
: member.storagePath;
final relative = pathContext.relative(fullPath, from: root).replaceAll('\\', '/');
final String nodePath;
final String nodeName;
if (pathContext.isAbsolute(relative) || relative == '..' || relative.startsWith('../')) {
// Keep unexpected stored paths within the displayed tree.
if (member.isFolder) {
nodePath = path.posix.join(pathContext.basename(member.storagePath), file.path.replaceAll('\\', '/'));
nodeName = file.name;
} else {
nodePath = '';
nodeName = member.name;
}
} else {
nodePath = path.posix.dirname(relative) == '.' ? '' : path.posix.dirname(relative);
nodeName = path.posix.basename(relative);
}
files.add(
TaskFileNode(
resourceIndex: files.length,
path: nodePath,
name: nodeName,
sizeBytes: file.sizeBytes,
downloadedBytes: member.isFolder
? folderRatio != null && file.sizeBytes > 0
? (file.sizeBytes * folderRatio).round()
: null
: member.downloadedBytes,
status: member.status,
taskId: member.id,
localPath: fullPath,
accessPath: member.isFolder
? path.posix.join(file.path.replaceAll('\\', '/'), file.name)
: pathContext.relative(fullPath, from: pathContext.dirname(member.storagePath)).replaceAll('\\', '/'),
),
);
}
}
var updatedAt = group.createdAt;
DateTime? doneAt;
for (final member in members) {
final update = member.updatedAt;
if (update != null && update.isAfter(updatedAt)) updatedAt = update;
if (completed) {
final done = member.doneAt;
if (done != null && (doneAt == null || done.isAfter(doneAt))) doneAt = done;
}
}
// The folder card answers "how long did this download take end to end", so
// a completed group reports its wall-clock span; single tasks keep their
// active-time semantics.
final createdAt = group.createdAt;
final downloadDuration = doneAt != null && doneAt.isAfter(createdAt) ? doneAt.difference(createdAt) : null;
return TaskRecord(
id: group.id,
name: group.name,
status: status,
downloaded: _formatBytes(downloaded),
total: total == null ? null : _formatBytes(total),
downloadedBytes: downloaded,
totalBytes: total,
speed: TransferRateFormatter.format(reportedSpeed).text,
speedBytes: reportedSpeed,
uploadSpeed: uploading ? TransferRateFormatter.format(uploadSpeed).text : null,
uploadedBytes: uploaded,
uploadSpeedBytes: uploadSpeed,
uploading: uploading,
progress: completed
? 1
: total != null && total > 0
? (downloaded / total).clamp(0.0, 1.0)
: null,
remainingSeconds: total != null && speed > 0 && total > downloaded ? ((total - downloaded) / speed).ceil() : null,
waiting: status == TaskStatus.downloading && !running,
downloadDuration: downloadDuration,
url: '',
storagePath: root,
files: files,
isFolder: true,
isGroup: true,
members: members,
createdAt: group.createdAt,
updatedAt: updatedAt,
doneAt: doneAt,
);
}
// The combined average rate of a set of members: downloaded bytes over active
// download time, the same ratio the backend freezes into a finished single
// task's progress. Members without a measurable duration are skipped because
// they would inflate the rate.
int? _averageSpeed(List<TaskRecord> members) {
var downloaded = 0;
var microseconds = 0;
for (final member in members) {
final duration = member.downloadDuration;
if (duration == null || duration.inMicroseconds <= 0) {
continue;
}
downloaded += member.downloadedBytes ?? 0;
microseconds += duration.inMicroseconds;
}
if (microseconds <= 0) {
return null;
}
return (downloaded * Duration.microsecondsPerSecond / microseconds).round();
}
Map<String, String> _requestHeaders(Object? extra) {
if (extra is! Map) return const {};
final header = extra['header'];
@@ -19,8 +19,6 @@ import '../../../../api/model/create_task_batch.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/options.dart' as api_options;
import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart';
import '../../../../api/model/resolve_task.dart';
import '../../../../core/capabilities/app_capabilities.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/utils/text_wrap.dart';
@@ -39,10 +37,11 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../l10n/l10n.dart';
import '../../../../util/util.dart';
import '../../application/pending_create_task.dart';
import '../../application/pending_download.dart';
import '../../application/task_list_navigation.dart';
import '../widgets/history_search_field.dart';
import '../widgets/create_task_action_buttons.dart';
import '../widgets/resolve_file_tree.dart';
import '../widgets/resolve_download_dialog.dart';
class CreateTaskWindowPage extends ConsumerStatefulWidget {
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
@@ -92,6 +91,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
bool _directDownload = false;
int _protocolTab = 0;
bool _creating = false;
final Map<String, int> _completedSubmissionCounts = {};
int _createSession = 0;
bool _draggingTorrent = false;
bool _asDefaultPath = false;
String _configuredDownloadDirectory = '';
@@ -248,6 +249,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (task == null || !mounted) return;
final req = task.req;
final opts = task.opts ?? api_options.Options();
_createSession++;
_completedSubmissionCounts.clear();
setState(() {
_initialSize = 0;
_checksumAlgorithm = 'md5';
@@ -871,7 +874,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
await windowManager.close();
}
Future<void> _finishCreation() async {
Future<void> _finishCreation(int session) async {
if (!mounted || session != _createSession) return;
if (_asDefaultPath && _directoryManuallySet && _directoryController.text.trim().isNotEmpty) {
try {
final service = ref.read(gopeedServiceProvider);
final config = await service.getConfig();
if (!mounted || session != _createSession) return;
config.downloadDir = _directoryController.text.trim();
await service.putConfig(config);
} catch (error) {
if (mounted && session == _createSession) _showToast(error.toString());
}
}
if (!mounted || session != _createSession) return;
if (widget.windowController != null) {
// Navigation is best-effort after a successful submission. A navigation
// failure must not leave a completed form available for resubmission.
@@ -883,6 +899,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} else {
ref.read(taskListNavigationProvider.notifier).showDownloading();
}
if (!mounted || session != _createSession) return;
await _closeWindow();
}
@@ -989,53 +1006,74 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_showToast(context.l10n.downloadLinkValid);
return;
}
if (!_validateAdvancedOptions()) {
final occurrences = <String, int>{};
final pendingUrls = <String>[];
for (final url in urls) {
final occurrence = (occurrences[url] ?? 0) + 1;
occurrences[url] = occurrence;
if (occurrence > (_completedSubmissionCounts[url] ?? 0)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isNotEmpty && !_validateAdvancedOptions()) {
return;
}
final session = _createSession;
setState(() {
_creating = true;
_draggingTorrent = false;
});
try {
await _saveCreateHistory(urls);
final isDirect = _directDownload || urls.length > 1;
if (isDirect) {
await Future.wait(
urls.map((url) {
return ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
opts: _buildOptions(
url: _protocolUrlFor(url),
name: urls.length > 1 ? '' : _renameController.text.trim(),
),
),
);
}),
unawaited(
_saveCreateHistory(urls).catchError((Object error) {
if (mounted && session == _createSession) _showToast(error.toString());
}),
);
if (pendingUrls.isEmpty) {
await _finishCreation(session);
return;
}
final sources = [
for (final entry in pendingUrls.indexed)
PendingDownload(
id: 'source-${entry.$1}',
request: _buildRequest(entry.$2, protocolUrl: _protocolUrlFor(entry.$2)),
options: _buildOptions(
url: _protocolUrlFor(entry.$2),
name: urls.length == 1 ? _renameController.text.trim() : '',
omitChecksum: urls.length > 1,
),
),
];
final service = ref.read(gopeedServiceProvider);
if (_directDownload) {
await service.createTaskBatch(
CreateTaskBatch(
reqs: [for (final source in sources) CreateTaskBatchItem(req: source.request, opts: source.options)],
),
);
await _finishCreation();
await _finishCreation(session);
return;
}
final request = _buildRequest(urls.first, protocolUrl: _protocolUrlFor(urls.first));
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request));
if (!mounted) return;
final created = await _showResolveDialog(request, result);
if (!created) {
if (mounted) {
setState(() => _creating = false);
}
return;
}
await _finishCreation();
final created = await showResolveDownloadsDialog(
context,
sources: sources,
service: service,
onBatchCreated: (sources) {
if (!mounted || session != _createSession) return;
for (final source in sources) {
_completedSubmissionCounts.update(source.request.url, (count) => count + 1, ifAbsent: () => 1);
}
},
);
if (!mounted || session != _createSession || !created) return;
await _finishCreation(session);
} catch (error) {
if (mounted) {
_showToast(error.toString());
setState(() => _creating = false);
}
if (mounted && session == _createSession) _showToast(error.toString());
} finally {
if (mounted) setState(() => _creating = false);
}
}
@@ -1298,190 +1336,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_applyCategoryHighlight();
}
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) {
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
rid: result.id,
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
),
);
return;
}
if (result.res.files.isNotEmpty) {
// The extension returned a file list, so the resource keeps a directory
// structure. The split tasks must not be classified into a category, the
// structure of the resolved resource is kept. The decision is based on
// the resolved resource, not on the number of selected files.
final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1;
final reqs = selected.map((index) {
final file = result.res.files[index];
final fileRequest = file.req ?? request;
return CreateTaskBatchItem(
req: fileRequest,
opts: api_options.Options(
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
skipAutoCategorize: keepStructure,
extra: _buildOptions(url: fileRequest.url, omitChecksum: isMultiFile).extra,
),
);
}).toList();
await ref
.read(gopeedServiceProvider)
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
}
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: request,
opts: _buildOptions(url: request.url),
),
);
}
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
final initialSelection = List<int>.generate(result.res.files.length, (index) => index);
var selectedIndexes = List<int>.of(initialSelection);
var submitting = false;
String? errorText;
// The directory the task will be saved to. A directory the user named
// wins over the category suggestion of the resolve response, the backend
// keeps it and does not classify the task.
final effectiveDownloadDir = _directoryManuallySet
? _directoryController.text.trim()
: (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim());
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: !submitting,
builder: (dialogContext) {
final palette = AppPalette.of(dialogContext);
final screenSize = MediaQuery.sizeOf(dialogContext);
final dialogWidth = screenSize.width < 760 ? screenSize.width - 32 : 720.0;
final treeHeight = screenSize.height < 700 ? screenSize.height * 0.55 : 440.0;
return StatefulBuilder(
builder: (dialogContext, setDialogState) {
Future<void> submit() async {
if (result.res.files.isNotEmpty && selectedIndexes.isEmpty) {
setDialogState(() => errorText = dialogContext.l10n.selectAtLeastOneFile);
return;
}
setDialogState(() {
submitting = true;
errorText = null;
});
try {
await _submitResolved(request, result, selectedIndexes);
if (dialogContext.mounted) {
await closeOverlay(dialogContext, true);
}
} catch (error) {
if (!dialogContext.mounted) return;
setDialogState(() {
submitting = false;
errorText = error.toString();
});
}
}
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Text(
result.res.name.trim().isEmpty ? dialogContext.l10n.resolvedFiles : result.res.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
content: SizedBox(
width: dialogWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.res.files.isEmpty
? dialogContext.l10n.resourceHasNoFiles
: dialogContext.l10n.resolvedFileCount(result.res.files.length),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
),
if (effectiveDownloadDir.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir),
key: const ValueKey('resolve-category-directory'),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
height: treeHeight.clamp(260.0, 460.0),
child: result.res.files.isEmpty
? Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: palette.cardBg,
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Text(
dialogContext.l10n.noFiles,
style: TextStyle(color: palette.textMuted, fontSize: 13),
),
)
: ResolveFileTree(
files: result.res.files,
initialSelection: initialSelection,
onSelectionChanged: (values) => selectedIndexes = values,
),
),
if (errorText != null) ...[
const SizedBox(height: 10),
Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)),
],
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: submitting,
onCancel: () => closeOverlay(dialogContext, false),
onSubmit: submit,
cancelLabel: dialogContext.l10n.cancel,
submitLabel: dialogContext.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
},
);
},
);
final created = await dialog.future;
return created == true;
}
Future<void> _showCreateHistory() async {
List<String> histories;
try {
@@ -21,13 +21,8 @@ class TaskDetailsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return AppDetailPage(
@@ -43,12 +38,12 @@ class TaskDetailsPage extends ConsumerWidget {
key: ValueKey(task.id),
task: task,
mobile: true,
onOpenStorage: () => FileExplorer.reveal(task!.storagePath),
onOpenStorage: () => FileExplorer.reveal(task.storagePath),
onUpdateUrl: (url) async {
try {
await ref
.read(tasksControllerProvider.notifier)
.updateUrl(task!.id, url, headers: task.requestHeaders);
.updateUrl(task.id, url, headers: task.requestHeaders);
} catch (error) {
if (context.mounted) {
showAppToast(context, error.toString(), type: AppToastType.error);
@@ -20,13 +20,8 @@ class TaskFilesPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return shad.Scaffold(
@@ -18,7 +18,7 @@ class CreateTaskActionButtons extends StatelessWidget {
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final VoidCallback? onSubmit;
final String cancelLabel;
final String submitLabel;
final Key? cancelButtonKey;
@@ -0,0 +1,177 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/widgets.dart' as flutter show Flexible;
import '../../../../core/capabilities/gopeed_capability.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import 'create_task_action_buttons.dart';
import 'resolve_file_tree.dart';
Future<bool> showResolveDownloadsDialog(
BuildContext context, {
required List<PendingDownload> sources,
required GopeedService service,
ValueChanged<List<PendingDownload>>? onBatchCreated,
}) async {
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (_) => _ResolveDownloadsDialog(sources: sources, service: service, onBatchCreated: onBatchCreated),
);
return await dialog.future == true;
}
class _ResolveDownloadsDialog extends StatefulWidget {
const _ResolveDownloadsDialog({required this.sources, required this.service, required this.onBatchCreated});
final List<PendingDownload> sources;
final GopeedService service;
final ValueChanged<List<PendingDownload>>? onBatchCreated;
@override
State<_ResolveDownloadsDialog> createState() => _ResolveDownloadsDialogState();
}
class _ResolveDownloadsDialogState extends State<_ResolveDownloadsDialog> {
bool _cancelled = false;
bool _submitting = false;
String? _errorText;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAll());
}
@override
void dispose() {
_cancelled = true;
super.dispose();
}
Future<void> _resolveAll() async {
for (final source in widget.sources) {
if (_cancelled || !mounted) return;
if (source.result != null || source.resolveError != null) continue;
final resolving = source.resolve(widget.service);
setState(() {});
await resolving;
if (_cancelled || !mounted) return;
setState(() {});
}
}
void _cancel() {
_cancelled = true;
closeOverlay(context, false);
}
Future<void> _submit() async {
if (_submitting || widget.sources.any((source) => source.loading)) return;
final batches = planDownloads(widget.sources);
if (batches.isEmpty) return;
setState(() {
_submitting = true;
_errorText = null;
for (final source in widget.sources) {
source.createError = null;
}
});
for (final batch in batches) {
await batch.submit(widget.service);
if (!mounted) return;
if (batch.sources.every((source) => source.created)) {
widget.onBatchCreated?.call(batch.sources);
}
}
final errors = widget.sources.map((source) => source.createError).whereType<String>().toSet();
if (errors.isEmpty) {
await closeOverlay(context, true);
} else {
setState(() {
_submitting = false;
_errorText = errors.join('\n');
});
}
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final canCreate =
!_submitting &&
!widget.sources.any((source) => source.loading) &&
widget.sources.any((source) => source.hasSelection);
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Row(
children: [
Expanded(child: Text(context.l10n.resolvedFiles)),
AppTooltip(
message: context.l10n.close,
child: GhostButton(
key: const ValueKey('resolve-close-button'),
onPressed: _submitting ? null : _cancel,
child: const Icon(Icons.close, size: 18),
),
),
],
),
content: SizedBox(
width: screen.width < 760 ? screen.width - 68 : 720,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: (screen.height * 0.48).clamp(120.0, 440.0),
child: IgnorePointer(
ignoring: _submitting,
child: ResolveFileTree(
sources: List.of(widget.sources),
onSelectionChanged: (selection) {
final selectedBySource = <String, List<int>>{};
for (final (sourceId, index) in selection) {
selectedBySource.putIfAbsent(sourceId, () => []).add(index);
}
setState(() {
for (final source in widget.sources) {
if (source.result == null || source.created) continue;
source.selected
..clear()
..addAll(selectedBySource[source.id] ?? const []);
}
});
},
),
),
),
if (_errorText != null)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: screen.height * 0.15),
child: SingleChildScrollView(
child: Text(_errorText!, style: TextStyle(color: AppPalette.of(context).error, fontSize: 12)),
),
),
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: _submitting,
onCancel: _cancel,
onSubmit: canCreate ? _submit : null,
cancelLabel: context.l10n.cancel,
submitLabel: context.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
}
}
@@ -2,99 +2,150 @@ import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
import '../../../../api/model/resource.dart';
import '../../../../core/icons/gopeed_icons.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/file_tree_view.dart';
import '../../../../util/util.dart';
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({
super.key,
required this.files,
required this.initialSelection,
required this.onSelectionChanged,
});
typedef PendingFileKey = (String, int);
final List<FileInfo> files;
final List<int> initialSelection;
final ValueChanged<List<int>> onSelectionChanged;
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({super.key, required this.sources, required this.onSelectionChanged});
final List<PendingDownload> sources;
final ValueChanged<Set<PendingFileKey>> onSelectionChanged;
@override
State<ResolveFileTree> createState() => _ResolveFileTreeState();
}
class _ResolveFileTreeState extends State<ResolveFileTree> {
late List<FileTreeItem<int>> _treeItems;
late List<int> _allFileIndexes;
late final Set<int> _selectedIndexes;
late List<FileTreeItem<PendingFileKey>> _treeItems;
late Map<PendingFileKey, FileInfo> _files;
late Map<String, String> _labels;
late Map<String, PendingDownload> _sourcesById;
late Map<String, PendingDownload> _sourcesByRootKey;
final Set<PendingFileKey> _selected = {};
final Set<_FileTypeFilter> _activeTypeFilters = {};
@override
void initState() {
super.initState();
_selectedIndexes = widget.initialSelection.where(_isValidIndex).toSet();
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
}
@override
void didUpdateWidget(covariant ResolveFileTree oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(widget.files, oldWidget.files)) return;
_selectedIndexes.removeWhere((index) => !_isValidIndex(index));
_activeTypeFilters.clear();
final previousFiles = _files;
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
if (previousFiles.length != _files.length || !previousFiles.keys.every(_files.containsKey)) {
_activeTypeFilters.clear();
}
}
void _rebuildTreeItems() {
_files = {};
_labels = {};
_treeItems = [];
_sourcesById = {for (final source in widget.sources) source.id: source};
_sourcesByRootKey = {for (final source in widget.sources) 'folder:${source.id}': source};
_selected.clear();
for (final source in widget.sources) {
if (source.result == null) {
_labels[source.id] = source.name;
_treeItems.add(
FileTreeItem(
key: source.id,
path: source.id,
name: source.name,
size: 0,
data: (source.id, -1),
directoryOnly: true,
),
);
continue;
}
final files = source.files.isEmpty ? [FileInfo(name: source.name)] : source.files;
// A non-empty resource name is a real directory, even for one file.
final hasRoot = source.result!.res.name.trim().isNotEmpty;
if (hasRoot) _labels[source.id] = source.name;
for (final entry in files.indexed) {
final key = (source.id, entry.$1);
final file = entry.$2;
if (!source.created) {
_files[key] = file;
if (source.selected.contains(entry.$1)) _selected.add(key);
}
_treeItems.add(
FileTreeItem<PendingFileKey>(
key: '${source.id}:${entry.$1}',
path: hasRoot ? '${source.id}/${file.path}' : file.path,
namespace: hasRoot ? '' : source.id,
name: file.name,
size: file.size,
data: key,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final selectedCount = _selectedIndexes.length;
final allSelected = _allFileIndexes.isNotEmpty && selectedCount == _allFileIndexes.length;
final partiallySelected = selectedCount > 0 && !allSelected;
final selectedSize = _selectedIndexes.fold<int>(0, (sum, index) => sum + widget.files[index].size);
final allSelected = _files.isNotEmpty && _selected.length == _files.length;
final partial = _selected.isNotEmpty && !allSelected;
return Column(
children: [
Expanded(
child: FileTreeView<int>(
child: FileTreeView<PendingFileKey>(
items: _treeItems,
initialExpandedDepth: 1,
directoryLabels: _labels,
initialExpandedDepth: 2,
keyPrefix: 'resolve-tree',
iconWidgetBuilder: _sourceIcon,
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
headerLeading: ExcludeFocus(
child: Checkbox(
state: allSelected
? CheckboxState.checked
: (partiallySelected ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: _allFileIndexes.isEmpty ? null : (_) => _setFileSelection(_allFileIndexes, !allSelected),
: partial
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: _files.isEmpty ? null : (_) => _setSelection(_files.keys, !allSelected),
),
),
leadingBuilder: (context, node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final nodeSelectedCount = indexes.where(_selectedIndexes.contains).length;
final selected = indexes.isNotEmpty && nodeSelectedCount == indexes.length;
final partial = nodeSelectedCount > 0 && !selected;
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
final count = keys.where(_selected.contains).length;
return ExcludeFocus(
child: Checkbox(
state: selected
state: keys.isNotEmpty && count == keys.length
? CheckboxState.checked
: (partial ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: (_) => _setFileSelection(indexes, !selected),
: count > 0
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: keys.isEmpty ? null : (_) => _setSelection(keys, count != keys.length),
),
);
},
onNodePressed: _toggleNodeSelection,
onNodePressed: (node) {
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
if (keys.isNotEmpty) _setSelection(keys, !keys.every(_selected.contains));
},
),
),
const SizedBox(height: 12),
_TreeFooter(
selectedCount: selectedCount,
totalCount: _allFileIndexes.length,
selectedSize: selectedSize,
unknownSize: selectedCount > 0 && selectedSize == 0,
selectedCount: _selected.length,
totalCount: _files.length,
selectedSize: _selected.fold(0, (sum, key) => sum + _files[key]!.size),
unknownSize: _selected.any((key) => _files[key]!.size <= 0),
activeTypeFilters: _activeTypeFilters,
onSelectVideo: () => _toggleTypeFilter(_FileTypeFilter.video),
onSelectAudio: () => _toggleTypeFilter(_FileTypeFilter.audio),
@@ -104,65 +155,63 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
);
}
bool _isValidIndex(int index) => index >= 0 && index < widget.files.length;
void _rebuildTreeItems() {
_allFileIndexes = List<int>.generate(widget.files.length, (index) => index, growable: false);
_treeItems = widget.files
.asMap()
.entries
.map(
(entry) => FileTreeItem<int>(
key: entry.key.toString(),
path: entry.value.path,
name: entry.value.name,
size: entry.value.size,
data: entry.key,
),
)
.toList(growable: false);
Widget _sourceIcon(BuildContext context, FileTreeNode<PendingFileKey> node) {
final source = node.item == null ? _sourcesByRootKey[node.key] : _sourcesById[node.item!.data.$1];
final palette = AppPalette.of(context);
if (source == null) {
return Icon(node.isFolder ? BootstrapIcons.folder2 : GopeedIcons.file, size: 18, color: palette.textSecondary);
}
final pending = source;
final message = pending.loading
? (pending.resolving ? context.l10n.resolvingResource : context.l10n.waitingToResolve)
: pending.resolveError ?? pending.request.url;
return AppTooltip(
focusable: true,
message: message,
child: Semantics(
label: message,
child: pending.loading
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator())
: Icon(
pending.resolveError != null
? Icons.error_outline
: node.isFolder && pending.isBt
? GopeedIcons.folderBt
: node.isFolder
? BootstrapIcons.folder2
: GopeedIcons.file,
size: 18,
color: pending.resolveError != null ? palette.error : palette.textSecondary,
),
),
);
}
void _setFileSelection(Iterable<int> indexes, bool selected) {
void _setSelection(Iterable<PendingFileKey> keys, bool selected) {
setState(() {
_activeTypeFilters.clear();
if (selected) {
_selectedIndexes.addAll(indexes);
_selected.addAll(keys);
} else {
_selectedIndexes.removeAll(indexes);
_selected.removeAll(keys);
}
});
_notifySelection();
}
void _toggleNodeSelection(FileTreeNode<int> node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final selected = indexes.isNotEmpty && indexes.every(_selectedIndexes.contains);
_setFileSelection(indexes, !selected);
widget.onSelectionChanged(Set.of(_selected));
}
void _toggleTypeFilter(_FileTypeFilter filter) {
setState(() {
if (!_activeTypeFilters.remove(filter)) {
_activeTypeFilters.add(filter);
}
final extensions = <String>{for (final activeFilter in _activeTypeFilters) ...activeFilter.extensions};
_selectedIndexes
if (!_activeTypeFilters.remove(filter)) _activeTypeFilters.add(filter);
final extensions = {for (final active in _activeTypeFilters) ...active.extensions};
_selected
..clear()
..addAll(
widget.files
.asMap()
.entries
_files.entries
.where((entry) => extensions.contains(_extensionOf(entry.value.name)))
.map((entry) => entry.key),
);
});
_notifySelection();
}
void _notifySelection() {
final values = _selectedIndexes.toList()..sort();
widget.onSelectionChanged(values);
widget.onSelectionChanged(Set.of(_selected));
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import '../../../../../core/utils/breakpoints.dart';
import '../../../../../shared/theme/app_design_tokens.dart';
import '../../../domain/task_record.dart';
import '../task_context_menu.dart';
import 'task_card_desktop.dart';
@@ -10,6 +11,8 @@ class TaskCard extends StatelessWidget {
const TaskCard({
super.key,
required this.task,
this.expanded = false,
this.nested = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -25,6 +28,8 @@ class TaskCard extends StatelessWidget {
});
final TaskRecord task;
final bool expanded;
final bool nested;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -42,40 +47,44 @@ class TaskCard extends StatelessWidget {
Widget build(BuildContext context) {
final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile;
if (isMobile) {
final card = TaskCardMobile(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
}
final card = TaskCardDesktop(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final card = isMobile
? TaskCardMobile(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
)
: TaskCardDesktop(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
return Padding(
padding: EdgeInsetsDirectional.only(start: nested ? AppDesignTokens.space24 : 0),
child: Semantics(
expanded: task.isGroup ? expanded : null,
child: actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card),
),
);
}
}
@@ -14,6 +14,7 @@ class TaskCardDesktop extends StatefulWidget {
const TaskCardDesktop({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -28,6 +29,7 @@ class TaskCardDesktop extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -64,7 +66,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
_lastTapTime = isDoubleTap ? null : now;
_lastTapPosition = isDoubleTap ? null : details.globalPosition;
widget.onPressed();
if (isDoubleTap) {
if (isDoubleTap && !widget.task.isGroup) {
widget.onOpen?.call();
}
}
@@ -134,7 +136,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
@@ -234,15 +236,21 @@ class _ProgressContent extends StatelessWidget {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, onPause),
if (task.canResume) (Icons.play_arrow, onResume),
(Icons.close, onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
return actions
.map(
@@ -13,6 +13,7 @@ class TaskCardMobile extends StatefulWidget {
const TaskCardMobile({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -27,6 +28,7 @@ class TaskCardMobile extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -62,7 +64,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
onDoubleTap: !widget.batchMode && task.status == TaskStatus.completed ? widget.onOpen : null,
onDoubleTap: !widget.batchMode && !task.isGroup && task.status == TaskStatus.completed ? widget.onOpen : null,
child: Container(
key: const ValueKey('task-card-mobile-surface'),
decoration: BoxDecoration(
@@ -117,7 +119,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -168,15 +170,21 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, widget.onPause),
if (task.canResume) (Icons.play_arrow, widget.onResume),
(Icons.close, widget.onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
return actions
.map(
@@ -46,25 +46,35 @@ class TaskUpdateListeningIndicator extends StatelessWidget {
}
class TaskCardIcon extends StatelessWidget {
const TaskCardIcon({super.key, required this.task});
const TaskCardIcon({super.key, required this.task, this.expanded = false});
final TaskRecord task;
final bool expanded;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (task.isGroup) ...[
Icon(expanded ? Icons.expand_more : Icons.chevron_right, size: 18, color: palette.textSecondary),
const SizedBox(width: AppDesignTokens.space4),
],
SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
),
),
),
),
],
);
}
}
@@ -69,14 +69,14 @@ class TaskContextMenu extends StatelessWidget {
shad.MenuButton(
key: const ValueKey('task-context-resume'),
leading: const Icon(Icons.play_arrow, size: 16),
enabled: task.status == TaskStatus.paused || task.status == TaskStatus.failed,
enabled: task.canResume,
onPressed: (_) => actions.onResume(),
child: Text(context.l10n.continueAction),
),
shad.MenuButton(
key: const ValueKey('task-context-pause'),
leading: const Icon(Icons.pause, size: 16),
enabled: task.status == TaskStatus.downloading,
enabled: task.canPause,
onPressed: (_) => actions.onPause(),
child: Text(context.l10n.pause),
),
@@ -5,7 +5,13 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../l10n/l10n.dart';
Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async {
Future<bool?> showTaskDeleteDialog(
BuildContext context, {
required int taskCount,
required bool keepFiles,
int? memberCount,
int? fileCount,
}) async {
var currentKeepFiles = keepFiles;
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
@@ -32,34 +38,46 @@ Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount
title: Text(dialogContext.l10n.deleteTasksTitle(taskCount)),
content: SizedBox(
width: contentWidth,
child: AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (memberCount != null) ...[
Text(dialogContext.l10n.taskGroupDeleteImpact(memberCount, fileCount ?? 0)),
const SizedBox(height: AppDesignTokens.space16),
],
),
AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(
color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
],
),
),
],
),
),
actions: [
@@ -121,7 +121,9 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
final contentPadding = widget.mobile
? AppDesignTokens.taskDetailsMobilePadding
: AppDesignTokens.taskDetailsDesktopPadding;
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
final runtimeStatus = _tabIndex == 1 && !widget.task.isGroup
? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value
: null;
final content = Column(
children: [
Container(
@@ -148,12 +150,14 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
active: _tabIndex == 1,
onTap: () => setState(() => _tabIndex = 1),
),
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
if (!widget.task.isGroup) ...[
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
],
],
),
),
@@ -340,7 +344,7 @@ class _TaskInfoTab extends StatelessWidget {
onCancel: onCancelEditUrl,
onSave: onSaveUrl,
)
else
else if (!task.isGroup)
_CopyableInfoBlock(
label: context.l10n.downloadLink,
value: task.url,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -7,6 +8,7 @@ import '../../../../l10n/l10n.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../util/util.dart';
import '../../domain/task_record.dart';
import '../../application/tasks_controller.dart';
import 'task_file_manager.dart';
Future<void> browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async {
@@ -49,7 +51,16 @@ Future<void> showTaskFileBrowserDialog(BuildContext context, TaskRecord task) as
content: SizedBox(
width: dialogWidth,
height: dialogHeight,
child: TaskFileManagerView(task: task),
child: Consumer(
builder: (context, ref, _) {
final state = ref.watch(tasksControllerProvider).value;
final current = state?.findTask(task.id);
if (state != null && current == null) {
return Center(child: Text(context.l10n.taskNotFound));
}
return TaskFileManagerView(task: current ?? task);
},
),
),
);
},
@@ -120,14 +120,19 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
}
String _localPath(_TaskFileEntry entry) {
if (entry.localPath != null) return entry.localPath!;
if (!widget.task.isFolder && _index.fileCount == 1) return widget.task.storagePath;
final segments = entry.fullPath.split('/').where((part) => part.isNotEmpty);
return path.joinAll([widget.task.storagePath, ...segments]);
}
String _accessUrl(_TaskFileEntry entry) {
final encodedPath = entry.fullPath.split('/').where((part) => part.isNotEmpty).map(Uri.encodeComponent).join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(widget.task.id)}/$encodedPath');
final encodedPath = (entry.source?.accessPath ?? entry.fullPath)
.split('/')
.where((part) => part.isNotEmpty)
.map(Uri.encodeComponent)
.join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(entry.source?.taskId ?? widget.task.id)}/$encodedPath');
}
}
@@ -304,7 +309,7 @@ class _FileActionButton extends StatelessWidget {
class _TaskFileDirectoryIndex {
_TaskFileDirectoryIndex(TaskRecord task) {
final sourceFiles = task.files.isEmpty
final sourceFiles = task.files.isEmpty && !task.isGroup
? [TaskFileNode(path: '/', name: task.name, sizeBytes: task.totalBytes ?? 0)]
: task.files;
fileCount = sourceFiles.length;
@@ -313,8 +318,18 @@ class _TaskFileDirectoryIndex {
final directory = _normalizeDirectory(file.path);
final name = singleFile ? path.basename(task.storagePath) : file.name;
final fullPath = directory == '/' ? '/$name' : '$directory/$name';
_add(directory, _TaskFileEntry(directory: false, fullPath: fullPath, name: name, size: file.sizeBytes));
_addParentDirectories(directory);
_add(
directory,
_TaskFileEntry(
directory: false,
fullPath: fullPath,
name: name,
size: file.sizeBytes,
source: file,
localPath: file.localPath,
),
);
_addParentDirectories(directory, file.localPath == null ? null : path.dirname(file.localPath!));
}
for (final entries in _entries.values) {
entries.sort((left, right) {
@@ -331,15 +346,19 @@ class _TaskFileDirectoryIndex {
List<_TaskFileEntry> entries(String directory) => _entries[directory] ?? const [];
void _addParentDirectories(String directory) {
void _addParentDirectories(String directory, String? localPath) {
var current = directory;
while (current != '/') {
final parent = path.posix.dirname(current);
final normalizedParent = parent == '.' ? '/' : parent;
final name = path.posix.basename(current);
_add(normalizedParent, _TaskFileEntry(directory: true, fullPath: current, name: name, size: 0));
_add(
normalizedParent,
_TaskFileEntry(directory: true, fullPath: current, name: name, size: 0, localPath: localPath),
);
_entries.putIfAbsent(current, () => []);
current = normalizedParent;
if (localPath != null) localPath = path.dirname(localPath);
}
}
@@ -350,12 +369,21 @@ class _TaskFileDirectoryIndex {
}
class _TaskFileEntry {
const _TaskFileEntry({required this.directory, required this.fullPath, required this.name, required this.size});
const _TaskFileEntry({
required this.directory,
required this.fullPath,
required this.name,
required this.size,
this.source,
this.localPath,
});
final bool directory;
final String fullPath;
final String name;
final int size;
final TaskFileNode? source;
final String? localPath;
}
String _normalizeDirectory(String value) {
@@ -42,6 +42,13 @@ class _TaskFileTreeState extends State<TaskFileTree> {
Widget build(BuildContext context) {
final progressByIndex = {
for (final file in widget.runtimeStatus?.files ?? const <FileRuntimeStatus>[]) file.index: file,
if (widget.task.isGroup)
for (final (index, file) in widget.task.files.indexed)
file.resourceIndex ?? index: FileRuntimeStatus(
index: file.resourceIndex ?? index,
size: file.sizeBytes,
downloaded: file.downloadedBytes ?? 0,
),
};
return Column(
@@ -179,7 +186,17 @@ class _FileDownloadProgress extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final taskStateIcon = switch (taskStatus) {
final statuses = node.leafItems.map((item) => item.data.status).whereType<TaskStatus>().toSet();
final status = statuses.isEmpty
? taskStatus
: statuses.contains(TaskStatus.downloading)
? TaskStatus.downloading
: statuses.contains(TaskStatus.failed)
? TaskStatus.failed
: statuses.contains(TaskStatus.paused)
? TaskStatus.paused
: TaskStatus.completed;
final taskStateIcon = switch (status) {
TaskStatus.paused => Semantics(
label: context.l10n.pause,
child: Icon(Icons.pause_circle_filled, size: 16, color: palette.textMuted),
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "بيانات الملف المُتعذّب فارغة",
"torrentPathUnavailable": "غير قادر على الوصول إلى طريق الملف المُتعذّب",
"selectAtLeastOneFile": "اختار ملف واحد على الأقل",
"resolvedFiles": "الملفات المبتغاة",
"resolvedFiles": "قائمة الملفات",
"waitingToResolve": "بانتظار التحليل",
"resolvingResource": "جارٍ تحليل المورد",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "سيتم الحفظ في {path}",
"resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "حالة التحديث",
"trackerSubscriptionTrackerCount": "عدد المتتبعات",
"trackerSubscriptionNotUpdated": "لم يُحدّث بعد",
"actions": "الإجراءات"
"actions": "الإجراءات",
"taskGroupDeleteImpact": "سيؤثر على {taskCount} من مهام التنزيل و{fileCount} من الملفات."
}
+5 -2
View File
@@ -386,7 +386,9 @@
"torrentDataEmpty": "Torrent fayl məlumatı boşdur",
"torrentPathUnavailable": "Torrent fayl yoluna giriş mümkün olmadı",
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
"resolvedFiles": "Tapılmış Fayllar",
"resolvedFiles": "Fayl siyahısı",
"waitingToResolve": "Təhlil gözlənilir",
"resolvingResource": "Resurs təhlil edilir",
"resolvedFileCount": "{count} fayl tapıldı",
"resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq",
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
@@ -661,5 +663,6 @@
"trackerSubscriptionUpdateStatus": "Yeniləmə statusu",
"trackerSubscriptionTrackerCount": "Tracker sayı",
"trackerSubscriptionNotUpdated": "Hələ yenilənməyib",
"actions": "Əməliyyatlar"
"actions": "Əməliyyatlar",
"taskGroupDeleteImpact": "{taskCount} endirməyə və {fileCount} fayla təsir edir."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "El fitxer torrent de dades és buit",
"torrentPathUnavailable": "No s' ha pogut accedir al camí del fitxer torrent",
"selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer",
"resolvedFiles": "Fitxers resolts",
"resolvedFiles": "Llista de fitxers",
"waitingToResolve": "Esperant l’anàlisi",
"resolvingResource": "S’està analitzant el recurs",
"resolvedFileCount": "{count}",
"resolvedCategoryDirectory": "Es desarà a {path}",
"resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estat de l’actualització",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Sense actualitzar",
"actions": "Accions"
"actions": "Accions",
"taskGroupDeleteImpact": "Afecta {taskCount} baixades i {fileCount} fitxers."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent-Dateidaten sind leer",
"torrentPathUnavailable": "Auf den Torrent-Dateipfad kann nicht zugegriffen werden",
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
"resolvedFiles": "Aufgelöste Dateien",
"resolvedFiles": "Dateiliste",
"waitingToResolve": "Warten auf Auflösung",
"resolvingResource": "Ressource wird aufgelöst",
"resolvedFileCount": "{count}-Dateien behoben",
"resolvedCategoryDirectory": "Wird in {path} gespeichert",
"resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Aktualisierungsstatus",
"trackerSubscriptionTrackerCount": "Tracker-Anzahl",
"trackerSubscriptionNotUpdated": "Noch nicht aktualisiert",
"actions": "Aktionen"
"actions": "Aktionen",
"taskGroupDeleteImpact": "Betrifft {taskCount} Downloads und {fileCount} Dateien."
}
+15 -2
View File
@@ -406,7 +406,9 @@
"torrentDataEmpty": "Torrent file data is empty",
"torrentPathUnavailable": "Unable to access the torrent file path",
"selectAtLeastOneFile": "Select at least one file",
"resolvedFiles": "Resolved Files",
"resolvedFiles": "File List",
"waitingToResolve": "Waiting to resolve",
"resolvingResource": "Resolving resource",
"resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}",
"resolvedCategoryDirectory": "Will be saved to {path}",
"@resolvedCategoryDirectory": {
@@ -704,5 +706,16 @@
"trackerSubscriptionUpdateStatus": "Update status",
"trackerSubscriptionTrackerCount": "Tracker count",
"trackerSubscriptionNotUpdated": "Not updated",
"actions": "Actions"
"actions": "Actions",
"taskGroupDeleteImpact": "Affects {taskCount} downloads and {fileCount} files.",
"@taskGroupDeleteImpact": {
"placeholders": {
"taskCount": {
"type": "int"
},
"fileCount": {
"type": "int"
}
}
}
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Los datos de archivos Torrent están vacíos",
"torrentPathUnavailable": "Incapaz de acceder a la ruta del archivo torrent",
"selectAtLeastOneFile": "Seleccione al menos un archivo",
"resolvedFiles": "Archivos resueltos",
"resolvedFiles": "Lista de archivos",
"waitingToResolve": "Esperando resolución",
"resolvingResource": "Resolviendo recurso",
"resolvedFileCount": "{count} resueltos",
"resolvedCategoryDirectory": "Se guardará en {path}",
"resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estado de actualización",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Sin actualizar",
"actions": "Acciones"
"actions": "Acciones",
"taskGroupDeleteImpact": "Afecta a {taskCount} descargas y {fileCount} archivos."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "داده های فایل تورنت خالی است",
"torrentPathUnavailable": "امکان دسترسی به مسیر فایل تورنت",
"selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید",
"resolvedFiles": "فایل های حل شده",
"resolvedFiles": "فهرست فایل‌ها",
"waitingToResolve": "در انتظار پردازش",
"resolvingResource": "در حال پردازش منبع",
"resolvedFileCount": "{count} فایل شناسایی شد",
"resolvedCategoryDirectory": "در {path} ذخیره می‌شود",
"resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "وضعیت به‌روزرسانی",
"trackerSubscriptionTrackerCount": "تعداد ردیاب‌ها",
"trackerSubscriptionNotUpdated": "هنوز به‌روزرسانی نشده",
"actions": "عملیات"
"actions": "عملیات",
"taskGroupDeleteImpact": "بر {taskCount} بارگیری و {fileCount} پرونده تأثیر می‌گذارد."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Les données du fichier Torrent sont vides",
"torrentPathUnavailable": "Impossible d'accéder au chemin de fichier torrent",
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
"resolvedFiles": "Fichiers résolus",
"resolvedFiles": "Liste des fichiers",
"waitingToResolve": "En attente d’analyse",
"resolvingResource": "Analyse de la ressource",
"resolvedFileCount": "{count} résolus",
"resolvedCategoryDirectory": "Sera enregistré dans {path}",
"resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "État de mise à jour",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Pas encore mis à jour",
"actions": "Actions"
"actions": "Actions",
"taskGroupDeleteImpact": "Concerne {taskCount} téléchargements et {fileCount} fichiers."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "A torrent fájl adatai üresek",
"torrentPathUnavailable": "Nem érhető el a torrent fájl elérési útja",
"selectAtLeastOneFile": "Válasszon ki legalább egy fájlt",
"resolvedFiles": "Feloldott fájlok",
"resolvedFiles": "Fájllista",
"waitingToResolve": "Feldolgozásra vár",
"resolvingResource": "Erőforrás feldolgozása",
"resolvedFileCount": "{count} fájlok megoldva",
"resolvedCategoryDirectory": "Ide lesz mentve: {path}",
"resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Frissítés állapota",
"trackerSubscriptionTrackerCount": "Trackerek száma",
"trackerSubscriptionNotUpdated": "Még nem frissült",
"actions": "Műveletek"
"actions": "Műveletek",
"taskGroupDeleteImpact": "{taskCount} letöltést és {fileCount} fájlt érint."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Data berkas Torrent kosong",
"torrentPathUnavailable": "Tak bisa mengakses path berkas torrent",
"selectAtLeastOneFile": "Pilih paling tidak satu berkas",
"resolvedFiles": "Berkas Terpecahkan",
"resolvedFiles": "Daftar berkas",
"waitingToResolve": "Menunggu pemrosesan",
"resolvingResource": "Memproses sumber daya",
"resolvedFileCount": "{count} terselesaikan",
"resolvedCategoryDirectory": "Akan disimpan ke {path}",
"resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Status pembaruan",
"trackerSubscriptionTrackerCount": "Jumlah tracker",
"trackerSubscriptionNotUpdated": "Belum diperbarui",
"actions": "Tindakan"
"actions": "Tindakan",
"taskGroupDeleteImpact": "Memengaruhi {taskCount} unduhan dan {fileCount} berkas."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "I dati dei file Torrent sono vuoti",
"torrentPathUnavailable": "Non è possibile accedere al percorso file torrent",
"selectAtLeastOneFile": "Selezionare almeno un file",
"resolvedFiles": "File recuperati",
"resolvedFiles": "Elenco dei file",
"waitingToResolve": "In attesa di analisi",
"resolvingResource": "Analisi della risorsa",
"resolvedFileCount": "{count} risolti",
"resolvedCategoryDirectory": "Verrà salvato in {path}",
"resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Stato aggiornamento",
"trackerSubscriptionTrackerCount": "Numero di tracker",
"trackerSubscriptionNotUpdated": "Non ancora aggiornato",
"actions": "Azioni"
"actions": "Azioni",
"taskGroupDeleteImpact": "Riguarda {taskCount} download e {fileCount} file."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "トレントファイルデータが空です",
"torrentPathUnavailable": "トレントファイルパスにアクセスできない",
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
"resolvedFiles": "解決されたファイル",
"resolvedFiles": "ファイル一覧",
"waitingToResolve": "解析待ち",
"resolvingResource": "リソースを解析中",
"resolvedFileCount": "{count} ファイルが解決しました",
"resolvedCategoryDirectory": "{path} に保存されます",
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新状況",
"trackerSubscriptionTrackerCount": "Tracker 数",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "{taskCount} 件のダウンロードと {fileCount} 個のファイルが対象です。"
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent 파일 데이터가 비어 있습니다",
"torrentPathUnavailable": "토렌트 파일 경로에 액세스 할 수 없습니다",
"selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오",
"resolvedFiles": "해결된 파일",
"resolvedFiles": "파일 목록",
"waitingToResolve": "분석 대기 중",
"resolvingResource": "리소스 분석 중",
"resolvedFileCount": "{count} 파일 해결",
"resolvedCategoryDirectory": "{path}에 저장됩니다",
"resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "업데이트 상태",
"trackerSubscriptionTrackerCount": "트래커 수",
"trackerSubscriptionNotUpdated": "아직 업데이트되지 않음",
"actions": "작업"
"actions": "작업",
"taskGroupDeleteImpact": "다운로드 {taskCount}개와 파일 {fileCount}개에 영향을 줍니다."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Dane pliku torrenta są puste",
"torrentPathUnavailable": "Nie można uzyskać dostępu do ścieżki pliku torrenta",
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
"resolvedFiles": "Rozwiązywane pliki",
"resolvedFiles": "Lista plików",
"waitingToResolve": "Oczekiwanie na analizę",
"resolvingResource": "Analizowanie zasobu",
"resolvedFileCount": "{count} plików rozwiązanych",
"resolvedCategoryDirectory": "Zostanie zapisane w {path}",
"resourceHasNoFiles": "Zasób nie ma listy plików do wyboru",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Stan aktualizacji",
"trackerSubscriptionTrackerCount": "Liczba trackerów",
"trackerSubscriptionNotUpdated": "Jeszcze nie zaktualizowano",
"actions": "Działania"
"actions": "Działania",
"taskGroupDeleteImpact": "Dotyczy {taskCount} pobrań i {fileCount} plików."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Os dados do ficheiro Torrent estão em branco",
"torrentPathUnavailable": "Não foi possível aceder ao local do ficheiro torrent",
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
"resolvedFiles": "Ficheiros Resolvidos",
"resolvedFiles": "Lista de arquivos",
"waitingToResolve": "Aguardando resolução",
"resolvingResource": "Resolvendo recurso",
"resolvedFileCount": "{count} ficheiros resolvidos",
"resolvedCategoryDirectory": "Será salvo em {path}",
"resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Estado da atualização",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Ainda não atualizado",
"actions": "Ações"
"actions": "Ações",
"taskGroupDeleteImpact": "Afeta {taskCount} downloads e {fileCount} arquivos."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Данные торрент-файлов пусты",
"torrentPathUnavailable": "Невозможность доступа к торрент-файлу",
"selectAtLeastOneFile": "Выберите хотя бы один файл",
"resolvedFiles": "Решенные файлы",
"resolvedFiles": "Список файлов",
"waitingToResolve": "Ожидание анализа",
"resolvingResource": "Анализ ресурса",
"resolvedFileCount": "{count} файлов, разрешенных",
"resolvedCategoryDirectory": "Будет сохранено в {path}",
"resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Статус обновления",
"trackerSubscriptionTrackerCount": "Число трекеров",
"trackerSubscriptionNotUpdated": "Ещё не обновлено",
"actions": "Действия"
"actions": "Действия",
"taskGroupDeleteImpact": "Затронет {taskCount} загрузок и {fileCount} файлов."
}
+5 -2
View File
@@ -395,7 +395,9 @@
"torrentDataEmpty": "Údaje torrent súboru sú prázdne",
"torrentPathUnavailable": "Nie je možné pristúpiť k ceste torrent súboru",
"selectAtLeastOneFile": "Vyberte aspoň jeden súbor",
"resolvedFiles": "Vyriešené súbory",
"resolvedFiles": "Zoznam súborov",
"waitingToResolve": "Čaká na spracovanie",
"resolvingResource": "Spracovanie zdroja",
"resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}",
"resolvedCategoryDirectory": "Uloží sa do {path}",
"resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov",
@@ -678,5 +680,6 @@
"trackerSubscriptionUpdateStatus": "Stav aktualizácie",
"trackerSubscriptionTrackerCount": "Počet trackerov",
"trackerSubscriptionNotUpdated": "Zatiaľ neaktualizované",
"actions": "Akcie"
"actions": "Akcie",
"taskGroupDeleteImpact": "Ovplyvní {taskCount} sťahovaní a {fileCount} súborov."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "டொரண்ட் கோப்பு தரவு காலியாக உள்ளது",
"torrentPathUnavailable": "டொரண்ட் கோப்பு பாதையை அணுக முடியவில்லை",
"selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்",
"resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்",
"resolvedFiles": "கோப்புப் பட்டியல்",
"waitingToResolve": "பகுப்பாய்விற்காகக் காத்திருக்கிறது",
"resolvingResource": "வளத்தைப் பகுப்பாய்வு செய்கிறது",
"resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன",
"resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்",
"resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "புதுப்பிப்பு நிலை",
"trackerSubscriptionTrackerCount": "டிராக்கர் எண்ணிக்கை",
"trackerSubscriptionNotUpdated": "இன்னும் புதுப்பிக்கப்படவில்லை",
"actions": "செயல்கள்"
"actions": "செயல்கள்",
"taskGroupDeleteImpact": "{taskCount} பதிவிறக்கங்களையும் {fileCount} கோப்புகளையும் பாதிக்கும்."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Torrent dosya verileri boş",
"torrentPathUnavailable": "Hata dosya yoluna erişmek için kullanılamaz",
"selectAtLeastOneFile": "En az bir dosya seçin",
"resolvedFiles": "Çözülen Dosyalar",
"resolvedFiles": "Dosya listesi",
"waitingToResolve": "Çözümleme bekleniyor",
"resolvingResource": "Kaynak çözümleniyor",
"resolvedFileCount": "{count} dosyaları çözüldü",
"resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}",
"resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Güncelleme durumu",
"trackerSubscriptionTrackerCount": "Tracker sayısı",
"trackerSubscriptionNotUpdated": "Henüz güncellenmedi",
"actions": "İşlemler"
"actions": "İşlemler",
"taskGroupDeleteImpact": "{taskCount} indirmeyi ve {fileCount} dosyayı etkiler."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Порожня інформація про файли, що обертаються",
"torrentPathUnavailable": "Неможливо отримати доступ до торрент-файлу доріжка",
"selectAtLeastOneFile": "Виберіть принаймні один файл",
"resolvedFiles": "Знезалізовані файли",
"resolvedFiles": "Список файлів",
"waitingToResolve": "Очікування аналізу",
"resolvingResource": "Аналіз ресурсу",
"resolvedFileCount": "{count} файли вирішені",
"resolvedCategoryDirectory": "Буде збережено в {path}",
"resourceHasNoFiles": "Ресурс не має вибраного списку файлів",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Стан оновлення",
"trackerSubscriptionTrackerCount": "Кількість трекерів",
"trackerSubscriptionNotUpdated": "Ще не оновлено",
"actions": "Дії"
"actions": "Дії",
"taskGroupDeleteImpact": "Вплине на {taskCount} завантажень і {fileCount} файлів."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "Dữ liệu về tập tin đã rỗng",
"torrentPathUnavailable": "Không thể truy cập đường dẫn tập tin lũ lụt",
"selectAtLeastOneFile": "Chọn ít nhất một tập tin",
"resolvedFiles": "Các tập tin đã giải quyết",
"resolvedFiles": "Danh sách tệp",
"waitingToResolve": "Đang chờ phân tích",
"resolvingResource": "Đang phân tích tài nguyên",
"resolvedFileCount": "{count} tập tin được giải quyết",
"resolvedCategoryDirectory": "Sẽ được lưu vào {path}",
"resourceHasNoFiles": "Không thể chọn danh sách tập tin",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "Trạng thái cập nhật",
"trackerSubscriptionTrackerCount": "Số tracker",
"trackerSubscriptionNotUpdated": "Chưa cập nhật",
"actions": "Thao tác"
"actions": "Thao tác",
"taskGroupDeleteImpact": "Ảnh hưởng đến {taskCount} tác vụ tải và {fileCount} tệp."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "种子文件内容为空",
"torrentPathUnavailable": "无法访问种子文件路径",
"selectAtLeastOneFile": "请至少选择一个文件",
"resolvedFiles": "已解析文件",
"resolvedFiles": "文件列表",
"waitingToResolve": "等待解析",
"resolvingResource": "正在解析资源",
"resolvedFileCount": "已解析 {count} 个文件",
"resolvedCategoryDirectory": "将保存到 {path}",
"resourceHasNoFiles": "该资源没有可选择的文件列表",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新状态",
"trackerSubscriptionTrackerCount": "Tracker 数量",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "将影响 {taskCount} 个下载任务和 {fileCount} 个文件。"
}
Loaded 50 of 84 files, more files were not shown because too many files have changed in this diff. Show more