PlayerView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import SwiftUI
  2. struct PlayerView: View {
  3. @Environment(PlayerViewModel.self) private var playerVM
  4. @EnvironmentObject private var theme: AppTheme
  5. @AppStorage("playbackMode") private var playbackMode: String = "queue"
  6. var body: some View {
  7. @Bindable var vm = playerVM
  8. VStack(spacing: 0) {
  9. WaveformDisplay()
  10. if !playerVM.waveformSamples.isEmpty {
  11. Rectangle()
  12. .fill(theme.waveformSeparator)
  13. .frame(height: 10)
  14. }
  15. SeekSlider()
  16. HStack(spacing: 0) {
  17. // Transport zone (left)
  18. HStack(spacing: 4) {
  19. Button { playerVM.playPrevious() } label: {
  20. Image(systemName: "backward.end.fill")
  21. .font(.system(size: 18))
  22. .frame(width: 36, height: 36)
  23. .contentShape(Rectangle())
  24. }
  25. .buttonStyle(.plain)
  26. .disabled(playerVM.currentTrack == nil)
  27. .help("Previous Track")
  28. Button { playerVM.togglePlayPause() } label: {
  29. Image(systemName: playerVM.isPlaying ? "pause.fill" : "play.fill")
  30. .font(.system(size: 24))
  31. .frame(width: 44, height: 44)
  32. .contentShape(Rectangle())
  33. }
  34. .buttonStyle(.plain)
  35. .disabled(playerVM.currentTrack == nil)
  36. .help(playerVM.isPlaying ? "Pause (Space)" : "Play (Space)")
  37. Button { playerVM.playNext() } label: {
  38. Image(systemName: "forward.end.fill")
  39. .font(.system(size: 18))
  40. .frame(width: 36, height: 36)
  41. .contentShape(Rectangle())
  42. }
  43. .buttonStyle(.plain)
  44. .disabled(playerVM.currentTrack == nil)
  45. .help("Next Track")
  46. HStack(spacing: 4) {
  47. Button { playerVM.shuffleEnabled.toggle() } label: {
  48. Image(systemName: "shuffle")
  49. .font(.system(size: 14))
  50. .frame(width: 28, height: 28)
  51. .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
  52. .contentShape(Rectangle())
  53. }
  54. .buttonStyle(.plain)
  55. .help(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off")
  56. Button {
  57. switch playerVM.repeatMode {
  58. case .off: playerVM.repeatMode = .all
  59. case .all: playerVM.repeatMode = .one
  60. case .one: playerVM.repeatMode = .off
  61. }
  62. } label: {
  63. Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat")
  64. .font(.system(size: 14))
  65. .frame(width: 28, height: 28)
  66. .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
  67. .contentShape(Rectangle())
  68. }
  69. .buttonStyle(.plain)
  70. .help("Repeat: \(playerVM.repeatMode.rawValue)")
  71. }
  72. .padding(.leading, 8)
  73. }
  74. .padding(.leading, 12)
  75. Spacer(minLength: 8)
  76. // Track info zone (center)
  77. if let track = playerVM.currentTrack {
  78. HStack(spacing: 8) {
  79. ArtworkView(track: track, size: 44)
  80. VStack(alignment: .leading, spacing: 2) {
  81. Text(track.title)
  82. .font(.system(size: 13, weight: .semibold))
  83. .lineLimit(1)
  84. .foregroundStyle(theme.primaryText)
  85. Text(track.artist.isEmpty ? "Unknown Artist" : track.artist)
  86. .font(.system(size: 11))
  87. .lineLimit(1)
  88. .foregroundStyle(theme.secondaryText)
  89. }
  90. }
  91. .frame(maxWidth: 360)
  92. } else {
  93. Text("Not Playing")
  94. .font(.system(size: 13))
  95. .foregroundStyle(theme.tertiaryText)
  96. }
  97. Spacer(minLength: 8)
  98. // Time + Volume zone (right)
  99. HStack(spacing: 12) {
  100. if playerVM.currentTrack != nil {
  101. Text("\(playerVM.currentTimeFormatted) / \(playerVM.durationFormatted)")
  102. .font(.system(size: 11, design: .monospaced))
  103. .foregroundStyle(theme.secondaryText)
  104. .fixedSize()
  105. }
  106. HStack(spacing: 4) {
  107. Image(systemName: volumeIcon)
  108. .font(.system(size: 10))
  109. .foregroundStyle(theme.secondaryText)
  110. Slider(value: $vm.volume, in: 0...1)
  111. .frame(width: 80)
  112. .controlSize(.small)
  113. .tint(theme.accent)
  114. }
  115. .help("Volume: \(Int(playerVM.volume * 100))%")
  116. if playerVM.currentTrack != nil {
  117. NowPlayingButton()
  118. }
  119. }
  120. .padding(.trailing, 12)
  121. }
  122. .frame(height: 64)
  123. }
  124. .background(.bar)
  125. }
  126. private var volumeIcon: String {
  127. if playerVM.volume == 0 { return "speaker.slash.fill" }
  128. if playerVM.volume < 0.33 { return "speaker.wave.1.fill" }
  129. if playerVM.volume < 0.66 { return "speaker.wave.2.fill" }
  130. return "speaker.wave.3.fill"
  131. }
  132. }
  133. // MARK: - Waveform Display
  134. private struct WaveformDisplay: View {
  135. @Environment(PlayerViewModel.self) private var playerVM
  136. @EnvironmentObject private var theme: AppTheme
  137. @State private var isDragging = false
  138. @State private var dragProgress: Double = 0
  139. var body: some View {
  140. if !playerVM.waveformSamples.isEmpty {
  141. GeometryReader { geo in
  142. let progress = isDragging ? dragProgress : playerVM.progress
  143. let samples = playerVM.waveformSamples
  144. Canvas { context, size in
  145. let midY = size.height / 2
  146. let count = samples.count
  147. guard count > 0 else { return }
  148. let playedIndex = Int(progress * Double(count))
  149. for (index, sample) in samples.enumerated() {
  150. let x = CGFloat(index) / CGFloat(count) * size.width
  151. let barWidth = max(0.5, size.width / CGFloat(count) - 0.3)
  152. let topHeight = CGFloat(sample.max) * midY
  153. let bottomHeight = CGFloat(-sample.min) * midY
  154. let rect = CGRect(
  155. x: x,
  156. y: midY - topHeight,
  157. width: barWidth,
  158. height: topHeight + bottomHeight
  159. )
  160. let color = index < playedIndex
  161. ? theme.primaryText
  162. : theme.waveformBackground
  163. context.fill(Path(rect), with: .color(color))
  164. }
  165. let playheadX = progress * size.width
  166. var playheadPath = Path()
  167. playheadPath.move(to: CGPoint(x: playheadX, y: 0))
  168. playheadPath.addLine(to: CGPoint(x: playheadX, y: size.height))
  169. context.stroke(playheadPath, with: .color(theme.accent), lineWidth: 1.5)
  170. }
  171. .contentShape(Rectangle())
  172. .gesture(
  173. DragGesture(minimumDistance: 0)
  174. .onChanged { value in
  175. isDragging = true
  176. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  177. }
  178. .onEnded { value in
  179. let prog = max(0, min(1, value.location.x / geo.size.width))
  180. playerVM.seekToProgress(prog)
  181. isDragging = false
  182. }
  183. )
  184. }
  185. .frame(height: 48)
  186. }
  187. }
  188. }
  189. // MARK: - Seek Slider
  190. private struct SeekSlider: View {
  191. @Environment(PlayerViewModel.self) private var playerVM
  192. @EnvironmentObject private var theme: AppTheme
  193. @State private var isDragging = false
  194. @State private var dragProgress: Double = 0
  195. var body: some View {
  196. GeometryReader { geo in
  197. let progress = isDragging ? dragProgress : playerVM.progress
  198. ZStack(alignment: .leading) {
  199. Rectangle()
  200. .fill(theme.seekbarBackground)
  201. Rectangle()
  202. .fill(theme.accent)
  203. .frame(width: max(0, progress * geo.size.width))
  204. }
  205. .contentShape(Rectangle())
  206. .gesture(
  207. DragGesture(minimumDistance: 0)
  208. .onChanged { value in
  209. isDragging = true
  210. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  211. }
  212. .onEnded { value in
  213. let prog = max(0, min(1, value.location.x / geo.size.width))
  214. playerVM.seekToProgress(prog)
  215. isDragging = false
  216. }
  217. )
  218. }
  219. .frame(height: 4)
  220. }
  221. }
  222. // MARK: - Now Playing Button
  223. private struct NowPlayingButton: View {
  224. @EnvironmentObject private var theme: AppTheme
  225. var body: some View {
  226. Button {
  227. NotificationCenter.default.post(name: .toggleNowPlaying, object: nil)
  228. } label: {
  229. Image(systemName: "text.below.photo")
  230. .font(.system(size: 14))
  231. .frame(width: 30, height: 30)
  232. .foregroundStyle(theme.secondaryText)
  233. .contentShape(Rectangle())
  234. }
  235. .buttonStyle(.plain)
  236. .help("Now Playing (⇧⌘P)")
  237. }
  238. }