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? 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 // 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 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 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 { } } } 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 @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 { // Visualizer behind content (portrait) — extends to edges if !isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled { VStack { Spacer() MitsuhaVisualizerView( isPlaying: audioPlayer.isPlaying, accentColor: accentPink ) .frame(height: screenGeo.size.height * visSettings.nowPlayingHeightPct) .allowsHitTesting(false) } .ignoresSafeArea() } // Visualizer behind content (landscape) — extends to edges if isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled { VStack(spacing: 0) { Spacer() MitsuhaVisualizerView( isPlaying: audioPlayer.isPlaying, accentColor: accentPink ) .frame(maxWidth: .infinity) .frame(height: screenGeo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)) .allowsHitTesting(false) } .ignoresSafeArea() } 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: $showVisualizerSettings) { VisualizerSettingsView() } .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: $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: - Background private var backgroundGradient: some View { ZStack { // Layer 1: Deep black base Color.black.ignoresSafeArea() // Layer 2: Blurred album art mist — must be clipped to prevent layout overflow if let art = albumColors.currentImage { GeometryReader { geo in Image(uiImage: art) .resizable() .aspectRatio(contentMode: .fill) .frame(width: geo.size.width, height: geo.size.height) .clipped() .blur(radius: 85, opaque: true) .opacity(0.5) } .ignoresSafeArea() } // 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() } } // 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 { List { if !isRadio { Section { sheetButton("Instant Mix", icon: "wand.and.stars") { showEllipsisMenu = false if let song = audioPlayer.currentSong { audioPlayer.playInstantMix(basedOn: song) } } sheetButton("Add to Playlist...", icon: "text.badge.plus") { showEllipsisMenu = false Task { availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] showPlaylistPicker = true } } } Section { if audioPlayer.currentSong?.albumId != nil { sheetButton("Go to Album", icon: "square.stack") { showEllipsisMenu = false if let albumId = audioPlayer.currentSong?.albumId { NotificationCenter.default.post( name: .navigateToAlbum, object: nil, userInfo: ["albumId": albumId] ) } } } if audioPlayer.currentSong?.artistId != nil { sheetButton("Go to Artist", icon: "music.mic") { showEllipsisMenu = false if let artistId = audioPlayer.currentSong?.artistId { NotificationCenter.default.post( name: .navigateToArtist, object: nil, userInfo: ["artistId": artistId] ) } } } if let pid = audioPlayer.sourcePlaylistId, let pname = audioPlayer.sourcePlaylistName { sheetButton("Show in \(pname)", icon: "music.note.list") { 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] ) } } } } if let song = audioPlayer.currentSong { Section { if offlineManager.isSongDownloaded(song.id) { sheetButton("Remove Download", icon: "trash", role: .destructive) { showEllipsisMenu = false offlineManager.removeSong(song.id) } if WatchConnectivityManager.shared.isWatchAvailable { sheetButton("Send to Watch", icon: "applewatch.and.arrow.forward") { showEllipsisMenu = false _ = WatchConnectivityManager.shared.sendSongToWatch(song) } } } else { sheetButton("Download", icon: "arrow.down.circle") { showEllipsisMenu = false if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } } } } } } Section { sheetButton("Get Info", icon: "info.circle") { showEllipsisMenu = false showGetInfo = true } if CompanionSettings.shared.isEnabled { sheetButton("Edit Tags", icon: "tag") { showEllipsisMenu = false showTrackEditor = true } } sheetButton("Visualizer Settings", icon: "waveform") { showEllipsisMenu = false showVisualizerSettings = true } } } .listStyle(.insetGrouped) .navigationTitle("Options") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { showEllipsisMenu = false } .foregroundColor(accentPink) } } } } private func sheetButton(_ title: String, icon: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { Button(role: role, action: action) { Label(title, systemImage: icon) } } // 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 /// 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 private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { NavigationStack { List { ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in HStack(spacing: 12) { if index == audioPlayer.queueIndex { Image(systemName: "waveform").font(.system(size: 12)) .foregroundColor(accentPink).frame(width: 20) } else { Text("\(index + 1)").font(.system(size: 13)) .foregroundColor(.gray).frame(width: 20) } VStack(alignment: .leading, spacing: 2) { Text(song.title).font(.system(size: 15)) .foregroundColor(index == audioPlayer.queueIndex ? accentPink : .white) Text(song.artist ?? "").font(.system(size: 12)).foregroundColor(.gray) } Spacer() Text(song.durationFormatted).font(.system(size: 13)).foregroundColor(.gray) } .contentShape(Rectangle()) .onTapGesture { audioPlayer.play(song: song, at: index) } } } .navigationTitle("Up Next") .navigationBarTitleDisplayMode(.inline) } } } // 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, style: .date) .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, 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. class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate { 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() 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") 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 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") } } .navigationTitle("Get Info") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() }.foregroundColor(accentPink) } } } } 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)) } }