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