NowPlayingView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import SwiftUI
  2. /// Tidal-inspired Now Playing window — large artwork, track info, synced lyrics.
  3. struct NowPlayingView: View {
  4. enum DisplayMode {
  5. case inline // Embedded in main window (Apple Music style, default)
  6. case floating // Separate window (Tidal style)
  7. }
  8. let displayMode: DisplayMode
  9. @Environment(PlayerViewModel.self) private var playerVM
  10. @EnvironmentObject private var theme: AppTheme
  11. @State private var lyrics: [LyricsParser.LyricLine] = []
  12. @State private var lyricsState: LyricsState = .idle
  13. @State private var isSynced = false
  14. @State private var lastLoadedTrackID: UUID?
  15. @State private var showAsPlainText = false
  16. init(displayMode: DisplayMode = .inline) {
  17. self.displayMode = displayMode
  18. }
  19. enum LyricsState {
  20. case idle
  21. case loading
  22. case loaded
  23. case notFound
  24. case error(String)
  25. case instrumental
  26. }
  27. var body: some View {
  28. VStack(spacing: 0) {
  29. if displayMode == .inline {
  30. inlineToolbar
  31. }
  32. HStack(spacing: 0) {
  33. // Left side: Artwork + track info + progress
  34. leftPanel
  35. .frame(minWidth: 280, idealWidth: 360, maxWidth: 500)
  36. Divider()
  37. // Right side: Lyrics
  38. lyricsPanel
  39. .frame(minWidth: 250, idealWidth: 450)
  40. }
  41. }
  42. .frame(minWidth: displayMode == .floating ? 650 : nil,
  43. minHeight: displayMode == .floating ? 500 : nil)
  44. .background(Color(nsColor: .windowBackgroundColor))
  45. .onChange(of: playerVM.currentTrack?.id) { _, newID in
  46. if newID != lastLoadedTrackID {
  47. loadLyrics()
  48. }
  49. }
  50. .onAppear {
  51. loadLyrics()
  52. }
  53. }
  54. // MARK: - Inline Toolbar
  55. private var inlineToolbar: some View {
  56. HStack {
  57. Button {
  58. NotificationCenter.default.post(name: .closeInlineNowPlaying, object: nil)
  59. } label: {
  60. HStack(spacing: 4) {
  61. Image(systemName: "chevron.down")
  62. .font(.system(size: 12, weight: .semibold))
  63. Text("Now Playing")
  64. .font(.system(size: 13, weight: .medium))
  65. }
  66. .foregroundStyle(.secondary)
  67. }
  68. .buttonStyle(.plain)
  69. Spacer()
  70. Button {
  71. NotificationCenter.default.post(name: .popOutNowPlaying, object: nil)
  72. } label: {
  73. Image(systemName: "arrow.up.forward.square")
  74. .font(.system(size: 14))
  75. .foregroundStyle(.secondary)
  76. }
  77. .buttonStyle(.plain)
  78. .help("Open in separate window")
  79. }
  80. .padding(.horizontal, 16)
  81. .padding(.vertical, 8)
  82. }
  83. // MARK: - Left Panel
  84. private var leftPanel: some View {
  85. VStack(spacing: 0) {
  86. Spacer(minLength: 24)
  87. // Album artwork
  88. artworkSection
  89. .padding(.horizontal, 32)
  90. Spacer(minLength: 20)
  91. // Track info
  92. trackInfoSection
  93. .padding(.horizontal, 24)
  94. Spacer(minLength: 16)
  95. // Progress bar
  96. progressSection
  97. .padding(.horizontal, 24)
  98. Spacer(minLength: 12)
  99. // Transport controls
  100. transportSection
  101. Spacer(minLength: 16)
  102. }
  103. }
  104. private var artworkSection: some View {
  105. Group {
  106. if let track = playerVM.currentTrack {
  107. ArtworkView(track: track, size: 320)
  108. .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 8)
  109. } else {
  110. ZStack {
  111. RoundedRectangle(cornerRadius: 12)
  112. .fill(.quaternary)
  113. Image(systemName: "music.note")
  114. .font(.system(size: 80))
  115. .foregroundStyle(.tertiary)
  116. }
  117. .frame(width: 320, height: 320)
  118. }
  119. }
  120. }
  121. private var trackInfoSection: some View {
  122. VStack(spacing: 4) {
  123. Text(playerVM.currentTrack?.title ?? "Not Playing")
  124. .font(.title2)
  125. .fontWeight(.semibold)
  126. .lineLimit(2)
  127. .multilineTextAlignment(.center)
  128. Text(playerVM.currentTrack?.artist ?? "")
  129. .font(.body)
  130. .foregroundStyle(.secondary)
  131. .lineLimit(1)
  132. if let album = playerVM.currentTrack?.album, !album.isEmpty {
  133. Text(album)
  134. .font(.callout)
  135. .foregroundStyle(.tertiary)
  136. .lineLimit(1)
  137. }
  138. }
  139. }
  140. private var progressSection: some View {
  141. VStack(spacing: 4) {
  142. NowPlayingSeekBar()
  143. HStack {
  144. Text(playerVM.currentTimeFormatted)
  145. .font(.system(size: 11, design: .monospaced))
  146. .foregroundStyle(.secondary)
  147. Spacer()
  148. Text(playerVM.remainingTimeFormatted)
  149. .font(.system(size: 11, design: .monospaced))
  150. .foregroundStyle(.secondary)
  151. }
  152. }
  153. }
  154. private var transportSection: some View {
  155. HStack(spacing: 16) {
  156. // Shuffle
  157. Button {
  158. playerVM.shuffleEnabled.toggle()
  159. } label: {
  160. Image(systemName: "shuffle")
  161. .font(.system(size: 15))
  162. .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : .secondary)
  163. }
  164. .buttonStyle(.plain)
  165. // Previous
  166. Button { playerVM.playPrevious() } label: {
  167. Image(systemName: "backward.fill")
  168. .font(.system(size: 22))
  169. }
  170. .buttonStyle(.plain)
  171. .disabled(playerVM.currentTrack == nil)
  172. // Play/Pause
  173. Button { playerVM.togglePlayPause() } label: {
  174. Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill")
  175. .font(.system(size: 44))
  176. .foregroundStyle(theme.accent)
  177. }
  178. .buttonStyle(.plain)
  179. .disabled(playerVM.currentTrack == nil)
  180. // Next
  181. Button { playerVM.playNext() } label: {
  182. Image(systemName: "forward.fill")
  183. .font(.system(size: 22))
  184. }
  185. .buttonStyle(.plain)
  186. .disabled(playerVM.currentTrack == nil)
  187. // Repeat
  188. Button {
  189. switch playerVM.repeatMode {
  190. case .off: playerVM.repeatMode = .all
  191. case .all: playerVM.repeatMode = .one
  192. case .one: playerVM.repeatMode = .off
  193. }
  194. } label: {
  195. Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat")
  196. .font(.system(size: 15))
  197. .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : .secondary)
  198. }
  199. .buttonStyle(.plain)
  200. }
  201. }
  202. // MARK: - Lyrics Panel
  203. private var lyricsPanel: some View {
  204. VStack(spacing: 0) {
  205. // Lyrics header
  206. HStack {
  207. Text("Lyrics")
  208. .font(.headline)
  209. .foregroundStyle(.secondary)
  210. Spacer()
  211. if case .loaded = lyricsState, isSynced {
  212. Button {
  213. showAsPlainText.toggle()
  214. } label: {
  215. HStack(spacing: 4) {
  216. Image(systemName: showAsPlainText ? "text.alignleft" : "waveform")
  217. .font(.system(size: 10))
  218. Text(showAsPlainText ? "Plain" : "Synced")
  219. .font(.caption)
  220. }
  221. .padding(.horizontal, 8)
  222. .padding(.vertical, 3)
  223. .background(showAsPlainText ? Color.gray.opacity(0.15) : theme.accent.opacity(0.2))
  224. .foregroundStyle(showAsPlainText ? .secondary : theme.accent)
  225. .clipShape(Capsule())
  226. }
  227. .buttonStyle(.plain)
  228. .help(showAsPlainText ? "Switch to synced lyrics" : "Switch to plain text")
  229. }
  230. }
  231. .padding(.horizontal, 24)
  232. .padding(.top, 20)
  233. .padding(.bottom, 8)
  234. // Lyrics content
  235. switch lyricsState {
  236. case .idle, .loading:
  237. Spacer()
  238. ProgressView()
  239. .scaleEffect(0.8)
  240. Text("Searching for lyrics…")
  241. .font(.callout)
  242. .foregroundStyle(.tertiary)
  243. .padding(.top, 8)
  244. Spacer()
  245. case .notFound:
  246. Spacer()
  247. VStack(spacing: 12) {
  248. Image(systemName: "text.page.slash")
  249. .font(.system(size: 36))
  250. .foregroundStyle(.tertiary)
  251. Text("No lyrics found")
  252. .font(.title3)
  253. .foregroundStyle(.secondary)
  254. if let track = playerVM.currentTrack {
  255. Text("\(track.artist) — \(track.title)")
  256. .font(.callout)
  257. .foregroundStyle(.tertiary)
  258. }
  259. }
  260. Spacer()
  261. case .instrumental:
  262. Spacer()
  263. VStack(spacing: 12) {
  264. Image(systemName: "pianokeys")
  265. .font(.system(size: 36))
  266. .foregroundStyle(.tertiary)
  267. Text("Instrumental")
  268. .font(.title3)
  269. .foregroundStyle(.secondary)
  270. }
  271. Spacer()
  272. case .error(let message):
  273. Spacer()
  274. VStack(spacing: 12) {
  275. Image(systemName: "exclamationmark.triangle")
  276. .font(.system(size: 36))
  277. .foregroundStyle(.tertiary)
  278. Text(message)
  279. .font(.callout)
  280. .foregroundStyle(.secondary)
  281. }
  282. Spacer()
  283. case .loaded:
  284. if isSynced && !showAsPlainText {
  285. SyncedLyricsView(
  286. lines: lyrics,
  287. currentTime: playerVM.currentTime,
  288. accent: theme.accent,
  289. onSeek: { time in playerVM.seek(to: time) }
  290. )
  291. } else {
  292. PlainLyricsView(lines: lyrics)
  293. }
  294. }
  295. }
  296. }
  297. // MARK: - Lyrics Loading
  298. private func loadLyrics() {
  299. guard let track = playerVM.currentTrack else {
  300. lyricsState = .idle
  301. lyrics = []
  302. return
  303. }
  304. lastLoadedTrackID = track.id
  305. lyricsState = .loading
  306. lyrics = []
  307. Task {
  308. do {
  309. let result = try await LRCLIBService.shared.fetchLyrics(
  310. artist: track.artist,
  311. title: track.title,
  312. album: track.album.isEmpty ? nil : track.album,
  313. duration: track.duration
  314. )
  315. if result.isInstrumental {
  316. lyricsState = .instrumental
  317. return
  318. }
  319. if let synced = result.syncedLyrics, !synced.isEmpty {
  320. lyrics = LyricsParser.parseSynced(synced)
  321. isSynced = true
  322. lyricsState = .loaded
  323. } else if let plain = result.plainLyrics, !plain.isEmpty {
  324. lyrics = LyricsParser.parsePlain(plain)
  325. isSynced = false
  326. lyricsState = .loaded
  327. } else {
  328. lyricsState = .notFound
  329. }
  330. } catch is LyricsError {
  331. lyricsState = .notFound
  332. } catch {
  333. lyricsState = .error(error.localizedDescription)
  334. }
  335. }
  336. }
  337. }
  338. // MARK: - Seekbar for Now Playing
  339. private struct NowPlayingSeekBar: View {
  340. @Environment(PlayerViewModel.self) private var playerVM
  341. @EnvironmentObject private var theme: AppTheme
  342. @State private var isDragging = false
  343. @State private var dragProgress: Double = 0
  344. var body: some View {
  345. GeometryReader { geo in
  346. ZStack(alignment: .leading) {
  347. // Background track
  348. Capsule()
  349. .fill(Color.gray.opacity(0.2))
  350. // Progress fill
  351. Capsule()
  352. .fill(theme.accent)
  353. .frame(width: max(0, (isDragging ? dragProgress : playerVM.progress) * geo.size.width))
  354. }
  355. .gesture(
  356. DragGesture(minimumDistance: 0)
  357. .onChanged { value in
  358. isDragging = true
  359. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  360. }
  361. .onEnded { value in
  362. let prog = max(0, min(1, value.location.x / geo.size.width))
  363. playerVM.seekToProgress(prog)
  364. isDragging = false
  365. }
  366. )
  367. }
  368. .frame(height: 4)
  369. .contentShape(Rectangle().inset(by: -8))
  370. }
  371. }
  372. // MARK: - Synced Lyrics View (auto-scrolling, highlighted)
  373. struct SyncedLyricsView: View {
  374. let lines: [LyricsParser.LyricLine]
  375. let currentTime: TimeInterval
  376. let accent: Color
  377. let onSeek: (TimeInterval) -> Void
  378. @State private var currentIndex: Int?
  379. var body: some View {
  380. ScrollViewReader { proxy in
  381. ScrollView {
  382. LazyVStack(alignment: .leading, spacing: 8) {
  383. // Top padding
  384. Spacer()
  385. .frame(height: 40)
  386. ForEach(Array(lines.enumerated()), id: \.element.id) { index, line in
  387. let isCurrent = index == currentIndex
  388. Text(line.text.isEmpty ? "♪" : line.text)
  389. .font(.system(size: isCurrent ? 20 : 17, weight: isCurrent ? .semibold : .regular))
  390. .foregroundStyle(isCurrent ? accent : (index < (currentIndex ?? 0) ? .secondary : .primary))
  391. .opacity(isCurrent ? 1.0 : (index < (currentIndex ?? 0) ? 0.5 : 0.7))
  392. .padding(.horizontal, 24)
  393. .padding(.vertical, 2)
  394. .id(line.id)
  395. .onTapGesture {
  396. onSeek(line.timestamp)
  397. }
  398. .animation(.easeInOut(duration: 0.3), value: isCurrent)
  399. }
  400. // Bottom padding
  401. Spacer()
  402. .frame(height: 120)
  403. }
  404. }
  405. .onChange(of: currentTime) { _, time in
  406. let newIndex = LyricsParser.currentLineIndex(in: lines, at: time)
  407. if newIndex != currentIndex {
  408. currentIndex = newIndex
  409. if let idx = newIndex, idx < lines.count {
  410. withAnimation(.easeInOut(duration: 0.4)) {
  411. proxy.scrollTo(lines[idx].id, anchor: .center)
  412. }
  413. }
  414. }
  415. }
  416. .onAppear {
  417. currentIndex = LyricsParser.currentLineIndex(in: lines, at: currentTime)
  418. if let idx = currentIndex, idx < lines.count {
  419. proxy.scrollTo(lines[idx].id, anchor: .center)
  420. }
  421. }
  422. }
  423. }
  424. }
  425. // MARK: - Plain Lyrics View (no timestamps)
  426. struct PlainLyricsView: View {
  427. let lines: [LyricsParser.LyricLine]
  428. var body: some View {
  429. ScrollView {
  430. LazyVStack(alignment: .leading, spacing: 6) {
  431. Spacer()
  432. .frame(height: 16)
  433. ForEach(lines) { line in
  434. Text(line.text.isEmpty ? " " : line.text)
  435. .font(.system(size: 16))
  436. .foregroundStyle(line.text.isEmpty ? .clear : .primary.opacity(0.8))
  437. .padding(.horizontal, 24)
  438. }
  439. Spacer()
  440. .frame(height: 60)
  441. }
  442. }
  443. }
  444. }