NowPlayingView.swift 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import SwiftUI
  2. /// Full-screen Now Playing view with large artwork, waveform, transport controls, and quick-add.
  3. struct NowPlayingView: View {
  4. @Environment(PlayerViewModel.self) private var playerVM
  5. @Environment(PlaylistViewModel.self) private var playlistVM
  6. @EnvironmentObject private var theme: AppTheme
  7. @Environment(\.modelContext) private var modelContext
  8. @Environment(\.dismiss) private var dismiss
  9. @State private var isDragging = false
  10. @State private var dragProgress: Double = 0
  11. // Lyrics state
  12. @State private var showLyrics = false
  13. @State private var lyrics: [LyricsParser.LyricLine] = []
  14. @State private var lyricsState: LyricsState = .idle
  15. @State private var isSynced = false
  16. @State private var lastLoadedTrackID: UUID?
  17. @State private var showAsPlainText = false
  18. enum LyricsState {
  19. case idle
  20. case loading
  21. case loaded
  22. case notFound
  23. case error(String)
  24. case instrumental
  25. }
  26. var body: some View {
  27. NavigationStack {
  28. ZStack {
  29. theme.background.ignoresSafeArea()
  30. VStack(spacing: 0) {
  31. if showLyrics {
  32. // Lyrics mode: scrollable lyrics panel
  33. lyricsPanel
  34. .transition(.opacity)
  35. } else {
  36. // Normal mode: artwork
  37. artworkSection
  38. .padding(.top, 20)
  39. Spacer(minLength: 16)
  40. }
  41. // Track info
  42. trackInfoSection
  43. .padding(.horizontal, 24)
  44. Spacer(minLength: showLyrics ? 8 : 16)
  45. // Waveform (hide in lyrics mode)
  46. if !showLyrics && playerVM.showingWaveform && !playerVM.waveformSamples.isEmpty {
  47. WaveformView()
  48. .frame(height: 60)
  49. .padding(.horizontal, 24)
  50. }
  51. // Seekbar + time
  52. seekbarSection
  53. .padding(.horizontal, 24)
  54. .padding(.top, 12)
  55. // Transport controls
  56. transportSection
  57. .padding(.top, 16)
  58. // Extra controls
  59. extraControlsSection
  60. .padding(.top, 8)
  61. .padding(.bottom, 20)
  62. }
  63. }
  64. .toolbar {
  65. ToolbarItem(placement: .topBarLeading) {
  66. Button {
  67. dismiss()
  68. } label: {
  69. Image(systemName: "chevron.down")
  70. .font(.title3)
  71. .foregroundStyle(theme.secondaryText)
  72. }
  73. .accessibilityIdentifier("nowPlayingDismiss")
  74. }
  75. ToolbarItem(placement: .topBarTrailing) {
  76. HStack(spacing: 12) {
  77. // Queue button
  78. Button {
  79. playerVM.showQueue = true
  80. } label: {
  81. Image(systemName: "list.bullet")
  82. .foregroundStyle(theme.secondaryText)
  83. }
  84. .accessibilityIdentifier("queueButton")
  85. // Lyrics toggle button
  86. Button {
  87. withAnimation(.easeInOut(duration: 0.3)) {
  88. showLyrics.toggle()
  89. }
  90. if showLyrics {
  91. if case .idle = lyricsState {
  92. loadLyrics()
  93. }
  94. }
  95. } label: {
  96. Image(systemName: showLyrics ? "text.quote.fill" : "text.quote")
  97. .foregroundStyle(showLyrics ? theme.accent : theme.secondaryText)
  98. }
  99. .accessibilityIdentifier("lyricsButton")
  100. // EQ button — disabled for cloud tracks (no AVAudioEngine EQ)
  101. Button {
  102. playerVM.showEQ = true
  103. } label: {
  104. Image(systemName: "slider.vertical.3")
  105. .foregroundStyle(
  106. playerVM.isEQActive ? theme.accent : theme.secondaryText
  107. )
  108. }
  109. .disabled(playerVM.isCloudPlayback)
  110. .opacity(playerVM.isCloudPlayback ? 0.3 : 1.0)
  111. .accessibilityIdentifier("eqButton")
  112. Menu {
  113. if let track = playerVM.currentTrack {
  114. Button {
  115. _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
  116. } label: {
  117. Label("Add to Target Playlist", systemImage: "star.fill")
  118. }
  119. Button {
  120. playlistVM.addCuePoint(
  121. to: track,
  122. at: playerVM.currentTime,
  123. name: "Marker at \(playerVM.currentTimeFormatted)",
  124. context: modelContext
  125. )
  126. playlistVM.showStatus("Cue point added")
  127. } label: {
  128. Label("Add Cue Point Here", systemImage: "bookmark.fill")
  129. }
  130. }
  131. } label: {
  132. Image(systemName: "ellipsis.circle")
  133. .foregroundStyle(theme.secondaryText)
  134. }
  135. }
  136. }
  137. }
  138. .toolbarBackground(theme.background, for: .navigationBar)
  139. .sheet(isPresented: Bindable(playerVM).showEQ) {
  140. EQView()
  141. .environment(playerVM)
  142. .environmentObject(theme)
  143. }
  144. .onChange(of: playerVM.currentTrack?.id) { _, newID in
  145. if newID != lastLoadedTrackID {
  146. loadLyrics()
  147. }
  148. }
  149. }
  150. }
  151. // MARK: - Artwork
  152. private var artworkSection: some View {
  153. Group {
  154. if let track = playerVM.currentTrack {
  155. LargeArtwork(track: track)
  156. .frame(width: 280, height: 280)
  157. .shadow(radius: 20)
  158. .overlay(alignment: .bottomTrailing) {
  159. if track.isCloud {
  160. Image(systemName: "cloud.fill")
  161. .font(.caption)
  162. .foregroundStyle(.white)
  163. .padding(4)
  164. .background(theme.accent.opacity(0.8))
  165. .clipShape(Circle())
  166. .offset(x: -8, y: -8)
  167. }
  168. }
  169. } else if playerVM.isCloudPlayback {
  170. RoundedRectangle(cornerRadius: 16)
  171. .fill(theme.cardBackground)
  172. .frame(width: 280, height: 280)
  173. .overlay {
  174. VStack(spacing: 8) {
  175. Image(systemName: "cloud.fill")
  176. .font(.system(size: 60))
  177. .foregroundStyle(theme.accent)
  178. if playerVM.isBuffering {
  179. ProgressView()
  180. }
  181. }
  182. }
  183. } else {
  184. RoundedRectangle(cornerRadius: 16)
  185. .fill(theme.cardBackground)
  186. .frame(width: 280, height: 280)
  187. .overlay {
  188. Image(systemName: "music.note")
  189. .font(.system(size: 60))
  190. .foregroundStyle(theme.tertiaryText)
  191. }
  192. }
  193. }
  194. }
  195. // MARK: - Track Info
  196. private var trackInfoSection: some View {
  197. VStack(spacing: 4) {
  198. Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing")
  199. .font(.title2.bold())
  200. .foregroundStyle(theme.primaryText)
  201. .lineLimit(1)
  202. .accessibilityIdentifier("nowPlayingTitle")
  203. Text(playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist ?? "")
  204. .font(.body)
  205. .foregroundStyle(theme.secondaryText)
  206. .lineLimit(1)
  207. .accessibilityIdentifier("nowPlayingArtist")
  208. HStack(spacing: 12) {
  209. if let bpm = playerVM.currentTrack?.bpm {
  210. Label("\(String(format: "%.0f", bpm)) BPM", systemImage: "metronome")
  211. .font(.caption)
  212. .foregroundStyle(theme.tertiaryText)
  213. }
  214. if let key = playerVM.currentTrack?.musicalKey {
  215. Label(key, systemImage: "music.quarternote.3")
  216. .font(.caption)
  217. .foregroundStyle(theme.tertiaryText)
  218. }
  219. if let format = playerVM.currentTrack?.fileFormat {
  220. Text(format)
  221. .font(.caption.weight(.medium))
  222. .foregroundStyle(theme.tertiaryText)
  223. .padding(.horizontal, 5)
  224. .padding(.vertical, 1)
  225. .background(theme.tertiaryText.opacity(0.15))
  226. .clipShape(RoundedRectangle(cornerRadius: 3))
  227. }
  228. }
  229. }
  230. }
  231. // MARK: - Seekbar
  232. private var seekbarSection: some View {
  233. VStack(spacing: 4) {
  234. GeometryReader { geo in
  235. ZStack(alignment: .leading) {
  236. Capsule()
  237. .fill(theme.seekbarBackground)
  238. Capsule()
  239. .fill(theme.seekbarForeground)
  240. .frame(width: max(0, (isDragging ? dragProgress : playerVM.progress) * geo.size.width))
  241. }
  242. .gesture(
  243. DragGesture(minimumDistance: 0)
  244. .onChanged { value in
  245. isDragging = true
  246. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  247. }
  248. .onEnded { value in
  249. let prog = max(0, min(1, value.location.x / geo.size.width))
  250. playerVM.seekToProgress(prog)
  251. isDragging = false
  252. }
  253. )
  254. }
  255. .frame(height: 6)
  256. HStack {
  257. Text(playerVM.currentTimeFormatted)
  258. .font(.caption.monospacedDigit())
  259. .foregroundStyle(theme.secondaryText)
  260. Spacer()
  261. Text(playerVM.remainingTimeFormatted)
  262. .font(.caption.monospacedDigit())
  263. .foregroundStyle(theme.secondaryText)
  264. }
  265. }
  266. }
  267. // MARK: - Transport
  268. private var transportSection: some View {
  269. HStack(spacing: 36) {
  270. Button { playerVM.shuffleEnabled.toggle() } label: {
  271. Image(systemName: "shuffle")
  272. .font(.system(size: 18))
  273. .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
  274. .frame(width: 44, height: 44)
  275. }
  276. .buttonStyle(.plain)
  277. .accessibilityIdentifier("shuffleButton")
  278. Button { playerVM.playPrevious() } label: {
  279. Image(systemName: "backward.fill")
  280. .font(.system(size: 28))
  281. .foregroundStyle(theme.primaryText)
  282. .frame(width: 44, height: 44)
  283. }
  284. .buttonStyle(.plain)
  285. .accessibilityIdentifier("previousButton")
  286. Button { playerVM.togglePlayPause() } label: {
  287. Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill")
  288. .font(.system(size: 64))
  289. .foregroundStyle(theme.accent)
  290. }
  291. .buttonStyle(.plain)
  292. .accessibilityIdentifier("playPauseButton")
  293. Button { playerVM.playNext() } label: {
  294. Image(systemName: "forward.fill")
  295. .font(.system(size: 28))
  296. .foregroundStyle(theme.primaryText)
  297. .frame(width: 44, height: 44)
  298. }
  299. .buttonStyle(.plain)
  300. .accessibilityIdentifier("nextButton")
  301. Button {
  302. switch playerVM.repeatMode {
  303. case .off: playerVM.repeatMode = .all
  304. case .all: playerVM.repeatMode = .one
  305. case .one: playerVM.repeatMode = .off
  306. }
  307. } label: {
  308. Image(systemName: playerVM.repeatMode.icon)
  309. .font(.system(size: 18))
  310. .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
  311. .frame(width: 44, height: 44)
  312. }
  313. .buttonStyle(.plain)
  314. }
  315. }
  316. // MARK: - Extra Controls
  317. private static let mixColors: [Color] = [
  318. Color(red: 0.95, green: 0.3, blue: 0.3),
  319. Color(red: 0.3, green: 0.75, blue: 0.95),
  320. Color(red: 0.95, green: 0.75, blue: 0.2),
  321. ]
  322. private var extraControlsSection: some View {
  323. HStack(spacing: 20) {
  324. // 3 Mix buttons
  325. ForEach(0..<3, id: \.self) { slot in
  326. Button {
  327. guard let track = playerVM.currentTrack else { return }
  328. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  329. } label: {
  330. VStack(spacing: 4) {
  331. Text("\(slot + 1)")
  332. .font(.system(size: 16, weight: .bold, design: .rounded))
  333. .frame(width: 36, height: 36)
  334. .foregroundStyle(
  335. playlistVM.mixTargets[slot] != nil
  336. ? Self.mixColors[slot]
  337. : theme.tertiaryText
  338. )
  339. .background(
  340. playlistVM.mixTargets[slot] != nil
  341. ? Self.mixColors[slot].opacity(0.15)
  342. : theme.tertiaryText.opacity(0.08)
  343. )
  344. .clipShape(RoundedRectangle(cornerRadius: 8))
  345. Text(playlistVM.mixTargetName(slot))
  346. .font(.system(size: 9))
  347. .foregroundStyle(theme.tertiaryText)
  348. .lineLimit(1)
  349. }
  350. .frame(minWidth: 50, minHeight: 44)
  351. }
  352. .buttonStyle(.plain)
  353. }
  354. Spacer()
  355. // Skip backward
  356. Button { playerVM.skipBackward() } label: {
  357. VStack(spacing: 4) {
  358. Image(systemName: "gobackward.10")
  359. .font(.system(size: 20))
  360. Text("-10s")
  361. .font(.caption2)
  362. }
  363. .foregroundStyle(theme.secondaryText)
  364. .frame(minWidth: 44, minHeight: 44)
  365. }
  366. .buttonStyle(.plain)
  367. // Skip forward
  368. Button { playerVM.skipForward() } label: {
  369. VStack(spacing: 4) {
  370. Image(systemName: "goforward.10")
  371. .font(.system(size: 20))
  372. Text("+10s")
  373. .font(.caption2)
  374. }
  375. .foregroundStyle(theme.secondaryText)
  376. .frame(minWidth: 44, minHeight: 44)
  377. }
  378. .buttonStyle(.plain)
  379. }
  380. .padding(.horizontal, 24)
  381. }
  382. // MARK: - Lyrics Panel
  383. private var lyricsPanel: some View {
  384. VStack(spacing: 0) {
  385. // Lyrics header with synced/plain toggle
  386. HStack {
  387. Text("Lyrics")
  388. .font(.headline)
  389. .foregroundStyle(theme.secondaryText)
  390. Spacer()
  391. if case .loaded = lyricsState, isSynced {
  392. Button {
  393. showAsPlainText.toggle()
  394. } label: {
  395. HStack(spacing: 4) {
  396. Image(systemName: showAsPlainText ? "text.alignleft" : "waveform")
  397. .font(.system(size: 10))
  398. Text(showAsPlainText ? "Plain" : "Synced")
  399. .font(.caption)
  400. }
  401. .padding(.horizontal, 8)
  402. .padding(.vertical, 3)
  403. .background(showAsPlainText ? theme.tertiaryText.opacity(0.15) : theme.accent.opacity(0.2))
  404. .foregroundStyle(showAsPlainText ? theme.secondaryText : theme.accent)
  405. .clipShape(Capsule())
  406. }
  407. .buttonStyle(.plain)
  408. }
  409. }
  410. .padding(.horizontal, 24)
  411. .padding(.top, 12)
  412. .padding(.bottom, 4)
  413. // Lyrics content
  414. switch lyricsState {
  415. case .idle, .loading:
  416. Spacer()
  417. ProgressView()
  418. .scaleEffect(0.8)
  419. Text("Searching for lyrics…")
  420. .font(.callout)
  421. .foregroundStyle(theme.tertiaryText)
  422. .padding(.top, 8)
  423. Spacer()
  424. case .notFound:
  425. Spacer()
  426. VStack(spacing: 12) {
  427. Image(systemName: "text.page.slash")
  428. .font(.system(size: 36))
  429. .foregroundStyle(theme.tertiaryText)
  430. Text("No lyrics found")
  431. .font(.title3)
  432. .foregroundStyle(theme.secondaryText)
  433. if let track = playerVM.currentTrack {
  434. Text("\(track.artist) — \(track.title)")
  435. .font(.caption)
  436. .foregroundStyle(theme.tertiaryText)
  437. }
  438. }
  439. Spacer()
  440. case .instrumental:
  441. Spacer()
  442. VStack(spacing: 12) {
  443. Image(systemName: "pianokeys")
  444. .font(.system(size: 36))
  445. .foregroundStyle(theme.tertiaryText)
  446. Text("Instrumental")
  447. .font(.title3)
  448. .foregroundStyle(theme.secondaryText)
  449. }
  450. Spacer()
  451. case .error(let message):
  452. Spacer()
  453. VStack(spacing: 12) {
  454. Image(systemName: "exclamationmark.triangle")
  455. .font(.system(size: 36))
  456. .foregroundStyle(theme.tertiaryText)
  457. Text(message)
  458. .font(.callout)
  459. .foregroundStyle(theme.secondaryText)
  460. }
  461. Spacer()
  462. case .loaded:
  463. if isSynced && !showAsPlainText {
  464. SyncedLyricsView(
  465. lines: lyrics,
  466. currentTime: playerVM.currentTime,
  467. accent: theme.accent,
  468. secondaryText: theme.secondaryText,
  469. primaryText: theme.primaryText,
  470. onSeek: { time in playerVM.seek(to: time) }
  471. )
  472. } else {
  473. PlainLyricsView(lines: lyrics, primaryText: theme.primaryText)
  474. }
  475. }
  476. }
  477. .accessibilityIdentifier("lyricsPanel")
  478. }
  479. // MARK: - Lyrics Loading
  480. private func loadLyrics() {
  481. guard let track = playerVM.currentTrack else {
  482. lyricsState = .idle
  483. lyrics = []
  484. return
  485. }
  486. lastLoadedTrackID = track.id
  487. lyricsState = .loading
  488. lyrics = []
  489. Task {
  490. do {
  491. let result = try await LRCLIBService.shared.fetchLyrics(
  492. artist: track.artist,
  493. title: track.title,
  494. album: track.album.isEmpty ? nil : track.album,
  495. duration: track.duration
  496. )
  497. if result.isInstrumental {
  498. lyricsState = .instrumental
  499. return
  500. }
  501. if let synced = result.syncedLyrics, !synced.isEmpty {
  502. lyrics = LyricsParser.parseSynced(synced)
  503. isSynced = true
  504. lyricsState = .loaded
  505. } else if let plain = result.plainLyrics, !plain.isEmpty {
  506. lyrics = LyricsParser.parsePlain(plain)
  507. isSynced = false
  508. lyricsState = .loaded
  509. } else {
  510. lyricsState = .notFound
  511. }
  512. } catch is LyricsError {
  513. lyricsState = .notFound
  514. } catch {
  515. lyricsState = .error(error.localizedDescription)
  516. }
  517. }
  518. }
  519. }
  520. // MARK: - Large Artwork
  521. struct LargeArtwork: View {
  522. let track: Track
  523. @EnvironmentObject private var theme: AppTheme
  524. @State private var artwork: UIImage?
  525. var body: some View {
  526. Group {
  527. if let image = artwork {
  528. Image(uiImage: image)
  529. .resizable()
  530. .aspectRatio(contentMode: .fill)
  531. } else {
  532. ZStack {
  533. RoundedRectangle(cornerRadius: 16)
  534. .fill(theme.cardBackground)
  535. Image(systemName: "music.note")
  536. .font(.system(size: 60))
  537. .foregroundStyle(theme.tertiaryText)
  538. }
  539. }
  540. }
  541. .clipShape(RoundedRectangle(cornerRadius: 16))
  542. .task {
  543. let url = track.fileURL
  544. artwork = await ArtworkService.shared.artwork(for: url)
  545. }
  546. }
  547. }
  548. // MARK: - Volume Slider
  549. struct VolumeSlider: View {
  550. @Environment(PlayerViewModel.self) private var playerVM
  551. @EnvironmentObject private var theme: AppTheme
  552. var body: some View {
  553. VStack(spacing: 4) {
  554. Image(systemName: volumeIcon)
  555. .font(.system(size: 20))
  556. .foregroundStyle(theme.secondaryText)
  557. Text("\(Int(playerVM.volume * 100))%")
  558. .font(.caption2)
  559. .foregroundStyle(theme.tertiaryText)
  560. }
  561. .onTapGesture {
  562. // Toggle between mute and previous volume
  563. if playerVM.volume > 0 {
  564. playerVM.volume = 0
  565. } else {
  566. playerVM.volume = 0.8
  567. }
  568. }
  569. }
  570. private var volumeIcon: String {
  571. if playerVM.volume == 0 { return "speaker.slash" }
  572. if playerVM.volume < 0.3 { return "speaker.wave.1" }
  573. if playerVM.volume < 0.7 { return "speaker.wave.2" }
  574. return "speaker.wave.3"
  575. }
  576. }
  577. // MARK: - Synced Lyrics View (auto-scrolling, highlighted)
  578. struct SyncedLyricsView: View {
  579. let lines: [LyricsParser.LyricLine]
  580. let currentTime: TimeInterval
  581. let accent: Color
  582. let secondaryText: Color
  583. let primaryText: Color
  584. let onSeek: (TimeInterval) -> Void
  585. @State private var currentIndex: Int?
  586. var body: some View {
  587. ScrollViewReader { proxy in
  588. ScrollView {
  589. LazyVStack(alignment: .leading, spacing: 8) {
  590. Spacer()
  591. .frame(height: 20)
  592. ForEach(Array(lines.enumerated()), id: \.element.id) { index, line in
  593. let isCurrent = index == currentIndex
  594. Text(line.text.isEmpty ? "♪" : line.text)
  595. .font(.system(size: isCurrent ? 20 : 17, weight: isCurrent ? .semibold : .regular))
  596. .foregroundStyle(isCurrent ? accent : (index < (currentIndex ?? 0) ? secondaryText : primaryText))
  597. .opacity(isCurrent ? 1.0 : (index < (currentIndex ?? 0) ? 0.5 : 0.7))
  598. .padding(.horizontal, 24)
  599. .padding(.vertical, 2)
  600. .id(line.id)
  601. .onTapGesture {
  602. onSeek(line.timestamp)
  603. }
  604. .animation(.easeInOut(duration: 0.3), value: isCurrent)
  605. }
  606. Spacer()
  607. .frame(height: 60)
  608. }
  609. }
  610. .onChange(of: currentTime) { _, time in
  611. let newIndex = LyricsParser.currentLineIndex(in: lines, at: time)
  612. if newIndex != currentIndex {
  613. currentIndex = newIndex
  614. if let idx = newIndex, idx < lines.count {
  615. withAnimation(.easeInOut(duration: 0.4)) {
  616. proxy.scrollTo(lines[idx].id, anchor: .center)
  617. }
  618. }
  619. }
  620. }
  621. .onAppear {
  622. currentIndex = LyricsParser.currentLineIndex(in: lines, at: currentTime)
  623. if let idx = currentIndex, idx < lines.count {
  624. proxy.scrollTo(lines[idx].id, anchor: .center)
  625. }
  626. }
  627. }
  628. }
  629. }
  630. // MARK: - Plain Lyrics View (no timestamps)
  631. struct PlainLyricsView: View {
  632. let lines: [LyricsParser.LyricLine]
  633. let primaryText: Color
  634. var body: some View {
  635. ScrollView {
  636. LazyVStack(alignment: .leading, spacing: 6) {
  637. Spacer()
  638. .frame(height: 16)
  639. ForEach(lines) { line in
  640. Text(line.text.isEmpty ? " " : line.text)
  641. .font(.system(size: 16))
  642. .foregroundStyle(line.text.isEmpty ? .clear : primaryText.opacity(0.8))
  643. .padding(.horizontal, 24)
  644. }
  645. Spacer()
  646. .frame(height: 40)
  647. }
  648. }
  649. }
  650. }