From 361f705d79e4cef4e658e80de3b61135e5f4b4cf Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 4 Apr 2026 18:02:14 -0700 Subject: [PATCH] Auto-commit before update (2026-04-04 18:02) --- iOS/Views/NowPlaying/NowPlayingView.swift | 3328 +++++++++++---------- 1 file changed, 1678 insertions(+), 1650 deletions(-) diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index d32dbfb..59ac096 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -8,1784 +8,1812 @@ import MusicKit // MARK: - Album Art Color Extractor class AlbumColorExtractor: ObservableObject { - static let shared = AlbumColorExtractor() +static let shared = AlbumColorExtractor() + +``` +@Published var primaryColor: Color = .pink +@Published var secondaryColor: Color = .blue +@Published var isLoaded = false +@Published var currentImage: UIImage? +/// UIKit status bar style derived from album brightness. +/// Kept separate from preferredColorScheme so NowPlayingView stays dark-mode +/// while only the status bar icons flip for very bright album art. +@Published var preferredStatusBarStyle: UIStatusBarStyle = .lightContent + +private var lastCoverArtId: String? +private var coverStoreObserver: AnyCancellable? + +private init() { + // Re-extract colors when custom album cover changes + coverStoreObserver = AlbumCoverStore.shared.$updateTrigger + .dropFirst() + .sink { [weak self] _ in + guard let self = self, let id = self.lastCoverArtId else { return } + self.lastCoverArtId = nil // force re-extract + self.extract(from: id) + } +} + +func extract(from coverArtId: String?) { + guard let id = coverArtId, id != lastCoverArtId else { return } + lastCoverArtId = id - @Published var primaryColor: Color = .pink - @Published var secondaryColor: Color = .blue - @Published var isLoaded = false - @Published var currentImage: UIImage? - /// UIKit status bar style derived from album brightness. - /// Kept separate from preferredColorScheme so NowPlayingView stays dark-mode - /// while only the status bar icons flip for very bright album art. - @Published var preferredStatusBarStyle: UIStatusBarStyle = .lightContent - - private var lastCoverArtId: String? - private var coverStoreObserver: AnyCancellable? - - private init() { - // Re-extract colors when custom album cover changes - coverStoreObserver = AlbumCoverStore.shared.$updateTrigger - .dropFirst() - .sink { [weak self] _ in - guard let self = self, let id = self.lastCoverArtId else { return } - self.lastCoverArtId = nil // force re-extract - self.extract(from: id) - } + // Check user-set custom cover first + if let customImage = AlbumCoverStore.shared.loadCover(for: id) { + let colors = Self.dominantColors(from: customImage) + self.primaryColor = colors.0 + self.secondaryColor = colors.1 + self.currentImage = customImage + self.isLoaded = true + self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) + return } - func extract(from coverArtId: String?) { - guard let id = coverArtId, id != lastCoverArtId else { return } - lastCoverArtId = id - - // Check user-set custom cover first - if let customImage = AlbumCoverStore.shared.loadCover(for: id) { - let colors = Self.dominantColors(from: customImage) - self.primaryColor = colors.0 - self.secondaryColor = colors.1 - self.currentImage = customImage - self.isLoaded = true - self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) - return - } - - guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 100) else { return } - - // Check cache - if let cached = ImageCache.shared.cachedImage(for: url) { - let colors = Self.dominantColors(from: cached) - self.primaryColor = colors.0 - self.secondaryColor = colors.1 - self.currentImage = cached - self.isLoaded = true - self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) - return - } - - Task { - do { - let (data, _) = try await URLSession.shared.data(from: url) - guard let image = UIImage(data: data) else { return } - ImageCache.shared.store(image, for: url) - let colors = Self.dominantColors(from: image) - await MainActor.run { - withAnimation(.easeInOut(duration: 1.0)) { - self.primaryColor = colors.0 - self.secondaryColor = colors.1 - self.currentImage = image - self.isLoaded = true - } - self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) + guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 100) else { return } + + // Check cache + if let cached = ImageCache.shared.cachedImage(for: url) { + let colors = Self.dominantColors(from: cached) + self.primaryColor = colors.0 + self.secondaryColor = colors.1 + self.currentImage = cached + self.isLoaded = true + self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) + return + } + + Task { + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let image = UIImage(data: data) else { return } + ImageCache.shared.store(image, for: url) + let colors = Self.dominantColors(from: image) + await MainActor.run { + withAnimation(.easeInOut(duration: 1.0)) { + self.primaryColor = colors.0 + self.secondaryColor = colors.1 + self.currentImage = image + self.isLoaded = true } - } catch { } + self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0) + } + } catch { } + } +} + +/// Derives UIStatusBarStyle from a dominant album color. +/// High threshold (0.88) because the NowPlaying background always applies +/// Color.black.opacity(0.4) over the art — only genuinely white/near-white +/// albums ever need dark icons. +private static func statusBarStyle(for color: Color) -> UIStatusBarStyle { + var brightness: CGFloat = 0 + UIColor(color).getHue(nil, saturation: nil, brightness: &brightness, alpha: nil) + return brightness > 0.88 ? .darkContent : .lightContent +} + +static func dominantColors(from image: UIImage) -> (Color, Color) { + guard let cgImage = image.cgImage else { return (.pink, .blue) } + + let width = min(cgImage.width, 50) + let height = min(cgImage.height, 50) + let bytesPerPixel = 4 + let bytesPerRow = width * bytesPerPixel + var pixelData = [UInt8](repeating: 0, count: width * height * bytesPerPixel) + + guard let context = CGContext( + data: &pixelData, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return (.pink, .blue) } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + // Sample pixels and bucket by hue + var colorBuckets: [(r: CGFloat, g: CGFloat, b: CGFloat, count: Int)] = Array(repeating: (0, 0, 0, 0), count: 12) + + let totalPixels = width * height + let step = max(1, totalPixels / 200) // sample ~200 pixels + + for i in stride(from: 0, to: totalPixels, by: step) { + let offset = i * bytesPerPixel + let r = CGFloat(pixelData[offset]) / 255.0 + let g = CGFloat(pixelData[offset + 1]) / 255.0 + let b = CGFloat(pixelData[offset + 2]) / 255.0 + + // Skip very dark or very light pixels + let brightness = (r + g + b) / 3.0 + if brightness < 0.1 || brightness > 0.9 { continue } + + // Skip low saturation (grayish) + let maxC = max(r, g, b) + let minC = min(r, g, b) + let saturation = maxC > 0 ? (maxC - minC) / maxC : 0 + if saturation < 0.15 { continue } + + // Bucket by hue (12 buckets = 30° each) + var hue: CGFloat = 0 + if maxC == minC { + hue = 0 + } else if maxC == r { + hue = (g - b) / (maxC - minC) + if hue < 0 { hue += 6 } + } else if maxC == g { + hue = 2 + (b - r) / (maxC - minC) + } else { + hue = 4 + (r - g) / (maxC - minC) + } + let bucket = Int(hue / 6.0 * 12.0) % 12 + + colorBuckets[bucket].r += r + colorBuckets[bucket].g += g + colorBuckets[bucket].b += b + colorBuckets[bucket].count += 1 + } + + // Sort buckets by count, pick top 2 + let sorted = colorBuckets.enumerated() + .filter { $0.element.count > 0 } + .sorted { $0.element.count > $1.element.count } + + func colorFromBucket(_ b: (r: CGFloat, g: CGFloat, b: CGFloat, count: Int)) -> Color { + let c = CGFloat(b.count) + return Color(red: b.r / c, green: b.g / c, blue: b.b / c) + } + + let primary = sorted.count > 0 ? colorFromBucket(sorted[0].element) : Color.pink + // Pick secondary that's at least 2 buckets away from primary for contrast + var secondary = Color.blue + if sorted.count > 1 { + let primaryIdx = sorted[0].offset + for s in sorted.dropFirst() { + let dist = min(abs(s.offset - primaryIdx), 12 - abs(s.offset - primaryIdx)) + if dist >= 2 { + secondary = colorFromBucket(s.element) + break + } + } + if secondary == .blue && sorted.count > 1 { + secondary = colorFromBucket(sorted[1].element) } } - /// Derives UIStatusBarStyle from a dominant album color. - /// High threshold (0.88) because the NowPlaying background always applies - /// Color.black.opacity(0.4) over the art — only genuinely white/near-white - /// albums ever need dark icons. - private static func statusBarStyle(for color: Color) -> UIStatusBarStyle { - var brightness: CGFloat = 0 - UIColor(color).getHue(nil, saturation: nil, brightness: &brightness, alpha: nil) - return brightness > 0.88 ? .darkContent : .lightContent - } - - static func dominantColors(from image: UIImage) -> (Color, Color) { - guard let cgImage = image.cgImage else { return (.pink, .blue) } - - let width = min(cgImage.width, 50) - let height = min(cgImage.height, 50) - let bytesPerPixel = 4 - let bytesPerRow = width * bytesPerPixel - var pixelData = [UInt8](repeating: 0, count: width * height * bytesPerPixel) - - guard let context = CGContext( - data: &pixelData, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { return (.pink, .blue) } - - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) - - // Sample pixels and bucket by hue - var colorBuckets: [(r: CGFloat, g: CGFloat, b: CGFloat, count: Int)] = Array(repeating: (0, 0, 0, 0), count: 12) - - let totalPixels = width * height - let step = max(1, totalPixels / 200) // sample ~200 pixels - - for i in stride(from: 0, to: totalPixels, by: step) { - let offset = i * bytesPerPixel - let r = CGFloat(pixelData[offset]) / 255.0 - let g = CGFloat(pixelData[offset + 1]) / 255.0 - let b = CGFloat(pixelData[offset + 2]) / 255.0 - - // Skip very dark or very light pixels - let brightness = (r + g + b) / 3.0 - if brightness < 0.1 || brightness > 0.9 { continue } - - // Skip low saturation (grayish) - let maxC = max(r, g, b) - let minC = min(r, g, b) - let saturation = maxC > 0 ? (maxC - minC) / maxC : 0 - if saturation < 0.15 { continue } - - // Bucket by hue (12 buckets = 30° each) - var hue: CGFloat = 0 - if maxC == minC { - hue = 0 - } else if maxC == r { - hue = (g - b) / (maxC - minC) - if hue < 0 { hue += 6 } - } else if maxC == g { - hue = 2 + (b - r) / (maxC - minC) - } else { - hue = 4 + (r - g) / (maxC - minC) - } - let bucket = Int(hue / 6.0 * 12.0) % 12 - - colorBuckets[bucket].r += r - colorBuckets[bucket].g += g - colorBuckets[bucket].b += b - colorBuckets[bucket].count += 1 - } - - // Sort buckets by count, pick top 2 - let sorted = colorBuckets.enumerated() - .filter { $0.element.count > 0 } - .sorted { $0.element.count > $1.element.count } - - func colorFromBucket(_ b: (r: CGFloat, g: CGFloat, b: CGFloat, count: Int)) -> Color { - let c = CGFloat(b.count) - return Color(red: b.r / c, green: b.g / c, blue: b.b / c) - } - - let primary = sorted.count > 0 ? colorFromBucket(sorted[0].element) : Color.pink - // Pick secondary that's at least 2 buckets away from primary for contrast - var secondary = Color.blue - if sorted.count > 1 { - let primaryIdx = sorted[0].offset - for s in sorted.dropFirst() { - let dist = min(abs(s.offset - primaryIdx), 12 - abs(s.offset - primaryIdx)) - if dist >= 2 { - secondary = colorFromBucket(s.element) - break - } - } - if secondary == .blue && sorted.count > 1 { - secondary = colorFromBucket(sorted[1].element) - } - } - - return (primary, secondary) - } + return (primary, secondary) +} +``` + } // MARK: - Now Playing View (landscape-adaptive) struct NowPlayingView: View { - @EnvironmentObject var audioPlayer: AudioPlayer - @EnvironmentObject var serverManager: ServerManager - @EnvironmentObject var offlineManager: OfflineManager - @Binding var isPresented: Bool - - @State private var isDraggingSlider = false - @State private var dragPosition: Double = 0 - @State private var showQueue = false - @State private var showVisualizerSettings = false - @State private var showPlaylistPicker = false - @State private var showGetInfo = false - @State private var showTrackEditor = false - @State private var showEllipsisMenu = false - @State private var isStarred = false - @State private var availablePlaylists: [Playlist] = [] - @State private var isRecording = false - @State private var shazamResult: String? - @State private var shazamArtworkURL: URL? - @State private var shazamPreviewURL: URL? - @State private var isShazaming = false - @State private var showShazamResult = false - @StateObject private var albumColors = AlbumColorExtractor.shared - @StateObject private var radioBuffer = RadioStreamBuffer.shared - @ObservedObject private var visSettings = VisualizerSettings.shared - - @State private var dragOffset: CGFloat = 0 - - // Local time polling — avoids @Published currentTime triggering global redraws - @State private var playbackTime: TimeInterval = 0 - @State private var playbackDuration: TimeInterval = 0 - private let timePoller = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() - - @Environment(\.horizontalSizeClass) private var hSizeClass - @Environment(\.verticalSizeClass) private var vSizeClass - - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - private var isLandscape: Bool { - vSizeClass == .compact - } - - /// Whether the current song is a radio stream - private var isRadio: Bool { - audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio" - } - - var body: some View { - GeometryReader { screenGeo in - ZStack { - visualizerLayer(geo: screenGeo) +@EnvironmentObject var audioPlayer: AudioPlayer +@EnvironmentObject var serverManager: ServerManager +@EnvironmentObject var offlineManager: OfflineManager +@Binding var isPresented: Bool - if isLandscape { - landscapeLayout - } else { - portraitLayout - } +``` +@State private var isDraggingSlider = false +@State private var dragPosition: Double = 0 +@State private var showQueue = false +@State private var showVisualizerSettings = false +@State private var showPlaylistPicker = false +@State private var showGetInfo = false +@State private var showTrackEditor = false +@State private var showEllipsisMenu = false +@State private var isStarred = false +@State private var availablePlaylists: [Playlist] = [] +@State private var isRecording = false +@State private var shazamResult: String? +@State private var shazamArtworkURL: URL? +@State private var shazamPreviewURL: URL? +@State private var isShazaming = false +@State private var showShazamResult = false +@StateObject private var albumColors = AlbumColorExtractor.shared +@StateObject private var radioBuffer = RadioStreamBuffer.shared +@ObservedObject private var visSettings = VisualizerSettings.shared + +@State private var dragOffset: CGFloat = 0 + +// Local time polling — avoids @Published currentTime triggering global redraws +@State private var playbackTime: TimeInterval = 0 +@State private var playbackDuration: TimeInterval = 0 +private let timePoller = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() + +@Environment(\.horizontalSizeClass) private var hSizeClass +@Environment(\.verticalSizeClass) private var vSizeClass + +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +private var isLandscape: Bool { + vSizeClass == .compact +} + +/// Whether the current song is a radio stream +private var isRadio: Bool { + audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio" +} + +var body: some View { + GeometryReader { screenGeo in + ZStack { + visualizerLayer(geo: screenGeo) + + if isLandscape { + landscapeLayout + } else { + portraitLayout } - } // end GeometryReader - .background { backgroundGradient } - .offset(y: dragOffset) - .gesture( - DragGesture(minimumDistance: 30, coordinateSpace: .local) - .onChanged { value in - let isVertical = abs(value.translation.height) > abs(value.translation.width) - if value.translation.height > 0 && isVertical { - dragOffset = value.translation.height + } + } // end GeometryReader + .background { backgroundGradient } + .offset(y: dragOffset) + .gesture( + DragGesture(minimumDistance: 30, coordinateSpace: .local) + .onChanged { value in + let isVertical = abs(value.translation.height) > abs(value.translation.width) + if value.translation.height > 0 && isVertical { + dragOffset = value.translation.height + } + } + .onEnded { value in + if value.translation.height > 120 || value.velocity.height > 800 { + // Dismiss — the parent ZStack transition handles the slide-out + withAnimation(.easeInOut(duration: 0.35)) { + dragOffset = 0 + isPresented = false + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dragOffset = 0 } } - .onEnded { value in - if value.translation.height > 120 || value.velocity.height > 800 { - // Dismiss — the parent ZStack transition handles the slide-out + } + ) + .preferredColorScheme(.dark) + .sheet(isPresented: $showQueue) { + if isRadio { + RadioRecordingsView(stationName: audioPlayer.currentSong?.title ?? "Radio") + } else { + QueueView() + } + } + .sheet(isPresented: $showGetInfo) { + if let song = audioPlayer.currentSong { + SongInfoSheet(song: song) + } + } + .sheet(isPresented: $showPlaylistPicker) { + AddToPlaylistSheet( + songId: audioPlayer.currentSong?.id, + playlists: availablePlaylists + ) + } + .sheet(isPresented: $showTrackEditor) { + if let song = audioPlayer.currentSong { + TrackEditorView(song: song) + } + } + .sheet(isPresented: $showVisualizerSettings) { + VisualizerSettingsView() + } + .sheet(isPresented: $showShazamResult) { + ShazamResultSheet( + title: shazamResult ?? "", + artworkURL: shazamArtworkURL, + previewURL: shazamPreviewURL + ) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showEllipsisMenu) { + ellipsisActionsSheet + .presentationDetents([.medium, .large]) + } + .onAppear { + dragOffset = 0 + isStarred = audioPlayer.currentSong?.starred != nil + albumColors.extract(from: audioPlayer.currentSong?.coverArt) + } + .onChange(of: audioPlayer.currentSong?.id) { _, _ in + isStarred = audioPlayer.currentSong?.starred != nil + albumColors.extract(from: audioPlayer.currentSong?.coverArt) + } + .onChange(of: audioPlayer.currentSong?.starred) { _, newVal in + isStarred = newVal != nil + } + .onReceive(timePoller) { _ in + playbackTime = audioPlayer.currentTime + playbackDuration = audioPlayer.duration + } +} + +// MARK: - Portrait Layout + +private var portraitLayout: some View { + VStack(spacing: 0) { + topBar + Spacer() + albumArt(size: 300) + Spacer() + songInfo + progressBar.padding(.top, 16) + transportControls.padding(.top, 20) + bottomControls.padding(.top, 12).padding(.bottom, 30) + } +} + +// MARK: - Landscape Layout + +private var landscapeLayout: some View { + GeometryReader { geo in + HStack(spacing: 0) { + // Left: Album art centered + VStack { + Spacer() + albumArt(size: min(200, geo.size.height - 40)) + Spacer() + } + .frame(width: geo.size.width * 0.4) + + // Right: Controls — padded below Dynamic Island + VStack(spacing: 6) { + HStack { + Button(action: { withAnimation(.easeInOut(duration: 0.35)) { - dragOffset = 0 isPresented = false } - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - dragOffset = 0 - } - } - } - ) - .preferredColorScheme(.dark) - .sheet(isPresented: $showQueue) { - if isRadio { - RadioRecordingsView(stationName: audioPlayer.currentSong?.title ?? "Radio") - } else { - QueueView() - } - } - .sheet(isPresented: $showGetInfo) { - if let song = audioPlayer.currentSong { - SongInfoSheet(song: song) - } - } - .sheet(isPresented: $showPlaylistPicker) { - AddToPlaylistSheet( - songId: audioPlayer.currentSong?.id, - playlists: availablePlaylists - ) - } - .sheet(isPresented: $showTrackEditor) { - if let song = audioPlayer.currentSong { - TrackEditorView(song: song) - } - } - .sheet(isPresented: $showVisualizerSettings) { - VisualizerSettingsView() - } - .sheet(isPresented: $showShazamResult) { - ShazamResultSheet( - title: shazamResult ?? "", - artworkURL: shazamArtworkURL, - previewURL: shazamPreviewURL - ) - .presentationDetents([.medium]) - } - .sheet(isPresented: $showEllipsisMenu) { - ellipsisActionsSheet - .presentationDetents([.medium, .large]) - } - .onAppear { - dragOffset = 0 - isStarred = audioPlayer.currentSong?.starred != nil - albumColors.extract(from: audioPlayer.currentSong?.coverArt) - } - .onChange(of: audioPlayer.currentSong?.id) { _, _ in - isStarred = audioPlayer.currentSong?.starred != nil - albumColors.extract(from: audioPlayer.currentSong?.coverArt) - } - .onChange(of: audioPlayer.currentSong?.starred) { _, newVal in - isStarred = newVal != nil - } - .onReceive(timePoller) { _ in - playbackTime = audioPlayer.currentTime - playbackDuration = audioPlayer.duration - } - } - - // MARK: - Portrait Layout - - private var portraitLayout: some View { - VStack(spacing: 0) { - topBar - Spacer() - albumArt(size: 300) - Spacer() - songInfo - progressBar.padding(.top, 16) - transportControls.padding(.top, 20) - bottomControls.padding(.top, 12).padding(.bottom, 30) - } - } - - // MARK: - Landscape Layout - - private var landscapeLayout: some View { - GeometryReader { geo in - HStack(spacing: 0) { - // Left: Album art centered - VStack { - Spacer() - albumArt(size: min(200, geo.size.height - 40)) - Spacer() - } - .frame(width: geo.size.width * 0.4) - - // Right: Controls — padded below Dynamic Island - VStack(spacing: 6) { - HStack { - Button(action: { - withAnimation(.easeInOut(duration: 0.35)) { - isPresented = false - } - }) { - Image(systemName: "chevron.down") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - } - Spacer() - ellipsisMenu - } - .padding(.horizontal, 16) - - songInfo - progressBar.padding(.top, 6) - transportControls.padding(.top, 8) - bottomControls.padding(.top, 4).padding(.bottom, 8) - } - .frame(width: geo.size.width * 0.6) - } - } - } - - // MARK: - Visualizer Layer - - @ViewBuilder - private func visualizerLayer(geo: GeometryProxy) -> some View { - if visSettings.enabled && visSettings.nowPlayingEnabled { - if isLandscape { - VStack(spacing: 0) { - Spacer() - MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink) - .frame(maxWidth: .infinity) - .frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)) - .allowsHitTesting(false) - } - .ignoresSafeArea() - } else { - VStack { - Spacer() - MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink) - .frame(height: geo.size.height * visSettings.nowPlayingHeightPct) - .allowsHitTesting(false) - } - .ignoresSafeArea() - } - } - } - - // MARK: - Background - - private var backgroundGradient: some View { - ZStack { - // Layer 1: Deep black base - Color.black.ignoresSafeArea() - - // Layer 2: Blurred album art with crossfade on song change - if let art = albumColors.currentImage { - GeometryReader { geo in - Image(uiImage: art) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - .blur(radius: 60, opaque: true) - .overlay(Color.black.opacity(0.4)) - } - .ignoresSafeArea() - .id(audioPlayer.currentSong?.albumId ?? audioPlayer.currentSong?.id ?? "none") - .transition(.opacity) - } - - // Layer 3: Radial vignette from dominant color - RadialGradient( - gradient: Gradient(colors: [ - albumColors.primaryColor.opacity(0.4), - Color.black.opacity(0.85) - ]), - center: .center, - startRadius: 80, - endRadius: 500 - ) - .ignoresSafeArea() - } - .animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.albumId) - } - - // MARK: - Top Bar (portrait only) - - private var topBar: some View { - HStack { - Button(action: { - withAnimation(.easeInOut(duration: 0.35)) { - isPresented = false - } - }) { - Image(systemName: "chevron.down") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) - } - .frame(width: 44, height: 44) - - Spacer() - - VStack(spacing: 2) { - Text("PLAYING FROM") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.gray) - .tracking(1) - Text(audioPlayer.currentSong?.album ?? "") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white) - .lineLimit(1) - } - .onTapGesture { - if let albumId = audioPlayer.currentSong?.albumId { - NotificationCenter.default.post( - name: .navigateToAlbum, - object: nil, - userInfo: ["albumId": albumId] - ) - } - } - - Spacer() - - ellipsisMenu - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - - private var ellipsisMenu: some View { - Button(action: { showEllipsisMenu = true }) { - Image(systemName: "ellipsis") - .font(.system(size: 18)) - .foregroundColor(.white) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - } - - /// Actions sheet — replaces Menu to avoid UIContextMenuInteraction conflicts - /// with the drag-to-dismiss gesture. Uses a custom sheet instead of confirmationDialog - /// so we can show icons and dividers. - private var ellipsisActionsSheet: some View { - NavigationStack { - ScrollView { - VStack(spacing: 16) { - // Visualizer Settings (centered, full width) - Button(action: { - showEllipsisMenu = false - showVisualizerSettings = true }) { - HStack(spacing: 8) { - Image(systemName: "waveform") - .font(.system(size: 18)) - .foregroundColor(accentPink) - Text("Visualizer Settings") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity, minHeight: 44) - .background(Color.white.opacity(0.08)) - .cornerRadius(12) - } - .padding(.horizontal, 16) - - if !isRadio { - // Row 1: Instant Mix | Add to Playlist - gridRow( - left: ("Instant Mix", "wand.and.stars", { - showEllipsisMenu = false - if let song = audioPlayer.currentSong { - audioPlayer.playInstantMix(basedOn: song) - } - }), - right: ("Add to Playlist", "text.badge.plus", { - showEllipsisMenu = false - Task { - availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] - showPlaylistPicker = true - } - }) - ) - - // Row 2: Go to Album | Go to Artist - gridRow( - left: ("Go to Album", "square.stack", { - showEllipsisMenu = false - if let albumId = audioPlayer.currentSong?.albumId { - NotificationCenter.default.post(name: .navigateToAlbum, object: nil, userInfo: ["albumId": albumId]) - } - }), - right: ("Go to Artist", "music.mic", { - showEllipsisMenu = false - if let artistId = audioPlayer.currentSong?.artistId { - NotificationCenter.default.post(name: .navigateToArtist, object: nil, userInfo: ["artistId": artistId]) - } - }) - ) - - // Row 3: Download/Remove | Send to Watch (always visible) - if let song = audioPlayer.currentSong { - let isDownloaded = offlineManager.isSongDownloaded(song.id) - let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) - gridRow( - left: isDownloaded - ? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) }) - : ("Download", "arrow.down.circle", { - showEllipsisMenu = false - if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } - }), - right: ( - isOnWatch ? "On Watch ✓" : "Send to Watch", - isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward", - { - showEllipsisMenu = false - if !isOnWatch { - _ = WatchConnectivityManager.shared.sendSongToWatch(song) - } - } - ) - ) - - // Row 3b: Favourite toggle - gridRow( - left: ( - song.starred != nil ? "Unfavourite" : "Favourite", - song.starred != nil ? "heart.slash.fill" : "heart", - { - showEllipsisMenu = false - Task { - if song.starred != nil { - try? await serverManager.client.unstar(id: song.id) - } else { - try? await serverManager.client.star(id: song.id) - } - } - } - ), - right: nil - ) - } - - Divider().padding(.horizontal, 16) - } - - // Row 4: Get Info | Edit Tags - gridRow( - left: ("Get Info", "info.circle", { - showEllipsisMenu = false - showGetInfo = true - }), - right: CompanionSettings.shared.isEnabled - ? ("Edit Tags", "tag", { - showEllipsisMenu = false - showTrackEditor = true - }) - : nil - ) - - // Playlist link if playing from one - if let pid = audioPlayer.sourcePlaylistId, - let pname = audioPlayer.sourcePlaylistName { - Button(action: { - showEllipsisMenu = false - withAnimation(.easeInOut(duration: 0.35)) { isPresented = false } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NotificationCenter.default.post(name: .navigateToPlaylist, object: nil, userInfo: ["playlistId": pid]) - } - }) { - Label("Show in \(pname)", systemImage: "music.note.list") - .font(.system(size: 14)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.white.opacity(0.08)) - .cornerRadius(12) - } - .padding(.horizontal, 16) - } - - Spacer() - } - .padding(.top, 20) - } - .background(Color(white: 0.1)) - .navigationTitle("Options") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { showEllipsisMenu = false } - .foregroundColor(accentPink) - } - } - } - } - - /// Two-button grid row for the options sheet - private func gridRow( - left: (String, String, () -> Void), - right: (String, String, () -> Void)? - ) -> some View { - HStack(spacing: 12) { - gridButton(title: left.0, icon: left.1, action: left.2) - if let r = right { - gridButton(title: r.0, icon: r.1, action: r.2) - } else { - Color.clear.frame(maxWidth: .infinity, minHeight: 70) - } - } - .padding(.horizontal, 16) - } - - private func gridButton(title: String, icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - VStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 20)) - .foregroundColor(accentPink) - Text(title) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.white) - .multilineTextAlignment(.center) - .lineLimit(2) - } - .frame(maxWidth: .infinity, minHeight: 70) - .background(Color.white.opacity(0.08)) - .cornerRadius(12) - } - } - - // MARK: - Album Art - - private func albumArt(size: CGFloat) -> some View { - Group { - if let song = audioPlayer.currentSong, - song.album == "Radio", - let img = RadioCoverStore.shared.loadCover(for: song.id) { - Image(uiImage: img) - .resizable() - .aspectRatio(contentMode: .fill) - } else { - AsyncCoverArt(coverArtId: audioPlayer.currentSong?.coverArt, size: Int(size) * 2) - } - } - .frame(width: size, height: size) - .cornerRadius(8) - .shadow(color: .black.opacity(0.5), radius: 20, y: 10) - } - - // MARK: - Song Info - - private var songInfo: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(audioPlayer.currentSong?.title ?? "Not Playing") - .font(.system(size: isLandscape ? 16 : 20, weight: .bold)) - .foregroundColor(.white) - .lineLimit(1) - Text(audioPlayer.currentSong?.artist ?? "") - .font(.system(size: isLandscape ? 13 : 16)) - .foregroundColor(accentPink) - .lineLimit(1) - .onTapGesture { - if let artistId = audioPlayer.currentSong?.artistId { - NotificationCenter.default.post( - name: .navigateToArtist, - object: nil, - userInfo: ["artistId": artistId] - ) - } - } - - // Show Shazam result if available - if let result = shazamResult { - Text(result) - .font(.system(size: isLandscape ? 11 : 13)) - .foregroundColor(.white.opacity(0.7)) - .lineLimit(2) - .transition(.opacity) - } - } - Spacer() - - if !isRadio { - // Heart/star for regular music - Button(action: toggleStar) { - Image(systemName: isStarred ? "heart.fill" : "heart") - .font(.system(size: isLandscape ? 18 : 22)) - .foregroundColor(isStarred ? accentPink : .gray) - } - } - } - .padding(.horizontal, isLandscape ? 20 : 30) - } - - // MARK: - Progress Bar - - private var progressBar: some View { - VStack(spacing: 6) { - if isRadio { - // Radio always shows LIVE indicator, buffer bar when available - radioProgressBar - } else { - // Normal song progress bar - normalProgressBar - } - } - .padding(.horizontal, isLandscape ? 20 : 30) - } - - private var normalProgressBar: some View { - VStack(spacing: 6) { - SiriSeekBar( - value: Binding( - get: { - guard playbackDuration > 0 else { return 0 } - return playbackTime / playbackDuration - }, - set: { _ in } - ), - onSeek: { pct in - isDraggingSlider = false - audioPlayer.seekToPercent(pct) - }, - onDragChanged: { pct in - isDraggingSlider = true - dragPosition = pct - } - ) - - HStack { - Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime)) - .font(.system(size: 11)).foregroundColor(.gray) - Spacer() - Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime))) - .font(.system(size: 11)).foregroundColor(.gray) - } - } - } - - private var radioProgressBar: some View { - VStack(spacing: 6) { - if radioBuffer.isHLSStream || !radioBuffer.isBuffering { - // HLS or no buffer — static thin bar, no seek - Capsule().fill(Color.white.opacity(0.15)).frame(height: 4) - } else { - // Buffered radio — scrubbable seek bar - GeometryReader { geo in - let bufferSec = radioBuffer.estimatedBufferSeconds - let maxSec = radioBuffer.maxBufferDuration - let fillPct = min(bufferSec / maxSec, 1.0) - - ZStack(alignment: .leading) { - Capsule().fill(Color.white.opacity(0.15)).frame(height: 4) - Capsule().fill(accentPink.opacity(0.35)) - .frame(width: geo.size.width * fillPct, height: 4) - - if isDraggingSlider { - Circle().fill(Color.white) - .frame(width: 14, height: 14) - .offset(x: geo.size.width * dragPosition - 7) - } - } - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { v in - isDraggingSlider = true - let pct = min(max(v.location.x / geo.size.width, 0), fillPct) - dragPosition = pct - } - .onEnded { v in - isDraggingSlider = false - let pct = min(max(v.location.x / geo.size.width, 0), fillPct) - let seekTime = maxSec * pct - audioPlayer.radioSeekBack(to: seekTime) - } - ) - } - .frame(height: 14) - - // Time labels - HStack { - if isDraggingSlider { - let scrubTime = radioBuffer.maxBufferDuration * dragPosition - Text(formatTime(scrubTime)) - .font(.system(size: 11)).foregroundColor(.gray) - } else { - Text(formatTime(radioBuffer.bufferedDuration)) - .font(.system(size: 11)).foregroundColor(.gray) + Image(systemName: "chevron.down") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) } Spacer() - if !radioBuffer.isLive { - // "Back to live" tap on time label - Button(action: { audioPlayer.radioGoLive() }) { - Text("Go Live") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(accentPink) - } - } + ellipsisMenu } + .padding(.horizontal, 16) + + songInfo + progressBar.padding(.top, 6) + transportControls.padding(.top, 8) + bottomControls.padding(.top, 4).padding(.bottom, 8) } - } - } - - // MARK: - Transport Controls - - private var transportControls: some View { - let iconSize: CGFloat = isLandscape ? 24 : 30 - let playSize: CGFloat = isLandscape ? 34 : 42 - let isRecordedRadio = isRadio && !audioPlayer.isRadioStream - let isLiveRadio = isRadio && audioPlayer.isRadioStream - - return VStack(spacing: 4) { - // LIVE indicator above transport — only for live radio streams - if isLiveRadio { - liveIndicator - .padding(.bottom, 4) - } - - HStack(spacing: 0) { - Spacer() - - if isRecordedRadio { - Button(action: { - audioPlayer.seek(to: max(0, audioPlayer.currentTime - 5)) - }) { - Image(systemName: "gobackward.5").font(.system(size: iconSize)).foregroundColor(.white) - }.frame(width: 60, height: 50) - } else if !isLiveRadio { - Button(action: { audioPlayer.previous() }) { - Image(systemName: "backward.fill").font(.system(size: iconSize)).foregroundColor(.white) - }.frame(width: 60, height: 50) - } else { - Color.clear.frame(width: 60, height: 50) - } - - Spacer() - - Button(action: { audioPlayer.togglePlayPause() }) { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: playSize)).foregroundColor(.white) - }.frame(width: 70, height: 60) - - Spacer() - - if isRecordedRadio { - Button(action: { - audioPlayer.seek(to: min(audioPlayer.duration, audioPlayer.currentTime + 5)) - }) { - Image(systemName: "goforward.5").font(.system(size: iconSize)).foregroundColor(.white) - }.frame(width: 60, height: 50) - } else if !isLiveRadio { - Button(action: { audioPlayer.next() }) { - Image(systemName: "forward.fill").font(.system(size: iconSize)).foregroundColor(.white) - }.frame(width: 60, height: 50) - } else { - Color.clear.frame(width: 60, height: 50) - } - - Spacer() - } - } - } - - /// Glowing LIVE indicator — red box when buffering active, gray when not - private var liveIndicator: some View { - Button(action: { - if radioBuffer.isBuffering && !radioBuffer.isLive { - audioPlayer.radioGoLive() - } else if !radioBuffer.isBuffering { - if let url = audioPlayer.radioStreamURL { - RadioStreamBuffer.shared.startBuffering( - url: url, - stationName: audioPlayer.currentSong?.title ?? "Radio" - ) - } - } - }) { - HStack(spacing: 5) { - Circle() - .fill(radioBuffer.isBuffering ? Color.red : Color.gray.opacity(0.5)) - .frame(width: 7, height: 7) - Text("LIVE") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(radioBuffer.isBuffering ? .white : .gray) - } - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(radioBuffer.isBuffering ? Color.red.opacity(0.25) : Color.white.opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(radioBuffer.isBuffering ? Color.red.opacity(0.6) : Color.clear, lineWidth: 1) - ) - .shadow(color: radioBuffer.isBuffering ? Color.red.opacity(0.4) : .clear, radius: 8) - } - } - - // MARK: - Bottom Controls - - private var bottomControls: some View { - HStack { - if isRadio { - // Shazam button - Button(action: shazamCurrentAudio) { - Image(systemName: isShazaming ? "waveform" : "shazam.logo") - .font(.system(size: isLandscape ? 14 : 16)) - .foregroundColor(isShazaming ? accentPink : .gray) - .symbolEffect(.pulse, isActive: isShazaming) - }.frame(width: 40, height: 40) - - Spacer() - - // Recordings list - Button(action: { showQueue = true }) { - Image(systemName: "recordingtape").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray) - }.frame(width: 40, height: 40) - - Spacer() - - AirPlayButton().frame(width: 40, height: 40) - } else { - // Normal music controls - Button(action: { audioPlayer.toggleShuffle() }) { - Image(systemName: "shuffle").font(.system(size: isLandscape ? 14 : 16)) - .foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray) - }.frame(width: 40, height: 40) - Spacer() - Button(action: { audioPlayer.cycleRepeat() }) { - Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") - .font(.system(size: isLandscape ? 14 : 16)) - .foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray) - }.frame(width: 40, height: 40) - Spacer() - Button(action: { showQueue = true }) { - Image(systemName: "list.bullet").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray) - }.frame(width: 40, height: 40) - Spacer() - AirPlayButton().frame(width: 40, height: 40) - } - } - .padding(.horizontal, isLandscape ? 20 : 30) - } - - // MARK: - Helpers - - private func formatTime(_ seconds: TimeInterval) -> String { - let secs = Int(max(0, seconds)) - return String(format: "%d:%02d", secs / 60, secs % 60) - } - - private func toggleStar() { - guard let song = audioPlayer.currentSong else { return } - let wasStarred = isStarred - isStarred.toggle() - Task { - do { - if wasStarred { - try await serverManager.client.unstar(id: song.id) - } else { - try await serverManager.client.star(id: song.id) - } - // Rebuild song with updated starred field so it persists across dismiss/reopen - await MainActor.run { - let newStarred: String? = wasStarred ? nil : "true" - let updated = Song( - id: song.id, parent: song.parent, isDir: song.isDir, - title: song.title, album: song.album, artist: song.artist, - track: song.track, year: song.year, genre: song.genre, - coverArt: song.coverArt, size: song.size, - contentType: song.contentType, suffix: song.suffix, - transcodedContentType: song.transcodedContentType, - transcodedSuffix: song.transcodedSuffix, - duration: song.duration, bitRate: song.bitRate, - path: song.path, playCount: song.playCount, - discNumber: song.discNumber, created: song.created, - albumId: song.albumId, artistId: song.artistId, - type: song.type, starred: newStarred, - bpm: song.bpm, musicBrainzId: song.musicBrainzId - ) - audioPlayer.currentSong = updated - // Also update in queue - if let idx = audioPlayer.queue.firstIndex(where: { $0.id == song.id }) { - audioPlayer.queue[idx] = updated - } - } - } catch { - await MainActor.run { isStarred = wasStarred } - } - } - } - - // MARK: - Shazam - - private func shazamCurrentAudio() { - guard !isShazaming else { return } - isShazaming = true - shazamResult = "Listening..." - shazamArtworkURL = nil - shazamPreviewURL = nil - - ShazamRecognizer.shared.recognize { result in - DispatchQueue.main.async { - isShazaming = false - if let match = result { - shazamResult = match.displayText - shazamArtworkURL = match.artworkURL - shazamPreviewURL = match.previewURL - showShazamResult = true - - // Auto-clear text after 10 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - if shazamResult == match.displayText { shazamResult = nil } - } - } else { - shazamResult = "No match found" - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - if shazamResult == "No match found" { shazamResult = nil } - } - } - } - } - } - - // MARK: - Recording - - private func toggleRecording() { - if isRecording { - _ = radioBuffer.stopRecording() - isRecording = false - } else { - radioBuffer.startRecording() - isRecording = true + .frame(width: geo.size.width * 0.6) } } } +// MARK: - Visualizer Layer + +@ViewBuilder +private func visualizerLayer(geo: GeometryProxy) -> some View { + if visSettings.enabled && visSettings.nowPlayingEnabled { + if isLandscape { + VStack(spacing: 0) { + Spacer() + MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink) + .frame(maxWidth: .infinity) + .frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)) + .allowsHitTesting(false) + } + .ignoresSafeArea() + } else { + VStack { + Spacer() + MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink) + .frame(height: geo.size.height * visSettings.nowPlayingHeightPct) + .allowsHitTesting(false) + } + .ignoresSafeArea() + } + } +} + +// MARK: - Background + +private var backgroundGradient: some View { + ZStack { + // Layer 1: Deep black base + Color.black.ignoresSafeArea() + + // Layer 2: Blurred album art with crossfade on song change + if let art = albumColors.currentImage { + GeometryReader { geo in + Image(uiImage: art) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .blur(radius: 60, opaque: true) + .overlay(Color.black.opacity(0.4)) + } + .ignoresSafeArea() + .id(audioPlayer.currentSong?.albumId ?? audioPlayer.currentSong?.id ?? "none") + .transition(.opacity) + } + + // Layer 3: Radial vignette from dominant color + RadialGradient( + gradient: Gradient(colors: [ + albumColors.primaryColor.opacity(0.4), + Color.black.opacity(0.85) + ]), + center: .center, + startRadius: 80, + endRadius: 500 + ) + .ignoresSafeArea() + } + .animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.albumId) +} + +// MARK: - Top Bar (portrait only) + +private var topBar: some View { + HStack { + Button(action: { + withAnimation(.easeInOut(duration: 0.35)) { + isPresented = false + } + }) { + Image(systemName: "chevron.down") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 44, height: 44) + + Spacer() + + VStack(spacing: 2) { + Text("PLAYING FROM") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.gray) + .tracking(1) + Text(audioPlayer.currentSong?.album ?? "") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + } + .onTapGesture { + if let albumId = audioPlayer.currentSong?.albumId { + NotificationCenter.default.post( + name: .navigateToAlbum, + object: nil, + userInfo: ["albumId": albumId] + ) + } + } + + Spacer() + + ellipsisMenu + } + .padding(.horizontal, 8) + .padding(.top, 8) +} + +private var ellipsisMenu: some View { + Button(action: { showEllipsisMenu = true }) { + Image(systemName: "ellipsis") + .font(.system(size: 18)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } +} + +/// Actions sheet — replaces Menu to avoid UIContextMenuInteraction conflicts +/// with the drag-to-dismiss gesture. Uses a custom sheet instead of confirmationDialog +/// so we can show icons and dividers. +private var ellipsisActionsSheet: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + // Visualizer Settings (centered, full width) + Button(action: { + showEllipsisMenu = false + showVisualizerSettings = true + }) { + HStack(spacing: 8) { + Image(systemName: "waveform") + .font(.system(size: 18)) + .foregroundColor(accentPink) + Text("Visualizer Settings") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, minHeight: 44) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) + } + .padding(.horizontal, 16) + + if !isRadio { + // Row 1: Instant Mix | Add to Playlist + gridRow( + left: ("Instant Mix", "wand.and.stars", { + showEllipsisMenu = false + if let song = audioPlayer.currentSong { + audioPlayer.playInstantMix(basedOn: song) + } + }), + right: ("Add to Playlist", "text.badge.plus", { + showEllipsisMenu = false + Task { + availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] + showPlaylistPicker = true + } + }) + ) + + // Row 2: Go to Album | Go to Artist + gridRow( + left: ("Go to Album", "square.stack", { + showEllipsisMenu = false + if let albumId = audioPlayer.currentSong?.albumId { + NotificationCenter.default.post(name: .navigateToAlbum, object: nil, userInfo: ["albumId": albumId]) + } + }), + right: ("Go to Artist", "music.mic", { + showEllipsisMenu = false + if let artistId = audioPlayer.currentSong?.artistId { + NotificationCenter.default.post(name: .navigateToArtist, object: nil, userInfo: ["artistId": artistId]) + } + }) + ) + + // Row 3: Download/Remove | Send to Watch (always visible) + if let song = audioPlayer.currentSong { + let isDownloaded = offlineManager.isSongDownloaded(song.id) + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) + gridRow( + left: isDownloaded + ? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) }) + : ("Download", "arrow.down.circle", { + showEllipsisMenu = false + if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } + }), + right: ( + isOnWatch ? "On Watch ✓" : "Send to Watch", + isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward", + { + showEllipsisMenu = false + if !isOnWatch { + _ = WatchConnectivityManager.shared.sendSongToWatch(song) + } + } + ) + ) + + // Row 3b: Favourite toggle + gridRow( + left: ( + song.starred != nil ? "Unfavourite" : "Favourite", + song.starred != nil ? "heart.slash.fill" : "heart", + { + showEllipsisMenu = false + Task { + if song.starred != nil { + try? await serverManager.client.unstar(id: song.id) + } else { + try? await serverManager.client.star(id: song.id) + } + } + } + ), + right: nil + ) + } + + Divider().padding(.horizontal, 16) + } + + // Row 4: Get Info | Edit Tags + gridRow( + left: ("Get Info", "info.circle", { + showEllipsisMenu = false + showGetInfo = true + }), + right: CompanionSettings.shared.isEnabled + ? ("Edit Tags", "tag", { + showEllipsisMenu = false + showTrackEditor = true + }) + : nil + ) + + // Playlist link if playing from one + if let pid = audioPlayer.sourcePlaylistId, + let pname = audioPlayer.sourcePlaylistName { + Button(action: { + showEllipsisMenu = false + withAnimation(.easeInOut(duration: 0.35)) { isPresented = false } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .navigateToPlaylist, object: nil, userInfo: ["playlistId": pid]) + } + }) { + Label("Show in \(pname)", systemImage: "music.note.list") + .font(.system(size: 14)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) + } + .padding(.horizontal, 16) + } + + Spacer() + } + .padding(.top, 20) + } + .background(Color(white: 0.1)) + .navigationTitle("Options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showEllipsisMenu = false } + .foregroundColor(accentPink) + } + } + } +} + +/// Two-button grid row for the options sheet +private func gridRow( + left: (String, String, () -> Void), + right: (String, String, () -> Void)? +) -> some View { + HStack(spacing: 12) { + gridButton(title: left.0, icon: left.1, action: left.2) + if let r = right { + gridButton(title: r.0, icon: r.1, action: r.2) + } else { + Color.clear.frame(maxWidth: .infinity, minHeight: 70) + } + } + .padding(.horizontal, 16) +} + +private func gridButton(title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(accentPink) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .frame(maxWidth: .infinity, minHeight: 70) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) + } +} + +// MARK: - Album Art + +private func albumArt(size: CGFloat) -> some View { + Group { + if let song = audioPlayer.currentSong, + song.album == "Radio", + let img = RadioCoverStore.shared.loadCover(for: song.id) { + Image(uiImage: img) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + AsyncCoverArt(coverArtId: audioPlayer.currentSong?.coverArt, size: Int(size) * 2) + } + } + .frame(width: size, height: size) + .cornerRadius(8) + .shadow(color: .black.opacity(0.5), radius: 20, y: 10) +} + +// MARK: - Song Info + +private var songInfo: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(audioPlayer.currentSong?.title ?? "Not Playing") + .font(.system(size: isLandscape ? 16 : 20, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Text(audioPlayer.currentSong?.artist ?? "") + .font(.system(size: isLandscape ? 13 : 16)) + .foregroundColor(accentPink) + .lineLimit(1) + .onTapGesture { + if let artistId = audioPlayer.currentSong?.artistId { + NotificationCenter.default.post( + name: .navigateToArtist, + object: nil, + userInfo: ["artistId": artistId] + ) + } + } + + // Show Shazam result if available + if let result = shazamResult { + Text(result) + .font(.system(size: isLandscape ? 11 : 13)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(2) + .transition(.opacity) + } + } + Spacer() + + if !isRadio { + // Heart/star for regular music + Button(action: toggleStar) { + Image(systemName: isStarred ? "heart.fill" : "heart") + .font(.system(size: isLandscape ? 18 : 22)) + .foregroundColor(isStarred ? accentPink : .gray) + } + } + } + .padding(.horizontal, isLandscape ? 20 : 30) +} + +// MARK: - Progress Bar + +private var progressBar: some View { + VStack(spacing: 6) { + if isRadio { + // Radio always shows LIVE indicator, buffer bar when available + radioProgressBar + } else { + // Normal song progress bar + normalProgressBar + } + } + .padding(.horizontal, isLandscape ? 20 : 30) +} + +private var normalProgressBar: some View { + VStack(spacing: 6) { + SiriSeekBar( + value: Binding( + get: { + guard playbackDuration > 0 else { return 0 } + return playbackTime / playbackDuration + }, + set: { _ in } + ), + onSeek: { pct in + isDraggingSlider = false + audioPlayer.seekToPercent(pct) + }, + onDragChanged: { pct in + isDraggingSlider = true + dragPosition = pct + } + ) + + HStack { + Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime)) + .font(.system(size: 11)).foregroundColor(.gray) + Spacer() + Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime))) + .font(.system(size: 11)).foregroundColor(.gray) + } + } +} + +private var radioProgressBar: some View { + VStack(spacing: 6) { + if radioBuffer.isHLSStream || !radioBuffer.isBuffering { + // HLS or no buffer — static thin bar, no seek + Capsule().fill(Color.white.opacity(0.15)).frame(height: 4) + } else { + // Buffered radio — scrubbable seek bar + GeometryReader { geo in + let bufferSec = radioBuffer.estimatedBufferSeconds + let maxSec = radioBuffer.maxBufferDuration + let fillPct = min(bufferSec / maxSec, 1.0) + + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.15)).frame(height: 4) + Capsule().fill(accentPink.opacity(0.35)) + .frame(width: geo.size.width * fillPct, height: 4) + + if isDraggingSlider { + Circle().fill(Color.white) + .frame(width: 14, height: 14) + .offset(x: geo.size.width * dragPosition - 7) + } + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { v in + isDraggingSlider = true + let pct = min(max(v.location.x / geo.size.width, 0), fillPct) + dragPosition = pct + } + .onEnded { v in + isDraggingSlider = false + let pct = min(max(v.location.x / geo.size.width, 0), fillPct) + let seekTime = maxSec * pct + audioPlayer.radioSeekBack(to: seekTime) + } + ) + } + .frame(height: 14) + + // Time labels + HStack { + if isDraggingSlider { + let scrubTime = radioBuffer.maxBufferDuration * dragPosition + Text(formatTime(scrubTime)) + .font(.system(size: 11)).foregroundColor(.gray) + } else { + Text(formatTime(radioBuffer.bufferedDuration)) + .font(.system(size: 11)).foregroundColor(.gray) + } + Spacer() + if !radioBuffer.isLive { + // "Back to live" tap on time label + Button(action: { audioPlayer.radioGoLive() }) { + Text("Go Live") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(accentPink) + } + } + } + } + } +} + +// MARK: - Transport Controls + +private var transportControls: some View { + let iconSize: CGFloat = isLandscape ? 24 : 30 + let playSize: CGFloat = isLandscape ? 34 : 42 + let isRecordedRadio = isRadio && !audioPlayer.isRadioStream + let isLiveRadio = isRadio && audioPlayer.isRadioStream + + return VStack(spacing: 4) { + // LIVE indicator above transport — only for live radio streams + if isLiveRadio { + liveIndicator + .padding(.bottom, 4) + } + + HStack(spacing: 0) { + Spacer() + + if isRecordedRadio { + Button(action: { + audioPlayer.seek(to: max(0, audioPlayer.currentTime - 5)) + }) { + Image(systemName: "gobackward.5").font(.system(size: iconSize)).foregroundColor(.white) + }.frame(width: 60, height: 50) + } else if !isLiveRadio { + Button(action: { audioPlayer.previous() }) { + Image(systemName: "backward.fill").font(.system(size: iconSize)).foregroundColor(.white) + }.frame(width: 60, height: 50) + } else { + Color.clear.frame(width: 60, height: 50) + } + + Spacer() + + Button(action: { audioPlayer.togglePlayPause() }) { + Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: playSize)).foregroundColor(.white) + }.frame(width: 70, height: 60) + + Spacer() + + if isRecordedRadio { + Button(action: { + audioPlayer.seek(to: min(audioPlayer.duration, audioPlayer.currentTime + 5)) + }) { + Image(systemName: "goforward.5").font(.system(size: iconSize)).foregroundColor(.white) + }.frame(width: 60, height: 50) + } else if !isLiveRadio { + Button(action: { audioPlayer.next() }) { + Image(systemName: "forward.fill").font(.system(size: iconSize)).foregroundColor(.white) + }.frame(width: 60, height: 50) + } else { + Color.clear.frame(width: 60, height: 50) + } + + Spacer() + } + } +} + +/// Glowing LIVE indicator — red box when buffering active, gray when not +private var liveIndicator: some View { + Button(action: { + if radioBuffer.isBuffering && !radioBuffer.isLive { + audioPlayer.radioGoLive() + } else if !radioBuffer.isBuffering { + if let url = audioPlayer.radioStreamURL { + RadioStreamBuffer.shared.startBuffering( + url: url, + stationName: audioPlayer.currentSong?.title ?? "Radio" + ) + } + } + }) { + HStack(spacing: 5) { + Circle() + .fill(radioBuffer.isBuffering ? Color.red : Color.gray.opacity(0.5)) + .frame(width: 7, height: 7) + Text("LIVE") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(radioBuffer.isBuffering ? .white : .gray) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(radioBuffer.isBuffering ? Color.red.opacity(0.25) : Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(radioBuffer.isBuffering ? Color.red.opacity(0.6) : Color.clear, lineWidth: 1) + ) + .shadow(color: radioBuffer.isBuffering ? Color.red.opacity(0.4) : .clear, radius: 8) + } +} + +// MARK: - Bottom Controls + +private var bottomControls: some View { + HStack { + if isRadio { + // Shazam button + Button(action: shazamCurrentAudio) { + Image(systemName: isShazaming ? "waveform" : "shazam.logo") + .font(.system(size: isLandscape ? 14 : 16)) + .foregroundColor(isShazaming ? accentPink : .gray) + .symbolEffect(.pulse, isActive: isShazaming) + }.frame(width: 40, height: 40) + + Spacer() + + // Recordings list + Button(action: { showQueue = true }) { + Image(systemName: "recordingtape").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray) + }.frame(width: 40, height: 40) + + Spacer() + + AirPlayButton().frame(width: 40, height: 40) + } else { + // Normal music controls + Button(action: { audioPlayer.toggleShuffle() }) { + Image(systemName: "shuffle").font(.system(size: isLandscape ? 14 : 16)) + .foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray) + }.frame(width: 40, height: 40) + Spacer() + Button(action: { audioPlayer.cycleRepeat() }) { + Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") + .font(.system(size: isLandscape ? 14 : 16)) + .foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray) + }.frame(width: 40, height: 40) + Spacer() + Button(action: { showQueue = true }) { + Image(systemName: "list.bullet").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray) + }.frame(width: 40, height: 40) + Spacer() + AirPlayButton().frame(width: 40, height: 40) + } + } + .padding(.horizontal, isLandscape ? 20 : 30) +} + +// MARK: - Helpers + +private func formatTime(_ seconds: TimeInterval) -> String { + let secs = Int(max(0, seconds)) + return String(format: "%d:%02d", secs / 60, secs % 60) +} + +private func toggleStar() { + guard let song = audioPlayer.currentSong else { return } + let wasStarred = isStarred + isStarred.toggle() + Task { + do { + if wasStarred { + try await serverManager.client.unstar(id: song.id) + } else { + try await serverManager.client.star(id: song.id) + } + // Rebuild song with updated starred field so it persists across dismiss/reopen + await MainActor.run { + let newStarred: String? = wasStarred ? nil : "true" + let updated = Song( + id: song.id, parent: song.parent, isDir: song.isDir, + title: song.title, album: song.album, artist: song.artist, + track: song.track, year: song.year, genre: song.genre, + coverArt: song.coverArt, size: song.size, + contentType: song.contentType, suffix: song.suffix, + transcodedContentType: song.transcodedContentType, + transcodedSuffix: song.transcodedSuffix, + duration: song.duration, bitRate: song.bitRate, + path: song.path, playCount: song.playCount, + discNumber: song.discNumber, created: song.created, + albumId: song.albumId, artistId: song.artistId, + type: song.type, starred: newStarred, + bpm: song.bpm, musicBrainzId: song.musicBrainzId + ) + audioPlayer.currentSong = updated + // Also update in queue + if let idx = audioPlayer.queue.firstIndex(where: { $0.id == song.id }) { + audioPlayer.queue[idx] = updated + } + } + } catch { + await MainActor.run { isStarred = wasStarred } + } + } +} + +// MARK: - Shazam + +private func shazamCurrentAudio() { + guard !isShazaming else { return } + isShazaming = true + shazamResult = "Listening..." + shazamArtworkURL = nil + shazamPreviewURL = nil + + ShazamRecognizer.shared.recognize { result in + DispatchQueue.main.async { + isShazaming = false + if let match = result { + shazamResult = match.displayText + shazamArtworkURL = match.artworkURL + shazamPreviewURL = match.previewURL + showShazamResult = true + + // Auto-clear text after 10 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + if shazamResult == match.displayText { shazamResult = nil } + } + } else { + shazamResult = "No match found" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if shazamResult == "No match found" { shazamResult = nil } + } + } + } + } +} + +// MARK: - Recording + +private func toggleRecording() { + if isRecording { + _ = radioBuffer.stopRecording() + isRecording = false + } else { + radioBuffer.startRecording() + isRecording = true + } +} +``` + +} + /// AirPlay route picker wrapped in a container UIView to prevent _UIReparentingView warnings. -/// The AVRoutePickerView creates internal subviews that conflict with SwiftUI's UIHostingController +/// The AVRoutePickerView creates internal subviews that conflict with SwiftUI’s UIHostingController /// hierarchy. Wrapping in a plain UIView keeps the reparenting inside our container. struct AirPlayButton: UIViewRepresentable { - func makeUIView(context: Context) -> UIView { - let container = UIView() - container.backgroundColor = .clear - - let picker = AVRoutePickerView() - picker.activeTintColor = UIColor(red: 1.0, green: 0.176, blue: 0.333, alpha: 1.0) - picker.tintColor = .gray - picker.prioritizesVideoDevices = false - picker.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(picker) - NSLayoutConstraint.activate([ - picker.centerXAnchor.constraint(equalTo: container.centerXAnchor), - picker.centerYAnchor.constraint(equalTo: container.centerYAnchor), - picker.widthAnchor.constraint(equalTo: container.widthAnchor), - picker.heightAnchor.constraint(equalTo: container.heightAnchor) - ]) - - return container - } - func updateUIView(_ uiView: UIView, context: Context) {} +func makeUIView(context: Context) -> UIView { +let container = UIView() +container.backgroundColor = .clear + +``` + let picker = AVRoutePickerView() + picker.activeTintColor = UIColor(red: 1.0, green: 0.176, blue: 0.333, alpha: 1.0) + picker.tintColor = .gray + picker.prioritizesVideoDevices = false + picker.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(picker) + NSLayoutConstraint.activate([ + picker.centerXAnchor.constraint(equalTo: container.centerXAnchor), + picker.centerYAnchor.constraint(equalTo: container.centerYAnchor), + picker.widthAnchor.constraint(equalTo: container.widthAnchor), + picker.heightAnchor.constraint(equalTo: container.heightAnchor) + ]) + + return container +} +func updateUIView(_ uiView: UIView, context: Context) {} +``` + } // MARK: - Add to Playlist Sheet struct AddToPlaylistSheet: View { - let songId: String? - let playlists: [Playlist] - @Environment(\.dismiss) private var dismiss - - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - NavigationStack { - List { - if playlists.isEmpty { - Text("No playlists found") - .foregroundColor(.gray) - } else { - ForEach(playlists) { playlist in - Button(action: { addToPlaylist(playlist) }) { - HStack(spacing: 12) { - AsyncCoverArt(coverArtId: playlist.coverArt, size: 44) - .frame(width: 44, height: 44) - .cornerRadius(4) - - VStack(alignment: .leading, spacing: 2) { - Text(playlist.name) - .font(.system(size: 15)) - .foregroundColor(.white) - Text("\(playlist.songCount ?? 0) songs") - .font(.system(size: 12)) - .foregroundColor(.gray) - } - - Spacer() - - Image(systemName: "plus.circle") - .foregroundColor(accentPink) +let songId: String? +let playlists: [Playlist] +@Environment(.dismiss) private var dismiss + +``` +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +var body: some View { + NavigationStack { + List { + if playlists.isEmpty { + Text("No playlists found") + .foregroundColor(.gray) + } else { + ForEach(playlists) { playlist in + Button(action: { addToPlaylist(playlist) }) { + HStack(spacing: 12) { + AsyncCoverArt(coverArtId: playlist.coverArt, size: 44) + .frame(width: 44, height: 44) + .cornerRadius(4) + + VStack(alignment: .leading, spacing: 2) { + Text(playlist.name) + .font(.system(size: 15)) + .foregroundColor(.white) + Text("\(playlist.songCount ?? 0) songs") + .font(.system(size: 12)) + .foregroundColor(.gray) } + + Spacer() + + Image(systemName: "plus.circle") + .foregroundColor(accentPink) } } } } - .navigationTitle("Add to Playlist") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Cancel") { dismiss() } - .foregroundColor(accentPink) - } + } + .navigationTitle("Add to Playlist") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { dismiss() } + .foregroundColor(accentPink) } } } - - private func addToPlaylist(_ playlist: Playlist) { - guard let songId = songId else { return } - Task { - try? await ServerManager.shared.client.updatePlaylist( - id: playlist.id, - songIdsToAdd: [songId] - ) - await MainActor.run { dismiss() } - } +} + +private func addToPlaylist(_ playlist: Playlist) { + guard let songId = songId else { return } + Task { + try? await ServerManager.shared.client.updatePlaylist( + id: playlist.id, + songIdsToAdd: [songId] + ) + await MainActor.run { dismiss() } } } +``` + +} // MARK: - Queue View struct QueueView: View { - @EnvironmentObject var audioPlayer: AudioPlayer - @EnvironmentObject var offlineManager: OfflineManager - @ObservedObject private var libraryCache = LibraryCache.shared - @State private var editMode: EditMode = .active - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - NavigationStack { - List { - // Currently playing header - if let song = audioPlayer.currentSong { - Section { - HStack(spacing: 12) { - Image(systemName: "waveform") - .font(.system(size: 14)) +@EnvironmentObject var audioPlayer: AudioPlayer +@EnvironmentObject var offlineManager: OfflineManager +@ObservedObject private var libraryCache = LibraryCache.shared +@State private var editMode: EditMode = .active +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +``` +var body: some View { + NavigationStack { + List { + // Currently playing header + if let song = audioPlayer.currentSong { + Section { + HStack(spacing: 12) { + Image(systemName: "waveform") + .font(.system(size: 14)) + .foregroundColor(accentPink) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.system(size: 15, weight: .medium)) .foregroundColor(accentPink) - .frame(width: 20) + .lineLimit(1) + Text(song.artist ?? "") + .font(.system(size: 12)) + .foregroundColor(.gray) + .lineLimit(1) + } + Spacer() + Text(song.durationFormatted) + .font(.system(size: 13)) + .foregroundColor(.gray) + } + } header: { + Text("Now Playing") + } + } + + + Section { + ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in + if index != audioPlayer.queueIndex { + let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable + HStack(spacing: 12) { + Text("\(index + 1)") + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(available ? .gray : .gray.opacity(0.35)) + .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(song.title) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(accentPink) + .font(.system(size: 15)) + .foregroundColor(available ? .white : .gray.opacity(0.4)) .lineLimit(1) Text(song.artist ?? "") .font(.system(size: 12)) - .foregroundColor(.gray) + .foregroundColor(available ? .gray : .gray.opacity(0.3)) .lineLimit(1) } Spacer() + if offlineManager.isSongDownloaded(song.id) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 11)) + .foregroundColor(.green.opacity(0.6)) + } Text(song.durationFormatted) .font(.system(size: 13)) - .foregroundColor(.gray) + .foregroundColor(available ? .gray : .gray.opacity(0.35)) + } + .contentShape(Rectangle()) + .onTapGesture { + if available { audioPlayer.play(song: song, at: index) } } - } header: { - Text("Now Playing") } } - - - Section { - ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in - if index != audioPlayer.queueIndex { - let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable - HStack(spacing: 12) { - Text("\(index + 1)") - .font(.system(size: 13, design: .monospaced)) - .foregroundColor(available ? .gray : .gray.opacity(0.35)) - .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(song.title) - .font(.system(size: 15)) - .foregroundColor(available ? .white : .gray.opacity(0.4)) - .lineLimit(1) - Text(song.artist ?? "") - .font(.system(size: 12)) - .foregroundColor(available ? .gray : .gray.opacity(0.3)) - .lineLimit(1) - } - Spacer() - if offlineManager.isSongDownloaded(song.id) { - Image(systemName: "arrow.down.circle.fill") - .font(.system(size: 11)) - .foregroundColor(.green.opacity(0.6)) - } - Text(song.durationFormatted) - .font(.system(size: 13)) - .foregroundColor(available ? .gray : .gray.opacity(0.35)) - } - .contentShape(Rectangle()) - .onTapGesture { - if available { audioPlayer.play(song: song, at: index) } - } - } - } - .onMove(perform: audioPlayer.moveQueueItem) - .onDelete(perform: audioPlayer.removeFromQueue) - } header: { - HStack { - Text("Up Next") - Spacer() - Text("\(audioPlayer.queue.count) songs") - .font(.caption) - .foregroundColor(.gray) - } + .onMove(perform: audioPlayer.moveQueueItem) + .onDelete(perform: audioPlayer.removeFromQueue) + } header: { + HStack { + Text("Up Next") + Spacer() + Text("\(audioPlayer.queue.count) songs") + .font(.caption) + .foregroundColor(.gray) } } - .environment(\.editMode, $editMode) - .navigationTitle("Queue") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 16) { - Button(action: { audioPlayer.toggleShuffle() }) { - Image(systemName: "shuffle") - .foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray) - } - Button(action: { audioPlayer.cycleRepeat() }) { - Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") - .foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray) - } + } + .environment(\.editMode, $editMode) + .navigationTitle("Queue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + Button(action: { audioPlayer.toggleShuffle() }) { + Image(systemName: "shuffle") + .foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray) + } + Button(action: { audioPlayer.cycleRepeat() }) { + Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") + .foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray) } } } } } } +``` + +} // MARK: - Radio Recordings View struct RadioRecordingsView: View { - let stationName: String - @State private var recordings: [RecordingFile] = [] - @State private var playingURL: URL? - @Environment(\.dismiss) private var dismiss - - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - struct RecordingFile: Identifiable { - let id = UUID() - let url: URL - let name: String - let date: Date - let size: String - let duration: String - } - - var body: some View { - NavigationStack { - Group { - if recordings.isEmpty { - VStack(spacing: 12) { - Image(systemName: "recordingtape") - .font(.system(size: 36)) - .foregroundColor(.gray) - Text("No recordings yet") - .font(.system(size: 16)) - .foregroundColor(.gray) - Text("Tap REC to record this station") - .font(.system(size: 13)) - .foregroundColor(.gray.opacity(0.7)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(recordings) { rec in - HStack(spacing: 12) { - Button(action: { playRecording(rec) }) { - Image(systemName: playingURL == rec.url ? "stop.circle.fill" : "play.circle.fill") - .font(.system(size: 28)) - .foregroundColor(playingURL == rec.url ? .red : accentPink) - } - .buttonStyle(.plain) - - VStack(alignment: .leading, spacing: 3) { - Text(rec.name) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white) - .lineLimit(1) - HStack(spacing: 8) { - Text(rec.date, format: .dateTime.month(.wide).day().year()) - .font(.system(size: 11)) - .foregroundColor(.gray) - Text(rec.size) - .font(.system(size: 11)) - .foregroundColor(.gray) - } - } - - Spacer() - - // Share button - ShareLink(item: rec.url) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 14)) +let stationName: String +@State private var recordings: [RecordingFile] = [] +@State private var playingURL: URL? +@Environment(.dismiss) private var dismiss + +``` +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +struct RecordingFile: Identifiable { + let id = UUID() + let url: URL + let name: String + let date: Date + let size: String + let duration: String +} + +var body: some View { + NavigationStack { + Group { + if recordings.isEmpty { + VStack(spacing: 12) { + Image(systemName: "recordingtape") + .font(.system(size: 36)) + .foregroundColor(.gray) + Text("No recordings yet") + .font(.system(size: 16)) + .foregroundColor(.gray) + Text("Tap REC to record this station") + .font(.system(size: 13)) + .foregroundColor(.gray.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(recordings) { rec in + HStack(spacing: 12) { + Button(action: { playRecording(rec) }) { + Image(systemName: playingURL == rec.url ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 28)) + .foregroundColor(playingURL == rec.url ? .red : accentPink) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 3) { + Text(rec.name) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + HStack(spacing: 8) { + Text(rec.date, format: .dateTime.month(.wide).day().year()) + .font(.system(size: 11)) + .foregroundColor(.gray) + Text(rec.size) + .font(.system(size: 11)) .foregroundColor(.gray) } } - .padding(.vertical, 4) + + Spacer() + + // Share button + ShareLink(item: rec.url) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 14)) + .foregroundColor(.gray) + } } - .onDelete(perform: deleteRecordings) + .padding(.vertical, 4) } + .onDelete(perform: deleteRecordings) } } - .navigationTitle("Recordings") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() } - .foregroundColor(accentPink) - } + } + .navigationTitle("Recordings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + .foregroundColor(accentPink) } - .onAppear { loadRecordings() } } + .onAppear { loadRecordings() } + } +} + +private func loadRecordings() { + let fm = FileManager.default + let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first! + let dir = docs.appendingPathComponent("RadioRecordings", isDirectory: true) + + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) else { + return } - private func loadRecordings() { - let fm = FileManager.default - let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first! - let dir = docs.appendingPathComponent("RadioRecordings", isDirectory: true) - - guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) else { - return - } - - let safeName = stationName.replacingOccurrences(of: "/", with: "-") - - recordings = files - .filter { $0.lastPathComponent.hasPrefix(safeName) || stationName == "Radio" } - .compactMap { url in - guard let attrs = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), - let date = attrs.contentModificationDate, - let size = attrs.fileSize else { return nil } - - let sizeStr = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) - let name = url.deletingPathExtension().lastPathComponent - - return RecordingFile(url: url, name: name, date: date, size: sizeStr, duration: "") - } - .sorted { $0.date > $1.date } - } + let safeName = stationName.replacingOccurrences(of: "/", with: "-") - private func playRecording(_ rec: RecordingFile) { - if playingURL == rec.url { - AudioPlayer.shared.stop() - playingURL = nil - } else { - let song = Song( - id: rec.id.uuidString, parent: nil, isDir: nil, - title: rec.name, album: "Recording", - artist: stationName, - track: nil, year: nil, genre: nil, coverArt: nil, - size: nil, contentType: nil, suffix: nil, - transcodedContentType: nil, transcodedSuffix: nil, - duration: 0, bitRate: nil, path: nil, playCount: nil, - discNumber: nil, created: nil, albumId: nil, artistId: nil, - type: nil, starred: nil, bpm: nil, musicBrainzId: nil - ) - AudioPlayer.shared.playRadio(song: song, streamURL: rec.url) - playingURL = rec.url + recordings = files + .filter { $0.lastPathComponent.hasPrefix(safeName) || stationName == "Radio" } + .compactMap { url in + guard let attrs = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let date = attrs.contentModificationDate, + let size = attrs.fileSize else { return nil } + + let sizeStr = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + let name = url.deletingPathExtension().lastPathComponent + + return RecordingFile(url: url, name: name, date: date, size: sizeStr, duration: "") } + .sorted { $0.date > $1.date } +} + +private func playRecording(_ rec: RecordingFile) { + if playingURL == rec.url { + AudioPlayer.shared.stop() + playingURL = nil + } else { + let song = Song( + id: rec.id.uuidString, parent: nil, isDir: nil, + title: rec.name, album: "Recording", + artist: stationName, + track: nil, year: nil, genre: nil, coverArt: nil, + size: nil, contentType: nil, suffix: nil, + transcodedContentType: nil, transcodedSuffix: nil, + duration: 0, bitRate: nil, path: nil, playCount: nil, + discNumber: nil, created: nil, albumId: nil, artistId: nil, + type: nil, starred: nil, bpm: nil, musicBrainzId: nil + ) + AudioPlayer.shared.playRadio(song: song, streamURL: rec.url) + playingURL = rec.url } - - private func deleteRecordings(at offsets: IndexSet) { - for idx in offsets { - try? FileManager.default.removeItem(at: recordings[idx].url) - } - recordings.remove(atOffsets: offsets) +} + +private func deleteRecordings(at offsets: IndexSet) { + for idx in offsets { + try? FileManager.default.removeItem(at: recordings[idx].url) } + recordings.remove(atOffsets: offsets) +} +``` + } // MARK: - Rounded Corner Shape Helper struct RoundedCornerShape: Shape { - let corners: UIRectCorner - let radius: CGFloat - func path(in rect: CGRect) -> Path { - Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners, - cornerRadii: CGSize(width: radius, height: radius)).cgPath) - } +let corners: UIRectCorner +let radius: CGFloat +func path(in rect: CGRect) -> Path { +Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners, +cornerRadii: CGSize(width: radius, height: radius)).cgPath) +} } // MARK: - Shazam Match Result // MARK: - Shazam Match Result struct ShazamMatch { - let title: String - let artist: String - let artworkURL: URL? - let previewURL: URL? - let appleMusicURL: URL? - - var displayText: String { - artist.isEmpty ? title : "\(title) — \(artist)" - } +let title: String +let artist: String +let artworkURL: URL? +let previewURL: URL? +let appleMusicURL: URL? + +``` +var displayText: String { + artist.isEmpty ? title : "\(title) — \(artist)" +} +``` + } // MARK: - Shazam Recognizer /// Uses ShazamKit + AVAudioEngine mic tap for audio recognition. class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { - static let shared = ShazamRecognizer() +static let shared = ShazamRecognizer() + +``` +@Published var isRecognizing = false + +private var session: SHSession? +private var audioEngine: AVAudioEngine? +private var completion: ((ShazamMatch?) -> Void)? +private var timeoutTask: Task? +private var hasDeliveredResult = false + +private override init() { super.init() } + +/// Start recognition via microphone using matchStreamingBuffer +func recognize(completion: @escaping (ShazamMatch?) -> Void) { + guard !isRecognizing else { return } + stopListening() - @Published var isRecognizing = false + isRecognizing = true + hasDeliveredResult = false + self.completion = completion - private var session: SHSession? - private var audioEngine: AVAudioEngine? - private var completion: ((ShazamMatch?) -> Void)? - private var timeoutTask: Task? - private var hasDeliveredResult = false + DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") - private override init() { super.init() } - - /// Start recognition via microphone using matchStreamingBuffer - func recognize(completion: @escaping (ShazamMatch?) -> Void) { - guard !isRecognizing else { return } - stopListening() - - isRecognizing = true - hasDeliveredResult = false - self.completion = completion - - DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") - - // Configure audio session for simultaneous playback + mic - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playAndRecord, mode: .default, options: [ - .defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers - ]) - try audioSession.setActive(true) - } catch { - DebugLogger.shared.log("Shazam: audio session failed: \(error.localizedDescription)", category: "Audio") - deliverResult(nil) - return - } - - // Create SHSession for Shazam catalog matching - let shSession = SHSession() - shSession.delegate = self - session = shSession - - // Dedicated audio engine for mic capture - let engine = AVAudioEngine() - audioEngine = engine - - let inputNode = engine.inputNode - let bus: AVAudioNodeBus = 0 - let nativeFormat = inputNode.outputFormat(forBus: bus) - - guard nativeFormat.sampleRate > 0, nativeFormat.channelCount > 0 else { - DebugLogger.shared.log("Shazam: invalid mic format (sr=\(nativeFormat.sampleRate))", category: "Audio") - deliverResult(nil) - return - } - - DebugLogger.shared.log("Shazam: mic \(Int(nativeFormat.sampleRate))Hz, \(nativeFormat.channelCount)ch", category: "Audio") - - // Install tap — feed buffers directly to matchStreamingBuffer - // Per Apple docs: "Use matchStreamingBuffer when you are generating - // the audio buffers and passing them into the framework." - // This is simpler and more reliable than manual SHSignatureGenerator. - inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFormat) { [weak shSession] buffer, time in - shSession?.matchStreamingBuffer(buffer, at: time) - } - - do { - engine.prepare() - try engine.start() - DebugLogger.shared.log("Shazam: engine started, listening...", category: "Audio") - } catch { - DebugLogger.shared.log("Shazam: engine start failed: \(error.localizedDescription)", category: "Audio") - deliverResult(nil) - return - } - - // Timeout after 12 seconds if no match - timeoutTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(12)) - if !self.hasDeliveredResult { - DebugLogger.shared.log("Shazam: timeout — no match after 12s", category: "Audio") - self.deliverResult(nil) - } - } - } - - // MARK: - SHSessionDelegate - - func session(_ session: SHSession, didFind match: SHMatch) { - guard let item = match.mediaItems.first else { return } - let title = item.title ?? "Unknown" - let artist = item.artist ?? "" - let artworkURL = item.artworkURL - let appleMusicURL = item.appleMusicURL - - DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio") - - // Extract preview URL from SHMediaItem.songs MusicKit bridge. - // SHMediaItem.songs returns MusicKit Song objects directly. - // Song.previewAssets contains the 30s DRM-free preview URL. - var previewURL: URL? = nil - if let firstSong = item.songs.first, - let preview = firstSong.previewAssets?.first { - previewURL = preview.url - DebugLogger.shared.log("Shazam: preview URL from songs bridge", category: "Audio") - } - - DispatchQueue.main.async { - let result = ShazamMatch( - title: title, - artist: artist, - artworkURL: artworkURL, - previewURL: previewURL, - appleMusicURL: appleMusicURL - ) - self.deliverResult(result) - } - } - - func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) { - // matchStreamingBuffer keeps trying automatically — don't stop here. - // The timeout task handles giving up. - if let error = error { - DebugLogger.shared.log("Shazam: segment no match — \(error.localizedDescription)", category: "Audio") - } - } - - // MARK: - Cleanup - - private func deliverResult(_ result: ShazamMatch?) { - guard !hasDeliveredResult else { return } - hasDeliveredResult = true - stopListening() - isRecognizing = false - completion?(result) - completion = nil - } - - private func stopListening() { - timeoutTask?.cancel() - timeoutTask = nil - - if let engine = audioEngine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - audioEngine = nil - } - session = nil - - // Restore playback audio session - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - DebugLogger.shared.log("Shazam: restore session failed: \(error.localizedDescription)", category: "Audio") - } - } - - func cancel() { - DebugLogger.shared.log("Shazam: cancelled", category: "Audio") + // Configure audio session for simultaneous playback + mic + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playAndRecord, mode: .default, options: [ + .defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers + ]) + try audioSession.setActive(true) + } catch { + DebugLogger.shared.log("Shazam: audio session failed: \(error.localizedDescription)", category: "Audio") deliverResult(nil) + return + } + + // Create SHSession for Shazam catalog matching + let shSession = SHSession() + shSession.delegate = self + session = shSession + + // Dedicated audio engine for mic capture + let engine = AVAudioEngine() + audioEngine = engine + + let inputNode = engine.inputNode + let bus: AVAudioNodeBus = 0 + let nativeFormat = inputNode.outputFormat(forBus: bus) + + guard nativeFormat.sampleRate > 0, nativeFormat.channelCount > 0 else { + DebugLogger.shared.log("Shazam: invalid mic format (sr=\(nativeFormat.sampleRate))", category: "Audio") + deliverResult(nil) + return + } + + DebugLogger.shared.log("Shazam: mic \(Int(nativeFormat.sampleRate))Hz, \(nativeFormat.channelCount)ch", category: "Audio") + + // Install tap — feed buffers directly to matchStreamingBuffer + // Per Apple docs: "Use matchStreamingBuffer when you are generating + // the audio buffers and passing them into the framework." + // This is simpler and more reliable than manual SHSignatureGenerator. + inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFormat) { [weak shSession] buffer, time in + shSession?.matchStreamingBuffer(buffer, at: time) + } + + do { + engine.prepare() + try engine.start() + DebugLogger.shared.log("Shazam: engine started, listening...", category: "Audio") + } catch { + DebugLogger.shared.log("Shazam: engine start failed: \(error.localizedDescription)", category: "Audio") + deliverResult(nil) + return + } + + // Timeout after 12 seconds if no match + timeoutTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(12)) + if !self.hasDeliveredResult { + DebugLogger.shared.log("Shazam: timeout — no match after 12s", category: "Audio") + self.deliverResult(nil) + } } } +// MARK: - SHSessionDelegate +func session(_ session: SHSession, didFind match: SHMatch) { + guard let item = match.mediaItems.first else { return } + let title = item.title ?? "Unknown" + let artist = item.artist ?? "" + let artworkURL = item.artworkURL + let appleMusicURL = item.appleMusicURL + + DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio") + + // Extract preview URL from SHMediaItem.songs MusicKit bridge. + // SHMediaItem.songs returns MusicKit Song objects directly. + // Song.previewAssets contains the 30s DRM-free preview URL. + var previewURL: URL? = nil + if let firstSong = item.songs.first, + let preview = firstSong.previewAssets?.first { + previewURL = preview.url + DebugLogger.shared.log("Shazam: preview URL from songs bridge", category: "Audio") + } + + DispatchQueue.main.async { + let result = ShazamMatch( + title: title, + artist: artist, + artworkURL: artworkURL, + previewURL: previewURL, + appleMusicURL: appleMusicURL + ) + self.deliverResult(result) + } +} + +func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) { + // matchStreamingBuffer keeps trying automatically — don't stop here. + // The timeout task handles giving up. + if let error = error { + DebugLogger.shared.log("Shazam: segment no match — \(error.localizedDescription)", category: "Audio") + } +} + +// MARK: - Cleanup + +private func deliverResult(_ result: ShazamMatch?) { + guard !hasDeliveredResult else { return } + hasDeliveredResult = true + stopListening() + isRecognizing = false + completion?(result) + completion = nil +} + +private func stopListening() { + timeoutTask?.cancel() + timeoutTask = nil + + if let engine = audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + audioEngine = nil + } + session = nil + + // Restore playback audio session + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + DebugLogger.shared.log("Shazam: restore session failed: \(error.localizedDescription)", category: "Audio") + } +} + +func cancel() { + DebugLogger.shared.log("Shazam: cancelled", category: "Audio") + deliverResult(nil) +} +``` + +} // MARK: - Shazam Result Sheet struct ShazamResultSheet: View { - let title: String - let artworkURL: URL? - let previewURL: URL? - - @Environment(\.dismiss) private var dismiss - @State private var previewPlayer: AVPlayer? - @State private var isPlayingPreview = false - - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - VStack(spacing: 20) { - // Artwork - if let url = artworkURL { - AsyncImage(url: url) { image in - image.resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 12) - .fill(Color.white.opacity(0.08)) - .overlay(ProgressView().tint(accentPink)) - } - .frame(width: 180, height: 180) - .cornerRadius(12) - .shadow(color: .black.opacity(0.4), radius: 12, y: 6) - } else { +let title: String +let artworkURL: URL? +let previewURL: URL? + +``` +@Environment(\.dismiss) private var dismiss +@State private var previewPlayer: AVPlayer? +@State private var isPlayingPreview = false + +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +var body: some View { + VStack(spacing: 20) { + // Artwork + if let url = artworkURL { + AsyncImage(url: url) { image in + image.resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.08)) - .frame(width: 180, height: 180) - .overlay( - Image(systemName: "shazam.logo") - .font(.system(size: 50)) - .foregroundColor(.gray) - ) + .overlay(ProgressView().tint(accentPink)) } - - // Title - Text(title) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - - // Preview button - if let previewURL = previewURL { - Button(action: { togglePreview(url: previewURL) }) { - HStack(spacing: 8) { - Image(systemName: isPlayingPreview ? "pause.fill" : "play.fill") - .font(.system(size: 14)) - Text(isPlayingPreview ? "Stop Preview" : "Play 30s Preview") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.white) - .padding(.horizontal, 24) - .padding(.vertical, 10) - .background(accentPink) - .cornerRadius(20) - } - } - - Spacer() - } - .padding(.top, 24) - .frame(maxWidth: .infinity) - .background(Color(white: 0.1)) - .onDisappear { - previewPlayer?.pause() - previewPlayer = nil - } - } - - private func togglePreview(url: URL) { - if isPlayingPreview { - previewPlayer?.pause() - isPlayingPreview = false + .frame(width: 180, height: 180) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 12, y: 6) } else { - if previewPlayer == nil { - previewPlayer = AVPlayer(url: url) - } else { - previewPlayer?.replaceCurrentItem(with: AVPlayerItem(url: url)) - } - previewPlayer?.play() - isPlayingPreview = true + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.08)) + .frame(width: 180, height: 180) + .overlay( + Image(systemName: "shazam.logo") + .font(.system(size: 50)) + .foregroundColor(.gray) + ) } + + // Title + Text(title) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + // Preview button + if let previewURL = previewURL { + Button(action: { togglePreview(url: previewURL) }) { + HStack(spacing: 8) { + Image(systemName: isPlayingPreview ? "pause.fill" : "play.fill") + .font(.system(size: 14)) + Text(isPlayingPreview ? "Stop Preview" : "Play 30s Preview") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(accentPink) + .cornerRadius(20) + } + } + + Spacer() } + .padding(.top, 24) + .frame(maxWidth: .infinity) + .background(Color(white: 0.1)) + .onDisappear { + previewPlayer?.pause() + previewPlayer = nil + } +} + +private func togglePreview(url: URL) { + if isPlayingPreview { + previewPlayer?.pause() + isPlayingPreview = false + } else { + if previewPlayer == nil { + previewPlayer = AVPlayer(url: url) + } else { + previewPlayer?.replaceCurrentItem(with: AVPlayerItem(url: url)) + } + previewPlayer?.play() + isPlayingPreview = true + } +} +``` + } // MARK: - Song Info Sheet struct SongInfoSheet: View { - let song: Song - @Environment(\.dismiss) private var dismiss - - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - NavigationStack { - List { - Section("TRACK") { - infoRow("Title", song.title) - infoRow("Artist", song.artist ?? "Unknown") - infoRow("Album", song.album ?? "Unknown") - if let track = song.track { infoRow("Track #", "\(track)") } - if let disc = song.discNumber { infoRow("Disc #", "\(disc)") } - if let year = song.year { infoRow("Year", "\(year)") } - if let genre = song.genre { infoRow("Genre", genre) } - } - - Section("FILE") { - if let dur = song.duration, dur > 0 { infoRow("Duration", song.durationFormatted) } - if let bitRate = song.bitRate { infoRow("Bit Rate", "\(bitRate) kbps") } - if let suffix = song.suffix { infoRow("Format", suffix.uppercased()) } - if let contentType = song.contentType { infoRow("Content Type", contentType) } - if let size = song.size { - infoRow("File Size", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) - } - if let path = song.path { infoRow("Path", path) } - } - - Section("METADATA") { - infoRow("Song ID", song.id) - if let albumId = song.albumId { infoRow("Album ID", albumId) } - if let artistId = song.artistId { infoRow("Artist ID", artistId) } - if let playCount = song.playCount { infoRow("Play Count", "\(playCount)") } - if let created = song.created { infoRow("Added", created) } - if let bpm = song.bpm { infoRow("BPM", "\(bpm)") } - infoRow("Starred", song.starred != nil ? "Yes" : "No") - - let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id) - infoRow("Downloaded", isDownloaded ? "Yes" : "No") - } +let song: Song +@Environment(.dismiss) private var dismiss + +``` +private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + +var body: some View { + NavigationStack { + List { + Section("TRACK") { + infoRow("Title", song.title) + infoRow("Artist", song.artist ?? "Unknown") + infoRow("Album", song.album ?? "Unknown") + if let track = song.track { infoRow("Track #", "\(track)") } + if let disc = song.discNumber { infoRow("Disc #", "\(disc)") } + if let year = song.year { infoRow("Year", "\(year)") } + if let genre = song.genre { infoRow("Genre", genre) } } - .navigationTitle("Get Info") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() }.foregroundColor(accentPink) + + Section("FILE") { + if let dur = song.duration, dur > 0 { infoRow("Duration", song.durationFormatted) } + if let bitRate = song.bitRate { infoRow("Bit Rate", "\(bitRate) kbps") } + if let suffix = song.suffix { infoRow("Format", suffix.uppercased()) } + if let contentType = song.contentType { infoRow("Content Type", contentType) } + if let size = song.size { + infoRow("File Size", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) } + if let path = song.path { infoRow("Path", path) } + } + + Section("METADATA") { + infoRow("Song ID", song.id) + if let albumId = song.albumId { infoRow("Album ID", albumId) } + if let artistId = song.artistId { infoRow("Artist ID", artistId) } + if let playCount = song.playCount { infoRow("Play Count", "\(playCount)") } + if let created = song.created { infoRow("Added", created) } + if let bpm = song.bpm { infoRow("BPM", "\(bpm)") } + infoRow("Starred", song.starred != nil ? "Yes" : "No") + + let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id) + infoRow("Downloaded", isDownloaded ? "Yes" : "No") } } - } - - private func infoRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label).foregroundColor(.gray) - Spacer() - Text(value) - .foregroundColor(.white) - .multilineTextAlignment(.trailing) - .lineLimit(2) + .navigationTitle("Get Info") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() }.foregroundColor(accentPink) + } } - .font(.system(size: 14)) } } + +private func infoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label).foregroundColor(.gray) + Spacer() + Text(value) + .foregroundColor(.white) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + .font(.system(size: 14)) +} +``` + +}