DownloadIndicator.swift 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import SwiftUI
  2. /// Reusable download state indicator for cloud tracks.
  3. /// Shows four states: not downloaded, downloading (progress), downloaded, error.
  4. /// 20pt frame, designed to sit between Spacer() and duration in track rows.
  5. struct DownloadIndicator: View {
  6. let track: Track
  7. @Environment(\.accessibilityReduceMotion) private var reduceMotion
  8. @State private var downloadManager = DownloadManager.shared
  9. @State private var showBounce = false
  10. var body: some View {
  11. Button {
  12. handleTap()
  13. } label: {
  14. ZStack {
  15. switch track.downloadState {
  16. case .none:
  17. Image(systemName: "arrow.down.circle")
  18. .foregroundStyle(.tertiary)
  19. case .downloading:
  20. let progress = downloadManager.progress(for: track)
  21. CircularProgressView(progress: progress)
  22. .foregroundStyle(Color.accentColor)
  23. case .downloaded:
  24. Image(systemName: "arrow.down.circle.fill")
  25. .foregroundStyle(.green)
  26. .scaleEffect(showBounce ? 1.15 : 1.0)
  27. case .error:
  28. Image(systemName: "exclamationmark.circle.fill")
  29. .foregroundStyle(.red)
  30. }
  31. }
  32. .font(.system(size: 14))
  33. .frame(width: 20, height: 20)
  34. .contentShape(Rectangle())
  35. }
  36. .buttonStyle(.plain)
  37. .help(helpText)
  38. .onChange(of: track.downloadState) { oldValue, newValue in
  39. if oldValue == .downloading && newValue == .downloaded {
  40. triggerBounce()
  41. }
  42. }
  43. }
  44. private var helpText: String {
  45. switch track.downloadState {
  46. case .none: "Download for offline playback"
  47. case .downloading: "Cancel download"
  48. case .downloaded: "Downloaded — available offline"
  49. case .error: "Download failed — tap to retry"
  50. }
  51. }
  52. private func handleTap() {
  53. switch track.downloadState {
  54. case .none:
  55. downloadManager.download(track: track, apiClient: ChadMusicAPIClient.shared)
  56. case .downloading:
  57. downloadManager.cancel(track: track)
  58. case .downloaded:
  59. break // No action on tap when downloaded — use context menu for remove
  60. case .error:
  61. downloadManager.download(track: track, apiClient: ChadMusicAPIClient.shared)
  62. }
  63. }
  64. private func triggerBounce() {
  65. if reduceMotion {
  66. return
  67. }
  68. withAnimation(.easeInOut(duration: 0.2)) {
  69. showBounce = true
  70. }
  71. DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
  72. withAnimation(.easeInOut(duration: 0.2)) {
  73. showBounce = false
  74. }
  75. }
  76. }
  77. }
  78. /// Circular progress ring for download indicator.
  79. private struct CircularProgressView: View {
  80. let progress: Double
  81. var body: some View {
  82. ZStack {
  83. Circle()
  84. .stroke(lineWidth: 2)
  85. .opacity(0.2)
  86. Circle()
  87. .trim(from: 0, to: CGFloat(max(0.05, progress)))
  88. .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round))
  89. .rotationEffect(.degrees(-90))
  90. }
  91. .padding(2)
  92. }
  93. }