mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat: improve queue management
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)) })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user