| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- 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)
- }
- }
- }
- }
|