feat: improve queue management

This commit is contained in:
nimbold
2026-06-02 05:12:23 +03:30
parent ac039a50c8
commit fd6457ddc5
4 changed files with 174 additions and 33 deletions
+2 -2
View File
@@ -175,8 +175,8 @@ struct ContentView: View {
controller.downloads.filter { $0.status == .downloading }
case .completed:
controller.downloads.filter { $0.status == .completed }
case .failed:
controller.downloads.filter { $0.status == .failed }
case .unfinished:
controller.downloads.filter { $0.status != .completed }
case .category(let category):
controller.downloads.filter { $0.category == category }
}
+55 -7
View File
@@ -75,8 +75,8 @@ final class DownloadController: ObservableObject {
downloads.filter { $0.status == .completed }.count
}
var failedCount: Int {
downloads.filter { $0.status == .failed }.count
var unfinishedCount: Int {
downloads.filter { $0.status != .completed }.count
}
var hasAria2: Bool {
@@ -182,6 +182,30 @@ final class DownloadController: ObservableObject {
updateSleepActivity()
}
func assignToQueue(itemIDs: Set<UUID>, queueID: UUID) {
let queueID = normalizedQueueID(queueID)
var changed = false
for index in downloads.indices where itemIDs.contains(downloads[index].id) {
guard downloads[index].status != .completed,
downloads[index].status != .downloading else {
continue
}
downloads[index].status = .queued
downloads[index].queueID = queueID
downloads[index].message = "Added to \(queueName(for: queueID))"
downloads[index].autoResumeOnLaunch = false
automaticRetryCounts[downloads[index].id] = nil
changed = true
}
if changed {
saveDownloads()
updateSleepActivity()
}
}
func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) {
@@ -240,7 +264,7 @@ final class DownloadController: ObservableObject {
func queueItems(for id: UUID) -> [DownloadItem] {
let id = normalizedQueueID(id)
return downloads.filter { normalizedQueueID($0.queueID) == id }
return downloads.filter { validQueueID($0.queueID) == id }
}
func queueCount(for id: UUID) -> Int {
@@ -275,11 +299,25 @@ final class DownloadController: ObservableObject {
saveDownloads()
}
func removeQueue(id: UUID) {
guard id != DownloadQueue.mainQueueID,
queues.contains(where: { $0.id == id }) else {
return
}
for index in downloads.indices where validQueueID(downloads[index].queueID) == id {
downloads[index].queueID = nil
}
queues.removeAll { $0.id == id }
engineMessage = "Removed queue. Downloads remain in Unfinished."
saveDownloads()
}
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
let queueID = normalizedQueueID(queueID)
guard itemID != targetID,
let source = downloads.firstIndex(where: { $0.id == itemID && normalizedQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && normalizedQueueID($0.queueID) == queueID }) else {
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
return
}
@@ -506,20 +544,26 @@ final class DownloadController: ObservableObject {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL)
let state: StoredDownloadState
let isLegacyDownloadList: Bool
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
state = storedState
isLegacyDownloadList = false
} else {
state = StoredDownloadState(
queues: [.main],
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
)
isLegacyDownloadList = true
}
var shouldResumeRecoveredDownloads = false
self.queues = normalizedQueues(state.queues)
self.downloads = state.downloads.map { item in
var adjusted = item
adjusted.queueID = normalizedQueueID(adjusted.queueID)
adjusted.queueID = validQueueID(adjusted.queueID)
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.status == .downloading {
adjusted.status = .queued
adjusted.message = "Recovered after restart. Resuming from partial file."
@@ -546,8 +590,12 @@ final class DownloadController: ObservableObject {
}
private func normalizedQueueID(_ id: UUID?) -> UUID {
validQueueID(id) ?? DownloadQueue.mainQueueID
}
private func validQueueID(_ id: UUID?) -> UUID? {
guard let id, queues.contains(where: { $0.id == id }) else {
return DownloadQueue.mainQueueID
return nil
}
return id
}
+17 -5
View File
@@ -239,7 +239,7 @@ struct DownloadTable: View {
}
.onDrag {
draggedItemID = item.id
return NSItemProvider(object: item.id.uuidString as NSString)
return NSItemProvider(object: dragPayload(for: item) as NSString)
}
.onDrop(
of: [.text],
@@ -361,10 +361,15 @@ struct DownloadTable: View {
}
}
if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) {
Button {
for target in targetItems where target.status != .downloading && target.status != .queued {
controller.queue(target)
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.assignToQueue(
itemIDs: Set(targetItems.map(\.id)),
queueID: queue.id
)
}
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
@@ -482,6 +487,13 @@ struct DownloadTable: View {
return index + 1
}
private func dragPayload(for item: DownloadItem) -> String {
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
return draggedIDs
.map(\.uuidString)
.joined(separator: "\n")
}
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
if lhs < rhs { return .orderedAscending }
if lhs > rhs { return .orderedDescending }
+100 -19
View File
@@ -1,11 +1,12 @@
import SwiftUI
import UniformTypeIdentifiers
enum DownloadSidebarFilter: Hashable {
case all
case queued
case active
case completed
case failed
case unfinished
case category(DownloadCategory)
var title: String {
@@ -14,7 +15,7 @@ enum DownloadSidebarFilter: Hashable {
case .queued: "Queue"
case .active: "Active"
case .completed: "Completed"
case .failed: "Failed"
case .unfinished: "Unfinished"
case .category(let category): category.rawValue
}
}
@@ -30,6 +31,7 @@ struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@State private var queueName = ""
var body: some View {
@@ -44,32 +46,20 @@ struct SidebarView: View {
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(SidebarSelection.downloads(.completed))
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
.tag(SidebarSelection.downloads(.failed))
Label("Unfinished", systemImage: "circle.dashed")
.badge(controller.unfinishedCount)
.tag(SidebarSelection.downloads(.unfinished))
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
folderRow(for: category)
}
}
Section("Queues") {
ForEach(controller.queues) { queue in
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
}
}
queueRow(for: queue)
}
Button {
@@ -101,6 +91,31 @@ struct SidebarView: View {
queueBeingRenamed = nil
}
}
.confirmationDialog(
"Delete Queue",
isPresented: Binding(
get: { queueBeingRemoved != nil },
set: { isPresented in
if !isPresented {
queueBeingRemoved = nil
}
}
),
presenting: queueBeingRemoved
) { queue in
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
}
queueBeingRemoved = nil
}
Button("Cancel", role: .cancel) {
queueBeingRemoved = nil
}
} message: { queue in
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Divider()
@@ -126,4 +141,70 @@ struct SidebarView: View {
.background(.bar)
}
}
private func folderRow(for category: DownloadCategory) -> some View {
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(SidebarSelection.downloads(.category(category)))
}
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
of: [.text],
delegate: QueueSidebarDropDelegate(
queueID: queue.id,
selection: $selection,
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
private struct QueueSidebarDropDelegate: DropDelegate {
let queueID: UUID
@Binding var selection: SidebarSelection
let controller: DownloadController
func performDrop(info: DropInfo) -> Bool {
guard let provider = info.itemProviders(for: [.text]).first else {
return false
}
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
guard let itemIDs = Self.itemIDs(from: item), !itemIDs.isEmpty else { return }
Task { @MainActor in
controller.assignToQueue(itemIDs: itemIDs, queueID: queueID)
selection = .queue(queueID)
}
}
return true
}
nonisolated private static func itemIDs(from item: NSSecureCoding?) -> Set<UUID>? {
let text: String?
if let data = item as? Data {
text = String(data: data, encoding: .utf8)
} else {
text = item as? String
}
guard let text else { return nil }
return Set(text
.split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == " " })
.compactMap { UUID(uuidString: String($0)) })
}
}