import SwiftUI import AVKit import AVFoundation import UIKit import Combine import ShazamKit import MusicKit // MARK: - Album Art Color Extractor class AlbumColorExtractor: ObservableObject { 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 var radioCoverStoreObserver: 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) } // Re-extract when a radio cover changes radioCoverStoreObserver = RadioCoverStore.shared.$updateTrigger .dropFirst() .sink { [weak self] _ in guard let self = self else { return } self.lastCoverArtId = nil // force re-extract on next call } } /// Extract colors directly from a UIImage — used for radio stations whose /// custom cover lives in RadioCoverStore rather than AlbumCoverStore. func extractDirect(from image: UIImage, id: String) { guard id != lastCoverArtId else { return } lastCoverArtId = id let colors = Self.dominantColors(from: image) 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) } 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) } } 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) } } 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 // isDraggingSlider and dragPosition moved into NowPlayingSeekBar — // they only affect the seek bar child, not NowPlayingView.body @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 shazamResult: String? @State private var shazamArtworkURL: URL? @State private var shazamPreviewURL: URL? @State private var isShazaming = false @State private var showShazamResult = false @State private var showLyricsSearch = false @State private var showLyricsEditor = false @StateObject private var albumColors = AlbumColorExtractor.shared @StateObject private var radioBuffer = RadioStreamBuffer.shared @ObservedObject private var visSettings = VisualizerSettings.shared @ObservedObject private var lyricsManager = LyricsManager.shared @State private var dragOffset: CGFloat = 0 // playbackTime/playbackDuration moved into NowPlayingSeekBar. // NowPlayingView.body must not read currentTime — it causes the entire view // to re-evaluate 10x/second even when hidden off screen (offset 1500pt). @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" } /// Routes color extraction to RadioCoverStore for radio stations with custom covers, /// otherwise falls through to the standard AlbumColorExtractor path. private func extractAlbumColors() { if isRadio, let song = audioPlayer.currentSong, let customImg = RadioCoverStore.shared.loadCover(for: song.id) { albumColors.extractDirect(from: customImg, id: song.id) } else { albumColors.extract(from: audioPlayer.currentSong?.coverArt) } } var body: some View { let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("NowPlayingView") : false 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 } } .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 } } } ) .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]) } .sheet(isPresented: $showLyricsSearch) { LyricsSearchSheet(initialQuery: "\(audioPlayer.currentSong?.artist ?? "") \(audioPlayer.currentSong?.title ?? "")") } .sheet(isPresented: $showLyricsEditor) { if let lyrics = lyricsManager.currentLyrics { LyricsEditorView(lyrics: lyrics, songPath: audioPlayer.currentSong?.path) } } .onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in showLyricsSearch = true } .onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in showLyricsEditor = true } .onAppear { dragOffset = 0 isStarred = audioPlayer.currentSong?.starred != nil extractAlbumColors() } .onChange(of: audioPlayer.currentSong?.id) { _, _ in isStarred = audioPlayer.currentSong?.starred != nil extractAlbumColors() } .onChange(of: RadioCoverStore.shared.updateTrigger) { _, _ in // Radio cover changed — re-extract if a radio station is playing if isRadio { extractAlbumColors() } } .onChange(of: audioPlayer.currentSong?.starred) { _, newVal in isStarred = newVal != nil } } // MARK: - Portrait Layout private var portraitLayout: some View { VStack(spacing: 0) { topBar Spacer() // Album art OR lyrics overlay if lyricsManager.isLyricsVisible { lyricsOverlay .frame(height: 340) .transition(.opacity) } else { albumArt(size: 300) .transition(.opacity) } 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 { let height = isLandscape ? geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7) : geo.size.height * visSettings.nowPlayingHeightPct if visSettings.enabled && visSettings.nowPlayingEnabled { VStack { Spacer() MitsuhaVisualizerView( isPlaying: audioPlayer.isPlaying, isSongLoaded: audioPlayer.currentSong != nil, songId: audioPlayer.currentSong?.id, accentColor: accentPink, isVisible: isPresented ) .frame(maxWidth: .infinity) .frame(height: height) .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 // Route through OptimisticActionQueue for offline // resilience — direct try? calls silently lose the // action when Tailscale is reconnecting (AUDIT-057) if song.starred != nil { OptimisticActionQueue.shared.unstar(songId: song.id) } else { OptimisticActionQueue.shared.star(songId: 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: - Lyrics Overlay /// Replaces the cover art area when lyrics are active. /// The visualizer continues behind — a blur panel fades in where lyrics text starts. private var lyricsOverlay: some View { ZStack { // Blur panel: .ultraThinMaterial with a gradient mask so the top // fades to transparent (visualizer shows through), bottom is opaque Rectangle() .fill(.ultraThinMaterial) .mask( VStack(spacing: 0) { LinearGradient( colors: [.clear, .white], startPoint: .top, endPoint: .bottom ) .frame(height: 50) Color.white } ) // Lyrics content on top of the blur LyricsOverlayView() } } // 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(.npTitle) .foregroundColor(.white) .lineLimit(1) Text(audioPlayer.currentSong?.artist ?? "") .font(.npArtist) .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) } .accessibilityLabel(isStarred ? "Unfavourite" : "Favourite") .accessibilityValue(isStarred ? "Added to favourites" : "Not in favourites") } } .padding(.horizontal, isLandscape ? 20 : 30) } // MARK: - Progress Bar private var progressBar: some View { VStack(spacing: 6) { if isRecordedRadio { // Feature 4: recorded radio uses the standard seek bar, not the buffer bar normalProgressBar } else if isLiveRadio { radioProgressBar } else { normalProgressBar } } .padding(.horizontal, isLandscape ? 20 : 30) } // Convenience flags used across progress bar and transport private var isRecordedRadio: Bool { isRadio && !audioPlayer.isRadioStream } private var isLiveRadio: Bool { isRadio && audioPlayer.isRadioStream } private var normalProgressBar: some View { // NowPlayingSeekBar owns its own @ObservedObject audioPlayer reference. // Only this child re-evaluates at 10Hz — NowPlayingView.body stays quiet. NowPlayingSeekBar() } private var radioProgressBar: some View { NowPlayingSeekBar(radioBuffer: radioBuffer, accentColor: accentPink) } // MARK: - Transport Controls private var transportControls: some View { let iconSize: CGFloat = isLandscape ? 24 : 30 let playSize: CGFloat = isLandscape ? 34 : 42 // Dynamic disable states (Feature 2) let bufferSec = radioBuffer.estimatedBufferSeconds let currentPos: TimeInterval = audioPlayer.isPlayingFromBuffer ? audioPlayer.currentTime : bufferSec let secondsBehindLive = bufferSec - currentPos let canRewind30 = !radioBuffer.isHLSStream && bufferSec >= 30 let canForward15 = !radioBuffer.isHLSStream && secondsBehindLive >= 15 // Feature 4: recorded radio also gets timeshift transport (30s/15s), not prev/next let showTimeshiftLeft = (isLiveRadio && radioBuffer.isBuffering) || isRecordedRadio let showTimeshiftRight = showTimeshiftLeft return VStack(spacing: 4) { if isLiveRadio { liveIndicator.padding(.bottom, 4) } HStack(spacing: 0) { Spacer() // Left button if showTimeshiftLeft { Button(action: { if isRecordedRadio { audioPlayer.seek(to: max(0, audioPlayer.currentTime - 30)) } else { audioPlayer.radioSkip(by: -30) } }) { Image(systemName: "gobackward.30") .font(.system(size: iconSize)) .foregroundColor(canRewind30 || isRecordedRadio ? .white : .gray.opacity(0.35)) } .frame(width: 60, height: 50) .disabled(isLiveRadio && !canRewind30) .accessibilityLabel("Rewind 30 seconds") } else if !isLiveRadio { Button(action: { audioPlayer.previous() }) { Image(systemName: "backward.fill") .font(.system(size: iconSize)).foregroundColor(.white) } .frame(width: 60, height: 50) .accessibilityLabel("Previous track") } 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) .accessibilityLabel(audioPlayer.isPlaying ? "Pause" : "Play") Spacer() // Right button if showTimeshiftRight { Button(action: { if isRecordedRadio { audioPlayer.seek(to: min(audioPlayer.duration, audioPlayer.currentTime + 15)) } else { audioPlayer.radioSkip(by: 15) } }) { Image(systemName: "goforward.15") .font(.system(size: iconSize)) .foregroundColor(canForward15 || isRecordedRadio ? .white : .gray.opacity(0.35)) } .frame(width: 60, height: 50) .disabled(isLiveRadio && !canForward15) .accessibilityLabel("Forward 15 seconds") } else if !isLiveRadio { Button(action: { audioPlayer.next() }) { Image(systemName: "forward.fill") .font(.system(size: iconSize)).foregroundColor(.white) } .frame(width: 60, height: 50) .accessibilityLabel("Next track") } else { Color.clear.frame(width: 60, height: 50) } Spacer() } } } /// REC button — repurposed from LIVE now that buffering is automatic. /// HLS stream → greyed "LIVE" pill with slash icon, non-tappable /// Not buffering yet → greyed pill (brief transient while auto-buffer starts) /// Buffering, idle → grey "REC" pill, tap to start recording /// Recording active → solid red pill with pulsing glow, shows elapsed time, tap to stop @State private var livePulse = false private var liveIndicator: some View { let isHLS = radioBuffer.isHLSStream let buffering = radioBuffer.isBuffering let recording = radioBuffer.isRecording return Button(action: { guard !isHLS, buffering else { return } audioPlayer.toggleRecording() }) { HStack(spacing: 5) { if isHLS { Image(systemName: "slash.circle") .font(.system(size: 9, weight: .bold)) .foregroundColor(.gray.opacity(0.5)) } else if recording { Circle() .fill(Color.red) .frame(width: 7, height: 7) } else { Image(systemName: "record.circle") .font(.system(size: 9, weight: .bold)) .foregroundColor(buffering ? .white : .gray.opacity(0.4)) } Text(recording ? radioBuffer.recordingTimeFormatted : "REC") .font(.system(size: 12, weight: .bold)) .foregroundColor(isHLS ? .gray.opacity(0.4) : recording ? .white : buffering ? .white.opacity(0.7) : .gray) } .padding(.horizontal, 14) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6) .fill(recording ? Color.red.opacity(0.85) : isHLS || !buffering ? Color.white.opacity(0.04) : Color.white.opacity(0.06) ) ) .overlay( RoundedRectangle(cornerRadius: 6) .stroke( recording ? Color.red.opacity(0.9) : Color.clear, lineWidth: 1.5 ) ) .shadow( color: recording ? Color.red.opacity(livePulse ? 0.7 : 0.2) : .clear, radius: livePulse ? 14 : 6 ) .animation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true), value: livePulse) .overlay(alignment: .bottom) { if isHLS { Text("HLS — no timeshift") .font(.system(size: 9)) .foregroundColor(.gray.opacity(0.5)) .offset(y: 18) } } } .disabled(isHLS || !buffering) .onAppear { livePulse = true } .onChange(of: recording) { _, val in livePulse = val } } // 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() // Lyrics toggle Button(action: { withAnimation(.easeInOut(duration: 0.3)) { lyricsManager.isLyricsVisible.toggle() } }) { Image(systemName: "quote.bubble") .font(.system(size: isLandscape ? 14 : 16)) .foregroundColor(lyricsManager.isLyricsVisible ? accentPink : .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, albumArtist: song.albumArtist, 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 } } } } } } } /// AirPlay route picker wrapped in a container UIView to prevent _UIReparentingView warnings. /// 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) {} } // 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) } } } } } .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() } } } } // 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)) .foregroundColor(accentPink) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text(song.title) .font(.system(size: 15, weight: .medium)) .foregroundColor(accentPink) .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)) .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) } } } .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)) .foregroundColor(.gray) } } .padding(.vertical, 4) } .onDelete(perform: deleteRecordings) } } } .navigationTitle("Recordings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } .foregroundColor(accentPink) } } .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 } 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 } } 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, albumArtist: nil, 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) } } // 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) } } // 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)" } } // MARK: - Shazam Recognizer /// Uses ShazamKit + AVAudioEngine mic tap for audio recognition. // MARK: - ShazamRecognizer (AVPlayer buffer tap — no microphone required) /// /// Architecture: /// AVPlayerItem → MTAudioProcessingTap (C callback) → analysisQueue /// analysisQueue → AVAudioConverter (→ 16kHz mono PCM) → SHSession.matchStreamingBuffer /// /// The tap is installed as an AVMutableAudioMix on the current player item once the /// player is playing. For non-AVPlayer paths (local engine) we fall back to the /// legacy microphone approach. class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { static let shared = ShazamRecognizer() @Published var isRecognizing = false private var session: SHSession? private var completion: ((ShazamMatch?) -> Void)? private var timeoutTask: Task? private var hasDeliveredResult = false // Tap state — uses shared AudioTapProcessor (no longer owns its own tap) private var converter: AVAudioConverter? private var sourceFormat: AVAudioFormat? private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)! private let analysisQueue = DispatchQueue(label: "com.navidrome.shazam", qos: .userInitiated) // Legacy mic fallback private var audioEngine: AVAudioEngine? private override init() { super.init() } // MARK: - Public entry point func recognize(completion: @escaping (ShazamMatch?) -> Void) { guard !isRecognizing else { return } stopAll() isRecognizing = true hasDeliveredResult = false self.completion = completion let shSession = SHSession() shSession.delegate = self session = shSession DebugLogger.shared.log("Shazam: starting recognition", category: "Audio") // Use the shared AudioTapProcessor — piggyback on FFT tap if active, // or install a new one. Fixes the old bug where Shazam's own tap // would overwrite the FFT visualizer tap (or vice versa). if AudioPlayer.shared.currentPlayerItem != nil { Task { @MainActor [weak self] in guard let self else { return } let tapProc = AudioTapProcessor.shared // Ensure the shared tap is installed (may already be active for FFT) if tapProc.sourceFormat == nil, let item = AudioPlayer.shared.currentPlayerItem { let _ = await tapProc.installTap(on: item) } // tapPrepare callback fires async on the audio thread after installTap. // Wait briefly for sourceFormat to be populated — without this, // sourceFormat is often nil and Shazam falls back to mic unnecessarily. if tapProc.sourceFormat == nil { try? await Task.sleep(for: .milliseconds(500)) } if let srcFormat = tapProc.sourceFormat { self.sourceFormat = srcFormat self.converter = AVAudioConverter(from: srcFormat, to: self.targetFormat) // Subscribe to the shared tap — receives buffers from render thread tapProc.shazamHandler = { [weak self] bufferList, frameCount in guard let self else { return } self.processTapBuffer(bufferList, frameCount: frameCount) } self.startTimeout(seconds: 15) DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio") } else { DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio") self.startMicFallback() } } } else { startMicFallback() } } // MARK: - Shared Tap Consumer private func removeTap() { // Unsubscribe from the shared tap — do NOT remove the audioMix // because the FFT visualizer may still be using it. AudioTapProcessor.shared.shazamHandler = nil converter = nil sourceFormat = nil } /// Called from the shared tap callback — dispatches to analysis queue to avoid blocking render thread. func processTapBuffer(_ bufferList: UnsafeMutablePointer, frameCount: CMItemCount) { guard let session, let conv = converter, let srcFmt = sourceFormat else { return } analysisQueue.async { [weak self] in guard let self else { return } self.convertAndMatch(bufferList: bufferList, frameCount: frameCount, converter: conv, sourceFormat: srcFmt, session: session) } } private func convertAndMatch( bufferList: UnsafeMutablePointer, frameCount: CMItemCount, converter: AVAudioConverter, sourceFormat: AVAudioFormat, session: SHSession ) { let capacity = AVAudioFrameCount(frameCount) guard capacity > 0 else { return } // Wrap the raw AudioBufferList in an AVAudioPCMBuffer guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: sourceFormat, frameCapacity: capacity) else { return } srcBuffer.frameLength = capacity let abl = UnsafeMutableAudioBufferListPointer(bufferList) for (i, buf) in abl.enumerated() { guard i < Int(sourceFormat.channelCount) else { break } let dst = srcBuffer.floatChannelData?[i] let src = buf.mData?.assumingMemoryBound(to: Float.self) let n = Int(buf.mDataByteSize) / MemoryLayout.size if let dst, let src { dst.initialize(from: src, count: n) } } // Output buffer for the converted audio let outputCapacity = AVAudioFrameCount(Double(capacity) * targetFormat.sampleRate / sourceFormat.sampleRate) + 16 guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputCapacity) else { return } var error: NSError? var inputConsumed = false converter.convert(to: outBuffer, error: &error) { _, inputStatus in if inputConsumed { inputStatus.pointee = .noDataNow return nil } inputConsumed = true inputStatus.pointee = .haveData return srcBuffer } if let error { DebugLogger.shared.log("Shazam: converter error \(error)", category: "Audio") return } if outBuffer.frameLength > 0 { session.matchStreamingBuffer(outBuffer, at: nil) } } // MARK: - Microphone fallback (local engine / engine path) private func startMicFallback() { DebugLogger.shared.log("Shazam: falling back to microphone", category: "Audio") 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)", category: "Audio") deliverResult(nil); return } let engine = AVAudioEngine() audioEngine = engine let inputNode = engine.inputNode let bus: AVAudioNodeBus = 0 let nativeFmt = inputNode.outputFormat(forBus: bus) guard nativeFmt.sampleRate > 0, nativeFmt.channelCount > 0 else { deliverResult(nil); return } guard let shSession = session else { deliverResult(nil); return } inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFmt) { [weak shSession] buf, time in shSession?.matchStreamingBuffer(buf, at: time) } do { engine.prepare(); try engine.start() } catch { deliverResult(nil); return } startTimeout(seconds: 12) } // MARK: - Timeout private func startTimeout(seconds: Int) { timeoutTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(seconds)) guard let self, !self.hasDeliveredResult else { return } DebugLogger.shared.log("Shazam: timeout after \(seconds)s", 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 ?? "" DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio") var previewURL: URL? if let firstSong = item.songs.first, let preview = firstSong.previewAssets?.first { previewURL = preview.url } DispatchQueue.main.async { self.deliverResult(ShazamMatch( title: title, artist: artist, artworkURL: item.artworkURL, previewURL: previewURL, appleMusicURL: item.appleMusicURL )) } } func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) { if let error { DebugLogger.shared.log("Shazam: segment no match — \(error)", category: "Audio") } } // MARK: - Cleanup private func deliverResult(_ result: ShazamMatch?) { guard !hasDeliveredResult else { return } hasDeliveredResult = true stopAll() isRecognizing = false completion?(result) completion = nil } private func stopAll() { timeoutTask?.cancel(); timeoutTask = nil removeTap() let usedMic = audioEngine != nil if let engine = audioEngine { if engine.inputNode.numberOfInputs > 0 { engine.inputNode.removeTap(onBus: 0) } engine.stop() audioEngine = nil } session = nil // Restore audio session only if we used the mic fallback if usedMic { try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) try? AVAudioSession.sharedInstance().setActive(true) } } 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 { 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 @State private var shareURL: String? @State private var isCreatingShare = false @State private var shareError: String? @State private var showCopiedToast = false 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") } // Share Link Section("SHARE") { if let url = shareURL { HStack { Text(url) .font(.system(size: 12)) .foregroundColor(accentPink) .lineLimit(2) Spacer() Button(action: { UIPasteboard.general.string = url withAnimation { showCopiedToast = true } Task { try? await Task.sleep(for: .seconds(2)) withAnimation { showCopiedToast = false } } }) { Image(systemName: showCopiedToast ? "checkmark" : "doc.on.clipboard") .font(.system(size: 14)) .foregroundColor(showCopiedToast ? .green : accentPink) } } // System share sheet if let shareLink = URL(string: url) { ShareLink(item: shareLink) { HStack { Image(systemName: "square.and.arrow.up") .font(.system(size: 14)) Text("Share via…") .font(.system(size: 14)) } .foregroundColor(accentPink) } } } else if isCreatingShare { HStack { ProgressView().tint(accentPink).scaleEffect(0.8) Text("Creating share link…") .font(.system(size: 13)) .foregroundColor(.gray) .padding(.leading, 8) } } else if let error = shareError { VStack(alignment: .leading, spacing: 4) { Text(error) .font(.system(size: 13)) .foregroundColor(.red) Button("Try Again") { Task { await createShareLink() } } .font(.system(size: 13, weight: .medium)) .foregroundColor(accentPink) } } else { Button(action: { Task { await createShareLink() } }) { HStack { Image(systemName: "link.badge.plus") .font(.system(size: 14)) Text("Create Share Link") .font(.system(size: 14)) } .foregroundColor(accentPink) } } } } .navigationTitle("Get Info") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() }.foregroundColor(accentPink) } } } } private func createShareLink() async { isCreatingShare = true shareError = nil do { // Expires in 7 days (ms since epoch) let expires = Int64((Date().timeIntervalSince1970 + 7 * 86400) * 1000) let share = try await ServerManager.shared.client.createShare( id: song.id, description: "\(song.title) — \(song.artist ?? "Unknown")", expires: expires ) if let url = share?.url { shareURL = url } else { shareError = "Server returned no share URL. Sharing may be disabled." } } catch { shareError = error.localizedDescription } isCreatingShare = false } 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)) } }