PlayerView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import SwiftUI
  2. /// Compact player bar with transport, shuffle/repeat, cursor mode, track info, time, volume, skin.
  3. struct PlayerView: View {
  4. @Environment(PlayerViewModel.self) private var playerVM
  5. @EnvironmentObject private var theme: AppTheme
  6. var body: some View {
  7. VStack(spacing: 0) {
  8. // Waveform display (clickable for seek)
  9. WaveformDisplay()
  10. // Separator between waveform and slider
  11. if !playerVM.waveformSamples.isEmpty {
  12. Rectangle()
  13. .fill(theme.waveformSeparator)
  14. .frame(height: 10)
  15. }
  16. // Thin seek slider below the waveform
  17. SeekSlider()
  18. HStack(spacing: 0) {
  19. TransportButtons()
  20. divider()
  21. ShuffleRepeatButtons()
  22. divider()
  23. CursorModeButton()
  24. divider()
  25. TrackInfoStrip()
  26. Spacer(minLength: 4)
  27. // Now Playing button
  28. if playerVM.currentTrack != nil {
  29. NowPlayingButton()
  30. divider()
  31. }
  32. TimeDisplay()
  33. divider()
  34. VolumeControl()
  35. divider()
  36. SettingsButton()
  37. }
  38. .padding(.horizontal, 10)
  39. .padding(.vertical, 5)
  40. .frame(height: 52)
  41. }
  42. .background(.bar)
  43. }
  44. private func divider() -> some View {
  45. Divider()
  46. .frame(height: 28)
  47. .padding(.horizontal, 7)
  48. }
  49. }
  50. // MARK: - Waveform Display (visual, clickable for seek)
  51. private struct WaveformDisplay: View {
  52. @Environment(PlayerViewModel.self) private var playerVM
  53. @EnvironmentObject private var theme: AppTheme
  54. @State private var isDragging = false
  55. @State private var dragProgress: Double = 0
  56. var body: some View {
  57. if !playerVM.waveformSamples.isEmpty {
  58. GeometryReader { geo in
  59. let progress = isDragging ? dragProgress : playerVM.progress
  60. let samples = playerVM.waveformSamples
  61. Canvas { context, size in
  62. let midY = size.height / 2
  63. let count = samples.count
  64. guard count > 0 else { return }
  65. let playedIndex = Int(progress * Double(count))
  66. for (index, sample) in samples.enumerated() {
  67. let x = CGFloat(index) / CGFloat(count) * size.width
  68. let barWidth = max(0.5, size.width / CGFloat(count) - 0.3)
  69. let topHeight = CGFloat(sample.max) * midY
  70. let bottomHeight = CGFloat(-sample.min) * midY
  71. let rect = CGRect(
  72. x: x,
  73. y: midY - topHeight,
  74. width: barWidth,
  75. height: topHeight + bottomHeight
  76. )
  77. let color = index < playedIndex
  78. ? theme.primaryText
  79. : theme.waveformBackground
  80. context.fill(Path(rect), with: .color(color))
  81. }
  82. // Playhead line
  83. let playheadX = progress * size.width
  84. var playheadPath = Path()
  85. playheadPath.move(to: CGPoint(x: playheadX, y: 0))
  86. playheadPath.addLine(to: CGPoint(x: playheadX, y: size.height))
  87. context.stroke(playheadPath, with: .color(theme.accent), lineWidth: 1.5)
  88. }
  89. .contentShape(Rectangle())
  90. .gesture(
  91. DragGesture(minimumDistance: 0)
  92. .onChanged { value in
  93. isDragging = true
  94. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  95. }
  96. .onEnded { value in
  97. let prog = max(0, min(1, value.location.x / geo.size.width))
  98. playerVM.seekToProgress(prog)
  99. isDragging = false
  100. }
  101. )
  102. }
  103. .frame(height: 32)
  104. }
  105. }
  106. }
  107. // MARK: - Seek Slider (thin progress bar)
  108. private struct SeekSlider: View {
  109. @Environment(PlayerViewModel.self) private var playerVM
  110. @EnvironmentObject private var theme: AppTheme
  111. @State private var isDragging = false
  112. @State private var dragProgress: Double = 0
  113. var body: some View {
  114. GeometryReader { geo in
  115. let progress = isDragging ? dragProgress : playerVM.progress
  116. ZStack(alignment: .leading) {
  117. // Background track
  118. Rectangle()
  119. .fill(theme.seekbarBackground)
  120. // Filled portion — matches volume slider color (accent)
  121. Rectangle()
  122. .fill(theme.accent)
  123. .frame(width: max(0, progress * geo.size.width))
  124. }
  125. .contentShape(Rectangle())
  126. .gesture(
  127. DragGesture(minimumDistance: 0)
  128. .onChanged { value in
  129. isDragging = true
  130. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  131. }
  132. .onEnded { value in
  133. let prog = max(0, min(1, value.location.x / geo.size.width))
  134. playerVM.seekToProgress(prog)
  135. isDragging = false
  136. }
  137. )
  138. }
  139. .frame(height: 4)
  140. }
  141. }
  142. // MARK: - Transport Buttons
  143. private struct TransportButtons: View {
  144. @Environment(PlayerViewModel.self) private var playerVM
  145. var body: some View {
  146. HStack(spacing: 5) {
  147. btn("stop.fill", help: "Stop") { playerVM.stop() }
  148. btn("backward.end.fill", help: "Previous Track") { playerVM.playPrevious() }
  149. btn(playerVM.isPlaying ? "pause.fill" : "play.fill", help: playerVM.isPlaying ? "Pause (Space)" : "Play (Space)") { playerVM.togglePlayPause() }
  150. btn("forward.end.fill", help: "Next Track") { playerVM.playNext() }
  151. }
  152. }
  153. private func btn(_ icon: String, help: String, action: @escaping () -> Void) -> some View {
  154. Button(action: action) {
  155. Image(systemName: icon)
  156. .font(.system(size: 20))
  157. .frame(width: 40, height: 40)
  158. .contentShape(Rectangle())
  159. }
  160. .buttonStyle(.plain)
  161. .disabled(playerVM.currentTrack == nil)
  162. .help(help)
  163. }
  164. }
  165. // MARK: - Shuffle & Repeat
  166. private struct ShuffleRepeatButtons: View {
  167. @Environment(PlayerViewModel.self) private var playerVM
  168. @EnvironmentObject private var theme: AppTheme
  169. var body: some View {
  170. HStack(spacing: 4) {
  171. Button {
  172. playerVM.shuffleEnabled.toggle()
  173. } label: {
  174. Image(systemName: "shuffle")
  175. .font(.system(size: 17))
  176. .frame(width: 36, height: 36)
  177. .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
  178. .contentShape(Rectangle())
  179. }
  180. .buttonStyle(.plain)
  181. .help(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off")
  182. Button {
  183. switch playerVM.repeatMode {
  184. case .off: playerVM.repeatMode = .all
  185. case .all: playerVM.repeatMode = .one
  186. case .one: playerVM.repeatMode = .off
  187. }
  188. } label: {
  189. Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat")
  190. .font(.system(size: 17))
  191. .frame(width: 36, height: 36)
  192. .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
  193. .contentShape(Rectangle())
  194. }
  195. .buttonStyle(.plain)
  196. .help("Repeat: \(playerVM.repeatMode.rawValue)")
  197. }
  198. }
  199. }
  200. // MARK: - Cursor Mode Toggle
  201. private struct CursorModeButton: View {
  202. @EnvironmentObject private var theme: AppTheme
  203. @ObservedObject private var viewConfig = PlaylistViewConfig.shared
  204. var body: some View {
  205. Button {
  206. // Toggle between the two modes (always keep at least one on)
  207. if viewConfig.cursorFollowsPlayback {
  208. viewConfig.cursorFollowsPlayback = false
  209. viewConfig.playbackFollowsCursor = true
  210. } else {
  211. viewConfig.cursorFollowsPlayback = true
  212. viewConfig.playbackFollowsCursor = false
  213. }
  214. } label: {
  215. // Both arrows point right (= playback direction)
  216. // Line position = cursor: |→ vs →|
  217. Group {
  218. if viewConfig.cursorFollowsPlayback {
  219. // |→ (cursor behind, playback pulls cursor forward)
  220. Image(systemName: "arrow.right.to.line")
  221. .scaleEffect(x: -1, y: 1)
  222. } else {
  223. // →| (cursor ahead, playback goes to where cursor points)
  224. Image(systemName: "arrow.right.to.line")
  225. }
  226. }
  227. .font(.system(size: 17))
  228. .frame(width: 36, height: 36)
  229. .foregroundStyle(theme.accent)
  230. .contentShape(Rectangle())
  231. }
  232. .buttonStyle(.plain)
  233. .help(viewConfig.cursorFollowsPlayback ? "Cursor follows playback (click to switch)" : "Playback follows cursor (click to switch)")
  234. }
  235. }
  236. // MARK: - Track Info Strip
  237. private struct TrackInfoStrip: View {
  238. @Environment(PlayerViewModel.self) private var playerVM
  239. @EnvironmentObject private var theme: AppTheme
  240. var body: some View {
  241. if let track = playerVM.currentTrack {
  242. HStack(spacing: 5) {
  243. if playerVM.isPlaying {
  244. Image(systemName: "speaker.wave.2.fill")
  245. .font(.system(size: 10))
  246. .foregroundStyle(theme.playingHighlight)
  247. }
  248. Text(trackDescription(track))
  249. .font(.system(size: theme.dataFontSize))
  250. .lineLimit(1)
  251. .truncationMode(.tail)
  252. .foregroundStyle(theme.primaryText)
  253. }
  254. } else {
  255. Text("Stopped")
  256. .font(.system(size: theme.dataFontSize))
  257. .foregroundStyle(theme.tertiaryText)
  258. }
  259. }
  260. private func trackDescription(_ track: Track) -> String {
  261. var parts: [String] = []
  262. if !track.artist.isEmpty { parts.append(track.artist) }
  263. parts.append(track.title)
  264. if !track.album.isEmpty { parts.append("[\(track.album)]") }
  265. return parts.joined(separator: " - ")
  266. }
  267. }
  268. // MARK: - Time Display
  269. private struct TimeDisplay: View {
  270. @Environment(PlayerViewModel.self) private var playerVM
  271. @EnvironmentObject private var theme: AppTheme
  272. var body: some View {
  273. if playerVM.currentTrack != nil {
  274. Text("\(playerVM.currentTimeFormatted) / \(playerVM.durationFormatted)")
  275. .font(.system(size: 14, design: .monospaced))
  276. .foregroundStyle(theme.secondaryText)
  277. .fixedSize()
  278. }
  279. }
  280. }
  281. // MARK: - Volume Control
  282. private struct VolumeControl: View {
  283. @Environment(PlayerViewModel.self) private var playerVM
  284. @EnvironmentObject private var theme: AppTheme
  285. var body: some View {
  286. @Bindable var vm = playerVM
  287. HStack(spacing: 4) {
  288. Image(systemName: "speaker.fill")
  289. .font(.system(size: 10))
  290. .foregroundStyle(theme.secondaryText)
  291. Slider(value: $vm.volume, in: 0...1)
  292. .frame(width: 70)
  293. .controlSize(.small)
  294. .tint(theme.accent)
  295. }
  296. .help("Volume: \(Int(playerVM.volume * 100))%")
  297. }
  298. }
  299. // MARK: - Settings Button
  300. private struct SettingsButton: View {
  301. @EnvironmentObject private var theme: AppTheme
  302. @Environment(\.openSettings) private var openSettings
  303. var body: some View {
  304. Button {
  305. openSettings()
  306. } label: {
  307. Image(systemName: "gearshape")
  308. .font(.system(size: 14))
  309. .foregroundStyle(theme.secondaryText)
  310. .frame(width: 30, height: 30)
  311. .contentShape(Rectangle())
  312. }
  313. .buttonStyle(.plain)
  314. .help("Settings (⌘,)")
  315. }
  316. }
  317. // MARK: - Now Playing Button
  318. private struct NowPlayingButton: View {
  319. @EnvironmentObject private var theme: AppTheme
  320. var body: some View {
  321. Button {
  322. NotificationCenter.default.post(name: .toggleNowPlaying, object: nil)
  323. } label: {
  324. Image(systemName: "text.below.photo")
  325. .font(.system(size: 14))
  326. .frame(width: 30, height: 30)
  327. .foregroundStyle(theme.secondaryText)
  328. .contentShape(Rectangle())
  329. }
  330. .buttonStyle(.plain)
  331. .help("Now Playing (⇧⌘P)")
  332. }
  333. }