import SwiftUI /// Full-screen Now Playing view with large artwork, waveform, transport controls, and quick-add. struct NowPlayingView: View { @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var isDragging = false @State private var dragProgress: Double = 0 // Lyrics state @State private var showLyrics = false @State private var lyrics: [LyricsParser.LyricLine] = [] @State private var lyricsState: LyricsState = .idle @State private var isSynced = false @State private var lastLoadedTrackID: UUID? @State private var showAsPlainText = false enum LyricsState { case idle case loading case loaded case notFound case error(String) case instrumental } var body: some View { NavigationStack { ZStack { theme.background.ignoresSafeArea() VStack(spacing: 0) { if showLyrics { // Lyrics mode: scrollable lyrics panel lyricsPanel .transition(.opacity) } else { // Normal mode: artwork artworkSection .padding(.top, 20) Spacer(minLength: 16) } // Track info trackInfoSection .padding(.horizontal, 24) Spacer(minLength: showLyrics ? 8 : 16) // Waveform (hide in lyrics mode) if !showLyrics && playerVM.showingWaveform && !playerVM.waveformSamples.isEmpty { WaveformView() .frame(height: 60) .padding(.horizontal, 24) } // Seekbar + time seekbarSection .padding(.horizontal, 24) .padding(.top, 12) // Transport controls transportSection .padding(.top, 16) // Extra controls extraControlsSection .padding(.top, 8) .padding(.bottom, 20) } } .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "chevron.down") .font(.title3) .foregroundStyle(theme.secondaryText) } .accessibilityIdentifier("nowPlayingDismiss") } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 12) { // Queue button Button { playerVM.showQueue = true } label: { Image(systemName: "list.bullet") .foregroundStyle(theme.secondaryText) } .accessibilityIdentifier("queueButton") // Lyrics toggle button Button { withAnimation(.easeInOut(duration: 0.3)) { showLyrics.toggle() } if showLyrics { if case .idle = lyricsState { loadLyrics() } } } label: { Image(systemName: showLyrics ? "text.quote.fill" : "text.quote") .foregroundStyle(showLyrics ? theme.accent : theme.secondaryText) } .accessibilityIdentifier("lyricsButton") Menu { if let track = playerVM.currentTrack { Button { _ = playlistVM.quickAddToTarget(track: track, context: modelContext) } label: { Label("Add to Target Playlist", systemImage: "star.fill") } Button { playlistVM.addCuePoint( to: track, at: playerVM.currentTime, name: "Marker at \(playerVM.currentTimeFormatted)", context: modelContext ) playlistVM.showStatus("Cue point added") } label: { Label("Add Cue Point Here", systemImage: "bookmark.fill") } } } label: { Image(systemName: "ellipsis.circle") .foregroundStyle(theme.secondaryText) } } } } .toolbarBackground(theme.background, for: .navigationBar) .onChange(of: playerVM.currentTrack?.id) { _, newID in if newID != lastLoadedTrackID { loadLyrics() } } } } // MARK: - Artwork private var artworkSection: some View { Group { if let track = playerVM.currentTrack { LargeArtwork(track: track) .frame(width: 280, height: 280) .shadow(radius: 20) .overlay(alignment: .bottomTrailing) { if track.isCloud { Image(systemName: "cloud.fill") .font(.caption) .foregroundStyle(.white) .padding(4) .background(theme.accent.opacity(0.8)) .clipShape(Circle()) .offset(x: -8, y: -8) } } } else if playerVM.isCloudPlayback { RoundedRectangle(cornerRadius: 16) .fill(theme.cardBackground) .frame(width: 280, height: 280) .overlay { VStack(spacing: 8) { Image(systemName: "cloud.fill") .font(.system(size: 60)) .foregroundStyle(theme.accent) if playerVM.isBuffering { ProgressView() } } } } else { RoundedRectangle(cornerRadius: 16) .fill(theme.cardBackground) .frame(width: 280, height: 280) .overlay { Image(systemName: "music.note") .font(.system(size: 60)) .foregroundStyle(theme.tertiaryText) } } } } // MARK: - Track Info private var trackInfoSection: some View { VStack(spacing: 4) { Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing") .font(.title2.bold()) .foregroundStyle(theme.primaryText) .lineLimit(1) .accessibilityIdentifier("nowPlayingTitle") Text(playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist ?? "") .font(.body) .foregroundStyle(theme.secondaryText) .lineLimit(1) .accessibilityIdentifier("nowPlayingArtist") HStack(spacing: 12) { if let bpm = playerVM.currentTrack?.bpm { Label("\(String(format: "%.0f", bpm)) BPM", systemImage: "metronome") .font(.caption) .foregroundStyle(theme.tertiaryText) } if let key = playerVM.currentTrack?.musicalKey { Label(key, systemImage: "music.quarternote.3") .font(.caption) .foregroundStyle(theme.tertiaryText) } if let format = playerVM.currentTrack?.fileFormat { Text(format) .font(.caption.weight(.medium)) .foregroundStyle(theme.tertiaryText) .padding(.horizontal, 5) .padding(.vertical, 1) .background(theme.tertiaryText.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 3)) } } } } // MARK: - Seekbar private var seekbarSection: some View { VStack(spacing: 4) { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(theme.seekbarBackground) Capsule() .fill(theme.seekbarForeground) .frame(width: max(0, (isDragging ? dragProgress : playerVM.progress) * geo.size.width)) } .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true dragProgress = max(0, min(1, value.location.x / geo.size.width)) } .onEnded { value in let prog = max(0, min(1, value.location.x / geo.size.width)) playerVM.seekToProgress(prog) isDragging = false } ) } .frame(height: 6) HStack { Text(playerVM.currentTimeFormatted) .font(.caption.monospacedDigit()) .foregroundStyle(theme.secondaryText) Spacer() Text(playerVM.remainingTimeFormatted) .font(.caption.monospacedDigit()) .foregroundStyle(theme.secondaryText) } } } // MARK: - Transport private var transportSection: some View { HStack(spacing: 36) { Button { playerVM.shuffleEnabled.toggle() } label: { Image(systemName: "shuffle") .font(.system(size: 18)) .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText) .frame(width: 44, height: 44) } .buttonStyle(.plain) .accessibilityIdentifier("shuffleButton") Button { playerVM.playPrevious() } label: { Image(systemName: "backward.fill") .font(.system(size: 28)) .foregroundStyle(theme.primaryText) .frame(width: 44, height: 44) } .buttonStyle(.plain) .accessibilityIdentifier("previousButton") Button { playerVM.togglePlayPause() } label: { Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 64)) .foregroundStyle(theme.accent) } .buttonStyle(.plain) .accessibilityIdentifier("playPauseButton") Button { playerVM.playNext() } label: { Image(systemName: "forward.fill") .font(.system(size: 28)) .foregroundStyle(theme.primaryText) .frame(width: 44, height: 44) } .buttonStyle(.plain) .accessibilityIdentifier("nextButton") Button { switch playerVM.repeatMode { case .off: playerVM.repeatMode = .all case .all: playerVM.repeatMode = .one case .one: playerVM.repeatMode = .off } } label: { Image(systemName: playerVM.repeatMode.icon) .font(.system(size: 18)) .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText) .frame(width: 44, height: 44) } .buttonStyle(.plain) } } // MARK: - Extra Controls private static let mixColors: [Color] = [ Color(red: 0.95, green: 0.3, blue: 0.3), Color(red: 0.3, green: 0.75, blue: 0.95), Color(red: 0.95, green: 0.75, blue: 0.2), ] private var extraControlsSection: some View { HStack(spacing: 20) { // 3 Mix buttons ForEach(0..<3, id: \.self) { slot in Button { guard let track = playerVM.currentTrack else { return } _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } label: { VStack(spacing: 4) { Text("\(slot + 1)") .font(.system(size: 16, weight: .bold, design: .rounded)) .frame(width: 36, height: 36) .foregroundStyle( playlistVM.mixTargets[slot] != nil ? Self.mixColors[slot] : theme.tertiaryText ) .background( playlistVM.mixTargets[slot] != nil ? Self.mixColors[slot].opacity(0.15) : theme.tertiaryText.opacity(0.08) ) .clipShape(RoundedRectangle(cornerRadius: 8)) Text(playlistVM.mixTargetName(slot)) .font(.system(size: 9)) .foregroundStyle(theme.tertiaryText) .lineLimit(1) } .frame(minWidth: 50, minHeight: 44) } .buttonStyle(.plain) } Spacer() // Skip backward Button { playerVM.skipBackward() } label: { VStack(spacing: 4) { Image(systemName: "gobackward.10") .font(.system(size: 20)) Text("-10s") .font(.caption2) } .foregroundStyle(theme.secondaryText) .frame(minWidth: 44, minHeight: 44) } .buttonStyle(.plain) // Skip forward Button { playerVM.skipForward() } label: { VStack(spacing: 4) { Image(systemName: "goforward.10") .font(.system(size: 20)) Text("+10s") .font(.caption2) } .foregroundStyle(theme.secondaryText) .frame(minWidth: 44, minHeight: 44) } .buttonStyle(.plain) } .padding(.horizontal, 24) } // MARK: - Lyrics Panel private var lyricsPanel: some View { VStack(spacing: 0) { // Lyrics header with synced/plain toggle HStack { Text("Lyrics") .font(.headline) .foregroundStyle(theme.secondaryText) Spacer() if case .loaded = lyricsState, isSynced { Button { showAsPlainText.toggle() } label: { HStack(spacing: 4) { Image(systemName: showAsPlainText ? "text.alignleft" : "waveform") .font(.system(size: 10)) Text(showAsPlainText ? "Plain" : "Synced") .font(.caption) } .padding(.horizontal, 8) .padding(.vertical, 3) .background(showAsPlainText ? theme.tertiaryText.opacity(0.15) : theme.accent.opacity(0.2)) .foregroundStyle(showAsPlainText ? theme.secondaryText : theme.accent) .clipShape(Capsule()) } .buttonStyle(.plain) } } .padding(.horizontal, 24) .padding(.top, 12) .padding(.bottom, 4) // Lyrics content switch lyricsState { case .idle, .loading: Spacer() ProgressView() .scaleEffect(0.8) Text("Searching for lyrics…") .font(.callout) .foregroundStyle(theme.tertiaryText) .padding(.top, 8) Spacer() case .notFound: Spacer() VStack(spacing: 12) { Image(systemName: "text.page.slash") .font(.system(size: 36)) .foregroundStyle(theme.tertiaryText) Text("No lyrics found") .font(.title3) .foregroundStyle(theme.secondaryText) if let track = playerVM.currentTrack { Text("\(track.artist) — \(track.title)") .font(.caption) .foregroundStyle(theme.tertiaryText) } } Spacer() case .instrumental: Spacer() VStack(spacing: 12) { Image(systemName: "pianokeys") .font(.system(size: 36)) .foregroundStyle(theme.tertiaryText) Text("Instrumental") .font(.title3) .foregroundStyle(theme.secondaryText) } Spacer() case .error(let message): Spacer() VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 36)) .foregroundStyle(theme.tertiaryText) Text(message) .font(.callout) .foregroundStyle(theme.secondaryText) } Spacer() case .loaded: if isSynced && !showAsPlainText { SyncedLyricsView( lines: lyrics, currentTime: playerVM.currentTime, accent: theme.accent, secondaryText: theme.secondaryText, primaryText: theme.primaryText, onSeek: { time in playerVM.seek(to: time) } ) } else { PlainLyricsView(lines: lyrics, primaryText: theme.primaryText) } } } .accessibilityIdentifier("lyricsPanel") } // MARK: - Lyrics Loading private func loadLyrics() { guard let track = playerVM.currentTrack else { lyricsState = .idle lyrics = [] return } lastLoadedTrackID = track.id lyricsState = .loading lyrics = [] Task { do { let result = try await LRCLIBService.shared.fetchLyrics( artist: track.artist, title: track.title, album: track.album.isEmpty ? nil : track.album, duration: track.duration ) if result.isInstrumental { lyricsState = .instrumental return } if let synced = result.syncedLyrics, !synced.isEmpty { lyrics = LyricsParser.parseSynced(synced) isSynced = true lyricsState = .loaded } else if let plain = result.plainLyrics, !plain.isEmpty { lyrics = LyricsParser.parsePlain(plain) isSynced = false lyricsState = .loaded } else { lyricsState = .notFound } } catch is LyricsError { lyricsState = .notFound } catch { lyricsState = .error(error.localizedDescription) } } } } // MARK: - Large Artwork struct LargeArtwork: View { let track: Track @EnvironmentObject private var theme: AppTheme @State private var artwork: UIImage? var body: some View { Group { if let image = artwork { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) } else { ZStack { RoundedRectangle(cornerRadius: 16) .fill(theme.cardBackground) Image(systemName: "music.note") .font(.system(size: 60)) .foregroundStyle(theme.tertiaryText) } } } .clipShape(RoundedRectangle(cornerRadius: 16)) .task { let url = track.fileURL artwork = await ArtworkService.shared.artwork(for: url) } } } // MARK: - Volume Slider struct VolumeSlider: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { VStack(spacing: 4) { Image(systemName: volumeIcon) .font(.system(size: 20)) .foregroundStyle(theme.secondaryText) Text("\(Int(playerVM.volume * 100))%") .font(.caption2) .foregroundStyle(theme.tertiaryText) } .onTapGesture { // Toggle between mute and previous volume if playerVM.volume > 0 { playerVM.volume = 0 } else { playerVM.volume = 0.8 } } } private var volumeIcon: String { if playerVM.volume == 0 { return "speaker.slash" } if playerVM.volume < 0.3 { return "speaker.wave.1" } if playerVM.volume < 0.7 { return "speaker.wave.2" } return "speaker.wave.3" } } // MARK: - Synced Lyrics View (auto-scrolling, highlighted) struct SyncedLyricsView: View { let lines: [LyricsParser.LyricLine] let currentTime: TimeInterval let accent: Color let secondaryText: Color let primaryText: Color let onSeek: (TimeInterval) -> Void @State private var currentIndex: Int? var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { Spacer() .frame(height: 20) ForEach(Array(lines.enumerated()), id: \.element.id) { index, line in let isCurrent = index == currentIndex Text(line.text.isEmpty ? "♪" : line.text) .font(.system(size: isCurrent ? 20 : 17, weight: isCurrent ? .semibold : .regular)) .foregroundStyle(isCurrent ? accent : (index < (currentIndex ?? 0) ? secondaryText : primaryText)) .opacity(isCurrent ? 1.0 : (index < (currentIndex ?? 0) ? 0.5 : 0.7)) .padding(.horizontal, 24) .padding(.vertical, 2) .id(line.id) .onTapGesture { onSeek(line.timestamp) } .animation(.easeInOut(duration: 0.3), value: isCurrent) } Spacer() .frame(height: 60) } } .onChange(of: currentTime) { _, time in let newIndex = LyricsParser.currentLineIndex(in: lines, at: time) if newIndex != currentIndex { currentIndex = newIndex if let idx = newIndex, idx < lines.count { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(lines[idx].id, anchor: .center) } } } } .onAppear { currentIndex = LyricsParser.currentLineIndex(in: lines, at: currentTime) if let idx = currentIndex, idx < lines.count { proxy.scrollTo(lines[idx].id, anchor: .center) } } } } } // MARK: - Plain Lyrics View (no timestamps) struct PlainLyricsView: View { let lines: [LyricsParser.LyricLine] let primaryText: Color var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 6) { Spacer() .frame(height: 16) ForEach(lines) { line in Text(line.text.isEmpty ? " " : line.text) .font(.system(size: 16)) .foregroundStyle(line.text.isEmpty ? .clear : primaryText.opacity(0.8)) .padding(.horizontal, 24) } Spacer() .frame(height: 40) } } } }