import SwiftUI /// Tidal-inspired Now Playing window — large artwork, track info, synced lyrics. struct NowPlayingView: View { enum DisplayMode { case inline // Embedded in main window (Apple Music style, default) case floating // Separate window (Tidal style) } let displayMode: DisplayMode @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @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 init(displayMode: DisplayMode = .inline) { self.displayMode = displayMode } enum LyricsState { case idle case loading case loaded case notFound case error(String) case instrumental } var body: some View { VStack(spacing: 0) { if displayMode == .inline { inlineToolbar } HStack(spacing: 0) { // Left side: Artwork + track info + progress leftPanel .frame(minWidth: 280, idealWidth: 360, maxWidth: 500) Divider() // Right side: Lyrics lyricsPanel .frame(minWidth: 250, idealWidth: 450) } } .frame(minWidth: displayMode == .floating ? 650 : nil, minHeight: displayMode == .floating ? 500 : nil) .background(Color(nsColor: .windowBackgroundColor)) .onChange(of: playerVM.currentTrack?.id) { _, newID in if newID != lastLoadedTrackID { loadLyrics() } } .onAppear { loadLyrics() } } // MARK: - Inline Toolbar private var inlineToolbar: some View { HStack { Button { NotificationCenter.default.post(name: .closeInlineNowPlaying, object: nil) } label: { HStack(spacing: 4) { Image(systemName: "chevron.down") .font(.system(size: 12, weight: .semibold)) Text("Now Playing") .font(.system(size: 13, weight: .medium)) } .foregroundStyle(.secondary) } .buttonStyle(.plain) Spacer() Button { NotificationCenter.default.post(name: .popOutNowPlaying, object: nil) } label: { Image(systemName: "arrow.up.forward.square") .font(.system(size: 14)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Open in separate window") } .padding(.horizontal, 16) .padding(.vertical, 8) } // MARK: - Left Panel private var leftPanel: some View { VStack(spacing: 0) { Spacer(minLength: 24) // Album artwork artworkSection .padding(.horizontal, 32) Spacer(minLength: 20) // Track info trackInfoSection .padding(.horizontal, 24) Spacer(minLength: 16) // Progress bar progressSection .padding(.horizontal, 24) Spacer(minLength: 12) // Transport controls transportSection Spacer(minLength: 16) } } private var artworkSection: some View { Group { if let track = playerVM.currentTrack { ArtworkView(track: track, size: 320) .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 8) } else { ZStack { RoundedRectangle(cornerRadius: 12) .fill(.quaternary) Image(systemName: "music.note") .font(.system(size: 80)) .foregroundStyle(.tertiary) } .frame(width: 320, height: 320) } } } private var trackInfoSection: some View { VStack(spacing: 4) { Text(playerVM.currentTrack?.title ?? "Not Playing") .font(.title2) .fontWeight(.semibold) .lineLimit(2) .multilineTextAlignment(.center) Text(playerVM.currentTrack?.artist ?? "") .font(.body) .foregroundStyle(.secondary) .lineLimit(1) if let album = playerVM.currentTrack?.album, !album.isEmpty { Text(album) .font(.callout) .foregroundStyle(.tertiary) .lineLimit(1) } } } private var progressSection: some View { VStack(spacing: 4) { NowPlayingSeekBar() HStack { Text(playerVM.currentTimeFormatted) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) Spacer() Text(playerVM.remainingTimeFormatted) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) } } } private var transportSection: some View { HStack(spacing: 16) { // Shuffle Button { playerVM.shuffleEnabled.toggle() } label: { Image(systemName: "shuffle") .font(.system(size: 15)) .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : .secondary) } .buttonStyle(.plain) // Previous Button { playerVM.playPrevious() } label: { Image(systemName: "backward.fill") .font(.system(size: 22)) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) // Play/Pause Button { playerVM.togglePlayPause() } label: { Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 44)) .foregroundStyle(theme.accent) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) // Next Button { playerVM.playNext() } label: { Image(systemName: "forward.fill") .font(.system(size: 22)) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) // Repeat Button { switch playerVM.repeatMode { case .off: playerVM.repeatMode = .all case .all: playerVM.repeatMode = .one case .one: playerVM.repeatMode = .off } } label: { Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat") .font(.system(size: 15)) .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : .secondary) } .buttonStyle(.plain) } } // MARK: - Lyrics Panel private var lyricsPanel: some View { VStack(spacing: 0) { // Lyrics header HStack { Text("Lyrics") .font(.headline) .foregroundStyle(.secondary) 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 ? Color.gray.opacity(0.15) : theme.accent.opacity(0.2)) .foregroundStyle(showAsPlainText ? .secondary : theme.accent) .clipShape(Capsule()) } .buttonStyle(.plain) .help(showAsPlainText ? "Switch to synced lyrics" : "Switch to plain text") } } .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 8) // Lyrics content switch lyricsState { case .idle, .loading: Spacer() ProgressView() .scaleEffect(0.8) Text("Searching for lyrics…") .font(.callout) .foregroundStyle(.tertiary) .padding(.top, 8) Spacer() case .notFound: Spacer() VStack(spacing: 12) { Image(systemName: "text.page.slash") .font(.system(size: 36)) .foregroundStyle(.tertiary) Text("No lyrics found") .font(.title3) .foregroundStyle(.secondary) if let track = playerVM.currentTrack { Text("\(track.artist) — \(track.title)") .font(.callout) .foregroundStyle(.tertiary) } } Spacer() case .instrumental: Spacer() VStack(spacing: 12) { Image(systemName: "pianokeys") .font(.system(size: 36)) .foregroundStyle(.tertiary) Text("Instrumental") .font(.title3) .foregroundStyle(.secondary) } Spacer() case .error(let message): Spacer() VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 36)) .foregroundStyle(.tertiary) Text(message) .font(.callout) .foregroundStyle(.secondary) } Spacer() case .loaded: if isSynced && !showAsPlainText { SyncedLyricsView( lines: lyrics, currentTime: playerVM.currentTime, accent: theme.accent, onSeek: { time in playerVM.seek(to: time) } ) } else { PlainLyricsView(lines: lyrics) } } } } // 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: - Seekbar for Now Playing private struct NowPlayingSeekBar: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @State private var isDragging = false @State private var dragProgress: Double = 0 var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { // Background track Capsule() .fill(Color.gray.opacity(0.2)) // Progress fill Capsule() .fill(theme.accent) .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: 4) .contentShape(Rectangle().inset(by: -8)) } } // MARK: - Synced Lyrics View (auto-scrolling, highlighted) struct SyncedLyricsView: View { let lines: [LyricsParser.LyricLine] let currentTime: TimeInterval let accent: Color let onSeek: (TimeInterval) -> Void @State private var currentIndex: Int? var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { // Top padding Spacer() .frame(height: 40) 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) ? .secondary : .primary)) .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) } // Bottom padding Spacer() .frame(height: 120) } } .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] 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 : .primary.opacity(0.8)) .padding(.horizontal, 24) } Spacer() .frame(height: 60) } } } }