NavidromeApp/iOS/Views/NowPlaying/NowPlayingView.swift
Dallas Groot 5b71feebfd bug fixes
2026-04-10 17:17:12 -07:00

2037 lines
82 KiB
Swift

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 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
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
@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 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
// Bound directly to @Published currentTime/duration on AudioPlayer.
// No Timer.publish poller addPeriodicTimeObserver pushes updates on main queue.
private var playbackTime: TimeInterval { audioPlayer.currentTime }
private var playbackDuration: TimeInterval { audioPlayer.duration }
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var isLandscape: Bool {
vSizeClass == .compact
}
/// Whether the current song is a radio stream
private var isRadio: Bool {
audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio"
}
var body: some View {
GeometryReader { screenGeo in
ZStack {
visualizerLayer(geo: screenGeo)
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
}
} // end GeometryReader
.background { backgroundGradient }
.offset(y: dragOffset)
.gesture(
DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onChanged { value in
let isVertical = abs(value.translation.height) > abs(value.translation.width)
if value.translation.height > 0 && isVertical {
dragOffset = value.translation.height
}
}
.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])
}
.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
}
}
// MARK: - Portrait Layout
private var portraitLayout: some View {
VStack(spacing: 0) {
topBar
Spacer()
albumArt(size: 300)
Spacer()
songInfo
progressBar.padding(.top, 16)
transportControls.padding(.top, 20)
bottomControls.padding(.top, 12).padding(.bottom, 30)
}
}
// MARK: - Landscape Layout
private var landscapeLayout: some View {
GeometryReader { geo in
HStack(spacing: 0) {
// Left: Album art centered
VStack {
Spacer()
albumArt(size: min(200, geo.size.height - 40))
Spacer()
}
.frame(width: geo.size.width * 0.4)
// Right: Controls padded below Dynamic Island
VStack(spacing: 6) {
HStack {
Button(action: {
withAnimation(.easeInOut(duration: 0.35)) {
isPresented = false
}
}) {
Image(systemName: "chevron.down")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
}
Spacer()
ellipsisMenu
}
.padding(.horizontal, 16)
songInfo
progressBar.padding(.top, 6)
transportControls.padding(.top, 8)
bottomControls.padding(.top, 4).padding(.bottom, 8)
}
.frame(width: geo.size.width * 0.6)
}
}
}
// MARK: - Visualizer Layer
@ViewBuilder
private func visualizerLayer(geo: GeometryProxy) -> some View {
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,
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
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
} else {
try? await serverManager.client.star(id: song.id)
}
}
}
),
right: nil
)
}
Divider().padding(.horizontal, 16)
}
// Row 4: Get Info | Edit Tags
gridRow(
left: ("Get Info", "info.circle", {
showEllipsisMenu = false
showGetInfo = true
}),
right: CompanionSettings.shared.isEnabled
? ("Edit Tags", "tag", {
showEllipsisMenu = false
showTrackEditor = true
})
: nil
)
// Playlist link if playing from one
if let pid = audioPlayer.sourcePlaylistId,
let pname = audioPlayer.sourcePlaylistName {
Button(action: {
showEllipsisMenu = false
withAnimation(.easeInOut(duration: 0.35)) { isPresented = false }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: .navigateToPlaylist, object: nil, userInfo: ["playlistId": pid])
}
}) {
Label("Show in \(pname)", systemImage: "music.note.list")
.font(.system(size: 14))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
.padding(.horizontal, 16)
}
Spacer()
}
.padding(.top, 20)
}
.background(Color(white: 0.1))
.navigationTitle("Options")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { showEllipsisMenu = false }
.foregroundColor(accentPink)
}
}
}
}
/// Two-button grid row for the options sheet
private func gridRow(
left: (String, String, () -> Void),
right: (String, String, () -> Void)?
) -> some View {
HStack(spacing: 12) {
gridButton(title: left.0, icon: left.1, action: left.2)
if let r = right {
gridButton(title: r.0, icon: r.1, action: r.2)
} else {
Color.clear.frame(maxWidth: .infinity, minHeight: 70)
}
}
.padding(.horizontal, 16)
}
private func gridButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(accentPink)
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
}
.frame(maxWidth: .infinity, minHeight: 70)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
}
// MARK: - Album Art
private func albumArt(size: CGFloat) -> some View {
Group {
if let song = audioPlayer.currentSong,
song.album == "Radio",
let img = RadioCoverStore.shared.loadCover(for: song.id) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
AsyncCoverArt(coverArtId: audioPlayer.currentSong?.coverArt, size: Int(size) * 2)
}
}
.frame(width: size, height: size)
.cornerRadius(8)
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
}
// MARK: - Song Info
private var songInfo: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(audioPlayer.currentSong?.title ?? "Not Playing")
.font(.system(size: isLandscape ? 16 : 20, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: isLandscape ? 13 : 16))
.foregroundColor(accentPink)
.lineLimit(1)
.onTapGesture {
if let artistId = audioPlayer.currentSong?.artistId {
NotificationCenter.default.post(
name: .navigateToArtist,
object: nil,
userInfo: ["artistId": artistId]
)
}
}
// Show Shazam result if available
if let result = shazamResult {
Text(result)
.font(.system(size: isLandscape ? 11 : 13))
.foregroundColor(.white.opacity(0.7))
.lineLimit(2)
.transition(.opacity)
}
}
Spacer()
if !isRadio {
// Heart/star for regular music
Button(action: toggleStar) {
Image(systemName: isStarred ? "heart.fill" : "heart")
.font(.system(size: isLandscape ? 18 : 22))
.foregroundColor(isStarred ? accentPink : .gray)
}
}
}
.padding(.horizontal, isLandscape ? 20 : 30)
}
// MARK: - Progress Bar
private var progressBar: some View {
VStack(spacing: 6) {
if 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 {
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 {
// HLS: fully greyed, no scrubber thumb, no time labels
Capsule()
.fill(Color.white.opacity(0.08))
.frame(height: 4)
HStack {
Text("--:--")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.gray.opacity(0.35))
Spacer()
Text("--:--")
.font(.system(size: 11))
.foregroundColor(.gray.opacity(0.35))
}
} else if radioBuffer.isBuffering {
GeometryReader { geo in
let bufferSec = radioBuffer.estimatedBufferSeconds
let maxSec = radioBuffer.maxBufferDuration
let fillPct = min(max(bufferSec / maxSec, 0), 1.0)
let playheadPct: CGFloat = {
if isDraggingSlider { return dragPosition }
if audioPlayer.isPlayingFromBuffer {
return CGFloat(min(playbackTime / maxSec, fillPct))
}
return fillPct
}()
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.1)).frame(height: 4)
Capsule().fill(accentPink.opacity(0.3))
.frame(width: geo.size.width * fillPct, height: 4)
Circle()
.fill(radioBuffer.isLive ? accentPink : Color.white)
.frame(width: isDraggingSlider ? 16 : 12,
height: isDraggingSlider ? 16 : 12)
.offset(x: geo.size.width * playheadPct - (isDraggingSlider ? 8 : 6))
.animation(.interactiveSpring(), value: isDraggingSlider)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 8)
.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 seekPos = pct * maxSec
audioPlayer.radioSeekBack(to: seekPos)
}
)
}
.frame(height: 24)
HStack {
if isDraggingSlider {
let dragPosSec = dragPosition * radioBuffer.maxBufferDuration
let behindLive = max(0, radioBuffer.estimatedBufferSeconds - dragPosSec)
Text(behindLive < 1 ? "LIVE" : "-" + formatTime(behindLive))
.font(.system(size: 11, weight: .medium))
.foregroundColor(behindLive < 1 ? accentPink : .gray)
} else if radioBuffer.isLive {
Text("LIVE")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(accentPink)
} else {
let behindLive = max(0, radioBuffer.estimatedBufferSeconds - playbackTime)
Text("-" + formatTime(behindLive))
.font(.system(size: 11, weight: .medium))
.foregroundColor(.gray)
}
Spacer()
Text(formatTime(radioBuffer.bufferedDuration))
.font(.system(size: 11))
.foregroundColor(.gray)
}
} else {
// Buffer not yet ready greyed static bar (brief state on auto-start)
Capsule()
.fill(Color.white.opacity(0.1))
.frame(height: 4)
HStack {
Text("Buffering...")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.gray.opacity(0.5))
Spacer()
}
}
}
}
// 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 ? playbackTime : 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)
} 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()
// 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)
} 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()
}
}
}
/// 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()
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 }
}
}
}
}
}
}
/// 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,
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<Void, Never>?
private var hasDeliveredResult = false
// Tap state MTAudioProcessingTap is a CF type, ARC manages it directly
private var tap: MTAudioProcessingTap?
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")
// Prefer tap on AVPlayer async track load, then install tap on main actor
if let playerItem = AudioPlayer.shared.currentPlayerItem {
Task { @MainActor [weak self] in
guard let self else { return }
if await self.installTap(on: playerItem) {
self.startTimeout(seconds: 15)
DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio")
} else {
self.startMicFallback()
}
}
} else {
startMicFallback()
}
}
// MARK: - MTAudioProcessingTap
/// Async because `loadTracks(withMediaType:)` is the non-deprecated API (iOS 16+).
private func installTap(on playerItem: AVPlayerItem) async -> Bool {
// loadTracks is the non-deprecated replacement for tracks(withMediaType:)
guard let audioTrack = try? await playerItem.asset
.loadTracks(withMediaType: .audio).first else {
DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio")
return false
}
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
init: { tap, clientInfo, tapStorageOut in
tapStorageOut.pointee = clientInfo
},
finalize: nil,
prepare: { tap, _, processingFormat in
let storage = Unmanaged<ShazamRecognizer>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
let format = AVAudioFormat(streamDescription: processingFormat)
storage.analysisQueue.async {
storage.sourceFormat = format
if let src = format {
storage.converter = AVAudioConverter(from: src, to: storage.targetFormat)
}
}
},
unprepare: nil,
process: shazamTapProcess
)
// MTAudioProcessingTap is a CF type Swift bridges it as MTAudioProcessingTap?
var tapOut: MTAudioProcessingTap?
let status = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut
)
guard status == noErr, let tapValue = tapOut else {
DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio")
return false
}
tap = tapValue // ARC owns it
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
inputParams.audioTapProcessor = tapValue
let mix = AVMutableAudioMix()
mix.inputParameters = [inputParams]
playerItem.audioMix = mix
return true
}
/// Called from the C tap callback dispatches to analysis queue to avoid blocking render thread.
func processTapBuffer(_ bufferList: UnsafeMutablePointer<AudioBufferList>,
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<AudioBufferList>,
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<Float>.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)
}
}
private func removeTap() {
// Removing the audioMix disconnects the tap cleanly
if let item = AudioPlayer.shared.currentPlayerItem {
item.audioMix = nil
}
tap = nil
converter = nil
sourceFormat = 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()
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 audioEngine != nil {
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: - MTAudioProcessingTap C callback (must be a free function)
/// Must be a C-style free function cannot be a method or closure.
private func shazamTapProcess(
tap: MTAudioProcessingTap,
numberFrames: CMItemCount,
flags: MTAudioProcessingTapFlags,
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// Fetch audio from source passes samples through to the player unchanged
let status = MTAudioProcessingTapGetSourceAudio(
tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut)
guard status == noErr else { return }
// Forward to the recognizer on the analysis queue never block this render thread
let recognizer = Unmanaged<ShazamRecognizer>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
recognizer.processTapBuffer(bufferListInOut, frameCount: numberFramesOut.pointee)
}
// 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))
}
}