DownloadsView.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import SwiftUI
  2. /// Shows active and completed Soulseek downloads from slskd.
  3. struct DownloadsView: View {
  4. @EnvironmentObject private var theme: AppTheme
  5. @State private var transferGroups: [SlskdTransferGroup] = []
  6. @State private var isLoading = false
  7. @State private var error: String?
  8. @State private var pollTask: Task<Void, Never>?
  9. var body: some View {
  10. VStack(spacing: 0) {
  11. // Header
  12. HStack {
  13. Text("Downloads")
  14. .font(.title2.bold())
  15. .foregroundStyle(theme.primaryText)
  16. Spacer()
  17. Button {
  18. Task { await refresh() }
  19. } label: {
  20. Image(systemName: "arrow.clockwise")
  21. .font(.system(size: 13))
  22. }
  23. .buttonStyle(.plain)
  24. .disabled(isLoading)
  25. }
  26. .padding(.horizontal, 20)
  27. .padding(.vertical, 12)
  28. Divider()
  29. if isLoading && transferGroups.isEmpty {
  30. Spacer()
  31. ProgressView("Loading downloads...")
  32. .foregroundStyle(theme.secondaryText)
  33. Spacer()
  34. } else if let error {
  35. Spacer()
  36. VStack(spacing: 8) {
  37. Image(systemName: "exclamationmark.triangle")
  38. .font(.title2)
  39. .foregroundStyle(.orange)
  40. Text(error)
  41. .font(.callout)
  42. .foregroundStyle(theme.secondaryText)
  43. }
  44. Spacer()
  45. } else if allTransfers.isEmpty {
  46. Spacer()
  47. VStack(spacing: 8) {
  48. Image(systemName: "arrow.down.circle")
  49. .font(.system(size: 32))
  50. .foregroundStyle(theme.tertiaryText)
  51. Text("No active downloads")
  52. .font(.callout)
  53. .foregroundStyle(theme.secondaryText)
  54. Text("Search for music and download from Soulseek sources.")
  55. .font(.caption)
  56. .foregroundStyle(theme.tertiaryText)
  57. }
  58. Spacer()
  59. } else {
  60. downloadsList
  61. }
  62. }
  63. .frame(maxWidth: .infinity, maxHeight: .infinity)
  64. .onAppear { startPolling() }
  65. .onDisappear { stopPolling() }
  66. }
  67. // MARK: - Downloads List
  68. private var downloadsList: some View {
  69. List {
  70. ForEach(transferGroups, id: \.username) { group in
  71. Section {
  72. ForEach(transfersFor(group), id: \.filename) { transfer in
  73. TransferRow(transfer: transfer)
  74. }
  75. } header: {
  76. HStack(spacing: 6) {
  77. Image(systemName: "person.fill")
  78. .font(.system(size: 10))
  79. Text(group.username)
  80. .font(.system(size: 11, weight: .semibold))
  81. Spacer()
  82. Text(groupSummary(group))
  83. .font(.system(size: 10))
  84. .foregroundStyle(theme.tertiaryText)
  85. }
  86. }
  87. }
  88. }
  89. .listStyle(.inset)
  90. }
  91. // MARK: - Helpers
  92. private var allTransfers: [SlskdTransfer] {
  93. transferGroups.flatMap { group in
  94. group.directories?.flatMap { $0.files ?? [] } ?? []
  95. }
  96. }
  97. private func transfersFor(_ group: SlskdTransferGroup) -> [SlskdTransfer] {
  98. group.directories?.flatMap { $0.files ?? [] } ?? []
  99. }
  100. private func groupSummary(_ group: SlskdTransferGroup) -> String {
  101. let transfers = transfersFor(group)
  102. let completed = transfers.filter(\.isComplete).count
  103. let failed = transfers.filter(\.isFailed).count
  104. let total = transfers.count
  105. if failed > 0 {
  106. return "\(completed)/\(total) done, \(failed) failed"
  107. }
  108. return "\(completed)/\(total) done"
  109. }
  110. // MARK: - Polling
  111. private func startPolling() {
  112. pollTask = Task {
  113. while !Task.isCancelled {
  114. await refresh()
  115. try? await Task.sleep(for: .seconds(3))
  116. }
  117. }
  118. }
  119. private func stopPolling() {
  120. pollTask?.cancel()
  121. pollTask = nil
  122. }
  123. private func refresh() async {
  124. isLoading = true
  125. do {
  126. transferGroups = try await SlskdAPIClient.shared.getDownloads()
  127. error = nil
  128. } catch {
  129. self.error = error.localizedDescription
  130. }
  131. isLoading = false
  132. }
  133. }
  134. // MARK: - Transfer Row
  135. private struct TransferRow: View {
  136. let transfer: SlskdTransfer
  137. @EnvironmentObject private var theme: AppTheme
  138. var body: some View {
  139. VStack(alignment: .leading, spacing: 4) {
  140. // Filename
  141. Text(displayName)
  142. .font(.system(size: 12))
  143. .foregroundStyle(theme.primaryText)
  144. .lineLimit(1)
  145. HStack(spacing: 8) {
  146. // State indicator
  147. stateView
  148. // Progress bar (only when downloading)
  149. if isInProgress {
  150. ProgressView(value: transfer.percentComplete / 100)
  151. .progressViewStyle(.linear)
  152. .frame(maxWidth: 200)
  153. }
  154. Spacer()
  155. // Size
  156. Text(ByteCountFormatter.string(fromByteCount: transfer.size, countStyle: .file))
  157. .font(.system(size: 10, design: .monospaced))
  158. .foregroundStyle(theme.tertiaryText)
  159. // Speed
  160. if let speed = transfer.averageSpeed, speed > 0, isInProgress {
  161. Text(ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .file) + "/s")
  162. .font(.system(size: 10, design: .monospaced))
  163. .foregroundStyle(theme.tertiaryText)
  164. }
  165. }
  166. }
  167. .padding(.vertical, 2)
  168. }
  169. private var displayName: String {
  170. let normalized = transfer.filename.replacingOccurrences(of: "\\", with: "/")
  171. return normalized.split(separator: "/").last.map(String.init) ?? transfer.filename
  172. }
  173. private var isInProgress: Bool {
  174. !transfer.isComplete && !transfer.isFailed &&
  175. (transfer.state.contains("InProgress") || transfer.state.contains("Queued"))
  176. }
  177. @ViewBuilder
  178. private var stateView: some View {
  179. if transfer.isComplete {
  180. Image(systemName: "checkmark.circle.fill")
  181. .font(.system(size: 10))
  182. .foregroundStyle(.green)
  183. } else if transfer.isFailed {
  184. Image(systemName: "xmark.circle.fill")
  185. .font(.system(size: 10))
  186. .foregroundStyle(.red)
  187. } else if transfer.state.contains("Queued") {
  188. Image(systemName: "clock")
  189. .font(.system(size: 10))
  190. .foregroundStyle(.orange)
  191. } else {
  192. Image(systemName: "arrow.down.circle")
  193. .font(.system(size: 10))
  194. .foregroundStyle(theme.accent)
  195. }
  196. }
  197. }