mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
feat: improve queue management
This commit is contained in:
@@ -175,8 +175,8 @@ struct ContentView: View {
|
|||||||
controller.downloads.filter { $0.status == .downloading }
|
controller.downloads.filter { $0.status == .downloading }
|
||||||
case .completed:
|
case .completed:
|
||||||
controller.downloads.filter { $0.status == .completed }
|
controller.downloads.filter { $0.status == .completed }
|
||||||
case .failed:
|
case .unfinished:
|
||||||
controller.downloads.filter { $0.status == .failed }
|
controller.downloads.filter { $0.status != .completed }
|
||||||
case .category(let category):
|
case .category(let category):
|
||||||
controller.downloads.filter { $0.category == category }
|
controller.downloads.filter { $0.category == category }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ final class DownloadController: ObservableObject {
|
|||||||
downloads.filter { $0.status == .completed }.count
|
downloads.filter { $0.status == .completed }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var failedCount: Int {
|
var unfinishedCount: Int {
|
||||||
downloads.filter { $0.status == .failed }.count
|
downloads.filter { $0.status != .completed }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasAria2: Bool {
|
var hasAria2: Bool {
|
||||||
@@ -182,6 +182,30 @@ final class DownloadController: ObservableObject {
|
|||||||
updateSleepActivity()
|
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) {
|
func resume(_ item: DownloadItem) {
|
||||||
restrictQueueToAutoResume = false
|
restrictQueueToAutoResume = false
|
||||||
update(item.id) {
|
update(item.id) {
|
||||||
@@ -240,7 +264,7 @@ final class DownloadController: ObservableObject {
|
|||||||
|
|
||||||
func queueItems(for id: UUID) -> [DownloadItem] {
|
func queueItems(for id: UUID) -> [DownloadItem] {
|
||||||
let id = normalizedQueueID(id)
|
let id = normalizedQueueID(id)
|
||||||
return downloads.filter { normalizedQueueID($0.queueID) == id }
|
return downloads.filter { validQueueID($0.queueID) == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func queueCount(for id: UUID) -> Int {
|
func queueCount(for id: UUID) -> Int {
|
||||||
@@ -275,11 +299,25 @@ final class DownloadController: ObservableObject {
|
|||||||
saveDownloads()
|
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) {
|
func moveDownload(_ itemID: UUID, before targetID: UUID, in queueID: UUID) {
|
||||||
let queueID = normalizedQueueID(queueID)
|
let queueID = normalizedQueueID(queueID)
|
||||||
guard itemID != targetID,
|
guard itemID != targetID,
|
||||||
let source = downloads.firstIndex(where: { $0.id == itemID && normalizedQueueID($0.queueID) == queueID }),
|
let source = downloads.firstIndex(where: { $0.id == itemID && validQueueID($0.queueID) == queueID }),
|
||||||
let target = downloads.firstIndex(where: { $0.id == targetID && normalizedQueueID($0.queueID) == queueID }) else {
|
let target = downloads.firstIndex(where: { $0.id == targetID && validQueueID($0.queueID) == queueID }) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,20 +544,26 @@ final class DownloadController: ObservableObject {
|
|||||||
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
|
guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
|
||||||
let data = try Data(contentsOf: storageURL)
|
let data = try Data(contentsOf: storageURL)
|
||||||
let state: StoredDownloadState
|
let state: StoredDownloadState
|
||||||
|
let isLegacyDownloadList: Bool
|
||||||
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
|
if let storedState = try? JSONDecoder().decode(StoredDownloadState.self, from: data) {
|
||||||
state = storedState
|
state = storedState
|
||||||
|
isLegacyDownloadList = false
|
||||||
} else {
|
} else {
|
||||||
state = StoredDownloadState(
|
state = StoredDownloadState(
|
||||||
queues: [.main],
|
queues: [.main],
|
||||||
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
|
downloads: try JSONDecoder().decode([DownloadItem].self, from: data)
|
||||||
)
|
)
|
||||||
|
isLegacyDownloadList = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldResumeRecoveredDownloads = false
|
var shouldResumeRecoveredDownloads = false
|
||||||
self.queues = normalizedQueues(state.queues)
|
self.queues = normalizedQueues(state.queues)
|
||||||
self.downloads = state.downloads.map { item in
|
self.downloads = state.downloads.map { item in
|
||||||
var adjusted = item
|
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 {
|
if adjusted.status == .downloading {
|
||||||
adjusted.status = .queued
|
adjusted.status = .queued
|
||||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||||
@@ -546,8 +590,12 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func normalizedQueueID(_ id: UUID?) -> UUID {
|
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 {
|
guard let id, queues.contains(where: { $0.id == id }) else {
|
||||||
return DownloadQueue.mainQueueID
|
return nil
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
.onDrag {
|
.onDrag {
|
||||||
draggedItemID = item.id
|
draggedItemID = item.id
|
||||||
return NSItemProvider(object: item.id.uuidString as NSString)
|
return NSItemProvider(object: dragPayload(for: item) as NSString)
|
||||||
}
|
}
|
||||||
.onDrop(
|
.onDrop(
|
||||||
of: [.text],
|
of: [.text],
|
||||||
@@ -361,10 +361,15 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetItems.contains(where: { $0.status != .downloading && $0.status != .queued }) {
|
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||||
Button {
|
Menu {
|
||||||
for target in targetItems where target.status != .downloading && target.status != .queued {
|
ForEach(controller.queues) { queue in
|
||||||
controller.queue(target)
|
Button(queue.name) {
|
||||||
|
controller.assignToQueue(
|
||||||
|
itemIDs: Set(targetItems.map(\.id)),
|
||||||
|
queueID: queue.id
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Add to Queue", systemImage: "list.bullet")
|
Label("Add to Queue", systemImage: "list.bullet")
|
||||||
@@ -482,6 +487,13 @@ struct DownloadTable: View {
|
|||||||
return index + 1
|
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 {
|
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
|
||||||
if lhs < rhs { return .orderedAscending }
|
if lhs < rhs { return .orderedAscending }
|
||||||
if lhs > rhs { return .orderedDescending }
|
if lhs > rhs { return .orderedDescending }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
enum DownloadSidebarFilter: Hashable {
|
enum DownloadSidebarFilter: Hashable {
|
||||||
case all
|
case all
|
||||||
case queued
|
case queued
|
||||||
case active
|
case active
|
||||||
case completed
|
case completed
|
||||||
case failed
|
case unfinished
|
||||||
case category(DownloadCategory)
|
case category(DownloadCategory)
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
@@ -14,7 +15,7 @@ enum DownloadSidebarFilter: Hashable {
|
|||||||
case .queued: "Queue"
|
case .queued: "Queue"
|
||||||
case .active: "Active"
|
case .active: "Active"
|
||||||
case .completed: "Completed"
|
case .completed: "Completed"
|
||||||
case .failed: "Failed"
|
case .unfinished: "Unfinished"
|
||||||
case .category(let category): category.rawValue
|
case .category(let category): category.rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ struct SidebarView: View {
|
|||||||
@EnvironmentObject private var controller: DownloadController
|
@EnvironmentObject private var controller: DownloadController
|
||||||
@Binding var selection: SidebarSelection
|
@Binding var selection: SidebarSelection
|
||||||
@State private var queueBeingRenamed: DownloadQueue?
|
@State private var queueBeingRenamed: DownloadQueue?
|
||||||
|
@State private var queueBeingRemoved: DownloadQueue?
|
||||||
@State private var queueName = ""
|
@State private var queueName = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -44,32 +46,20 @@ struct SidebarView: View {
|
|||||||
Label("Completed", systemImage: "checkmark.circle")
|
Label("Completed", systemImage: "checkmark.circle")
|
||||||
.badge(controller.completedCount)
|
.badge(controller.completedCount)
|
||||||
.tag(SidebarSelection.downloads(.completed))
|
.tag(SidebarSelection.downloads(.completed))
|
||||||
Label("Failed", systemImage: "exclamationmark.triangle")
|
Label("Unfinished", systemImage: "circle.dashed")
|
||||||
.badge(controller.failedCount)
|
.badge(controller.unfinishedCount)
|
||||||
.tag(SidebarSelection.downloads(.failed))
|
.tag(SidebarSelection.downloads(.unfinished))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Folders") {
|
Section("Folders") {
|
||||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||||
Label(category.rawValue, systemImage: category.symbolName)
|
folderRow(for: category)
|
||||||
.badge(controller.downloads.filter { $0.category == category }.count)
|
|
||||||
.tag(SidebarSelection.downloads(.category(category)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Queues") {
|
Section("Queues") {
|
||||||
ForEach(controller.queues) { queue in
|
ForEach(controller.queues) { queue in
|
||||||
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
|
queueRow(for: queue)
|
||||||
.badge(controller.queueCount(for: queue.id))
|
|
||||||
.tag(SidebarSelection.queue(queue.id))
|
|
||||||
.contextMenu {
|
|
||||||
if !queue.isMain {
|
|
||||||
Button("Rename") {
|
|
||||||
queueBeingRenamed = queue
|
|
||||||
queueName = queue.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -101,6 +91,31 @@ struct SidebarView: View {
|
|||||||
queueBeingRenamed = nil
|
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) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -126,4 +141,70 @@ struct SidebarView: View {
|
|||||||
.background(.bar)
|
.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