bug fixes

Songs Tab (SearchView.swift)
Default state now loads all songs alphabetically from the library via
getAlbumList2 → per-album song fetch, cached under "all_songs_sorted"
so subsequent opens are instant. The Download All banner shows song
count + already-downloaded count and queues only non-downloaded songs.
Every row uses .contextMenu (the long-press menu) with Play Now, Play
Next, Add to Queue, Download/Remove, Send to Watch, and Add to
Playlist — same pattern as Favourites. Watch and download badges
appear on each row. Searching ≥2 chars runs the server search and
shows artists/albums/songs in sections, then clears back to the full
list when the field is empty.
Keyboard Done Button
A single keyboardDoneButton() View extension in AsyncCoverArt.swift
calls UIApplication.shared.sendAction(resignFirstResponder:...)
globally — no @FocusState needed. Applied to: LoginView (all 4
fields), CompanionSettingsView (host/port), TrackEditorView
(checkField helper covers all tag fields), BatchAlbumEditorSheet
(editField helper), RadioView (name/URL), PlaylistsView (name fields),
MyMusicView (search), SearchView (via @FocusState + toolbar directly).
ShazamKit MTAudioProcessingTap
Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix
— works for HLS, radio, and any AVPlayer stream without touching the
microphone. The prepare callback captures the source format and builds
an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free
function (required by the API) calls
MTAudioProcessingTapGetSourceAudio then dispatches to a serial
analysisQueue — the render thread is never blocked. convertAndMatch
wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and
feeds SHSession.matchStreamingBuffer. Fallback to microphone
(AVAudioEngine) is kept for the local engine path where no
AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if
the mic fallback is ever hit.
This commit is contained in:
Dallas Groot 2026-04-10 16:55:09 -07:00
parent 00ffd7970e
commit 2bdac607b4
12 changed files with 1282 additions and 779 deletions

View file

@ -53,6 +53,8 @@ class AudioPlayer: NSObject, ObservableObject {
// MARK: - AVPlayer (for streaming)
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
/// Exposed for ShazamRecognizer tap installation
var currentPlayerItem: AVPlayerItem? { playerItem }
private var timeObserver: Any?
// MARK: - AVAudioEngine (for local files with FFT)
@ -84,7 +86,7 @@ class AudioPlayer: NSObject, ObservableObject {
// MARK: - Offline Visualizer
#if os(iOS)
private var offlineVisFrames: [[Float]] = []
private var offlineVisBuffer = VisFrameBuffer.empty
private var offlineVisTimer: Timer?
private var offlineVisFPS: Double = 30.0
#endif
@ -776,11 +778,12 @@ class AudioPlayer: NSObject, ObservableObject {
// (zeroed by pause) and the wave starts flat then slowly ramps up.
if isUsingOfflineVis {
// Offline vis: prime with the last-rendered frame at current position
if !offlineVisFrames.isEmpty {
if !offlineVisBuffer.isEmpty {
let dur = duration
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
let idx = min(Int(pct * Double(offlineVisFrames.count)), offlineVisFrames.count - 1)
setLevels(offlineVisFrames[idx])
let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1)
offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels)
levelTick &+= 1
}
startOfflineVisSync()
} else if isUsingRealFFT, !isUsingOfflineVis {
@ -1179,10 +1182,10 @@ class AudioPlayer: NSObject, ObservableObject {
if hasCache {
alog("Offline vis: loading cache for \(songId)")
if let frames = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisFrames(frames)
if let buffer = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisBuffer(buffer)
await MainActor.run {
self.offlineVisFrames = normalized
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
@ -1195,11 +1198,17 @@ class AudioPlayer: NSObject, ObservableObject {
if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path {
do {
if let serverFrames = try await CompanionAPIService().fetchVisualizerFrames(relativePath: path) {
let normalized = Self.normalizeVisFrames(serverFrames)
try? await storage.saveCache(frames: serverFrames, for: songId)
// Convert [[Float]] to flat buffer then normalize
let ppf = serverFrames.first?.count ?? 0
var flat = ContiguousArray<Float>()
flat.reserveCapacity(serverFrames.count * ppf)
for frame in serverFrames { flat.append(contentsOf: frame) }
let rawBuf = VisFrameBuffer(data: flat, frameCount: serverFrames.count, pointsPerFrame: ppf)
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: fetched \(serverFrames.count) frames from server")
await MainActor.run {
self.offlineVisFrames = normalized
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
@ -1251,11 +1260,17 @@ class AudioPlayer: NSObject, ObservableObject {
let frames = result.visFrames
try? await storage.saveCache(frames: frames, for: songId)
let normalized = Self.normalizeVisFrames(frames)
// Convert [[Float]] to flat VisFrameBuffer then normalize
let ppf = frames.first?.count ?? 0
var flat = ContiguousArray<Float>()
flat.reserveCapacity(frames.count * ppf)
for frame in frames { flat.append(contentsOf: frame) }
let rawBuf = VisFrameBuffer(data: flat, frameCount: frames.count, pointsPerFrame: ppf)
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
self.offlineVisFrames = normalized
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
self.offlineVisProgress = 1.0
@ -1272,32 +1287,28 @@ class AudioPlayer: NSObject, ObservableObject {
/// Normalize vis frames so the wave is always visible regardless of song loudness.
/// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping.
private static func normalizeVisFrames(_ frames: [[Float]]) -> [[Float]] {
guard !frames.isEmpty else { return frames }
// Collect all non-zero values to find the peak
var allValues: [Float] = []
allValues.reserveCapacity(frames.count * (frames.first?.count ?? 20))
for frame in frames {
for v in frame where v > 0.001 {
allValues.append(v)
}
}
guard !allValues.isEmpty else { return frames }
// Use 95th percentile instead of absolute max to avoid outlier spikes
allValues.sort()
let p95Index = min(Int(Float(allValues.count) * 0.95), allValues.count - 1)
let p95 = allValues[p95Index]
guard p95 > 0.001 else { return frames }
let scale = 0.8 / p95
return frames.map { frame in
frame.map { min(1.0, $0 * scale) }
/// Normalize a VisFrameBuffer to p95 amplitude in-place.
/// One sort + one pass no [[Float]] allocation.
private static func normalizeVisBuffer(_ buffer: VisFrameBuffer) -> VisFrameBuffer {
guard buffer.frameCount > 0 else { return buffer }
// Collect non-zero values to find p95
var vals = buffer.data.filter { $0 > 0.001 }
guard !vals.isEmpty else { return buffer }
vals.sort()
let p95 = vals[min(Int(Float(vals.count) * 0.95), vals.count - 1)]
guard p95 > 0.001 else { return buffer }
let scale = Float(0.8) / p95
var normalized = buffer.data // single ContiguousArray copy
for i in 0..<normalized.count {
normalized[i] = min(1.0, normalized[i] * scale)
}
return VisFrameBuffer(
data: normalized,
frameCount: buffer.frameCount,
pointsPerFrame: buffer.pointsPerFrame
)
}
/// Sync cached frames to current playback time.
@ -1307,10 +1318,9 @@ class AudioPlayer: NSObject, ObservableObject {
stopOfflineVisTimer()
stopLevelTimer()
let frameCount = offlineVisFrames.count
let frameCount = offlineVisBuffer.frameCount
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
// Wait briefly for duration to become available if it's still 0
if duration <= 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.startOfflineVisSyncTimer()
@ -1322,35 +1332,35 @@ class AudioPlayer: NSObject, ObservableObject {
private func startOfflineVisSyncTimer() {
stopOfflineVisTimer()
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
guard let self = self, self.isUsingOfflineVis else { return }
guard self.isPlaying else {
// Keep pulsing zeros so levelTick increments and the visualizer can decay
// Zero out levels so the visualizer decays during pause
if self._audioLevels.contains(where: { $0 > 0.005 }) {
self.setLevels(Array(repeating: 0, count: 30))
for i in 0..<self._audioLevels.count { self._audioLevels[i] = 0 }
self.levelTick &+= 1
}
return
}
let frames = self.offlineVisFrames
guard !frames.isEmpty else { return }
let dur = self.duration
let buf = self.offlineVisBuffer
guard buf.frameCount > 0 else { return }
let dur = self.duration
let time = self.currentTime
// Map playback position to frame index proportionally
let frameIndex: Int
if dur > 0 {
let pct = min(time / dur, 1.0)
frameIndex = min(Int(pct * Double(frames.count)), frames.count - 1)
frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1)
} else {
// Duration still unknown estimate at 30fps
frameIndex = min(Int(time * 30.0), frames.count - 1)
}
if frameIndex >= 0, frameIndex < frames.count {
self.setLevels(frames[frameIndex])
frameIndex = min(Int(time * 30.0), buf.frameCount - 1)
}
// Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels
buf.copyFrame(at: frameIndex, into: &self._audioLevels)
self.levelTick &+= 1
}
}
@ -1458,7 +1468,7 @@ class AudioPlayer: NSObject, ObservableObject {
isUsingRealFFT = false
isUsingOfflineVis = false
#if os(iOS)
offlineVisFrames = []
offlineVisBuffer = VisFrameBuffer.empty
#endif
if isRadioStream {
isRadioStream = false

View file

@ -391,3 +391,24 @@ struct AsyncCoverArt: View {
}
}
}
// MARK: - Keyboard Done Button
/// Adds a "Done" button above the keyboard on any TextField or SecureField.
/// Usage: TextField(...).keyboardDoneButton()
extension View {
func keyboardDoneButton() -> some View {
self.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333))
.fontWeight(.medium)
}
}
}
}

View file

@ -249,7 +249,4 @@ struct BatchAlbumEditorSheet: View {
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
}
.font(.system(size: 14))
}
}
.keyboardDoneButton()

View file

@ -288,16 +288,7 @@ struct TrackEditorView: View {
.multilineTextAlignment(.trailing)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
.disabled(!isEnabled.wrappedValue)
}
.font(.system(size: 14))
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundColor(.gray)
Spacer()
Text(value).foregroundColor(.white.opacity(0.7)).lineLimit(1)
}
.keyboardDoneButton()
.font(.system(size: 13))
}
}

View file

@ -647,7 +647,7 @@ struct SettingsView: View {
Text("Visualizer Appearance")
.foregroundColor(.white)
Spacer()
Text(VisualizerSettings.shared.style.rawValue)
Text(VisualizerSettings.shared.nowPlaying.style.rawValue)
.foregroundColor(.gray)
Image(systemName: "chevron.right")
.font(.system(size: 12))
@ -856,7 +856,7 @@ struct SmartDJVisualizerSettingsView: View {
analysisProgress = "Scanning..."
let downloaded = OfflineManager.shared.downloadedSongs
let points = VisualizerSettings.shared.numberOfPoints
let points = VisualizerSettings.shared.nowPlaying.numberOfPoints
let fps = VisualizerSettings.shared.effectiveFPS
let cutoff = VisualizerSettings.shared.frequencyCutoff

View file

@ -235,6 +235,7 @@ struct MyMusicView: View {
.font(.system(size: 15))
.foregroundColor(.white)
.autocorrectionDisabled()
.keyboardDoneButton()
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")

View file

@ -104,6 +104,7 @@ struct CreatePlaylistAlert: View {
var body: some View {
TextField("Playlist name", text: $name)
.keyboardDoneButton()
Button("Create") {
Task { try? await serverManager.client.createPlaylist(name: name) }
}

View file

@ -162,7 +162,9 @@ struct RadioView: View {
}
.alert("Add Radio Station", isPresented: $showAddStation) {
TextField("Station Name", text: $newStationName)
.keyboardDoneButton()
TextField("Stream URL", text: $newStationURL)
.keyboardDoneButton()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Add") {

View file

@ -5,236 +5,351 @@ struct SearchView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var libraryCache = LibraryCache.shared
@State private var searchText = ""
@State private var artists: [Artist] = []
@State private var albums: [Album] = []
@State private var songs: [Song] = []
@State private var isSearching = false
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@State private var searchText = ""
@State private var searchArtists: [Artist] = []
@State private var searchAlbums: [Album] = []
@State private var searchSongs: [Song] = []
@State private var isSearching = false
// All-songs browse state
@State private var allSongs: [Song] = []
@State private var isLoadingSongs = false
@State private var songsLoaded = false
// Long-press menu
@State private var menuSong: Song? = nil
@State private var showMenu = false
@State private var playlistPickerSongId: String? = nil
@State private var availablePlaylists: [Playlist] = []
@State private var showPlaylistPicker = false
@FocusState private var searchFocused: Bool
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var isSearchActive: Bool { !searchText.isEmpty }
// MARK: - Body
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Artists, Songs, Albums", text: $searchText)
.foregroundColor(.white)
.autocapitalization(.none)
.disableAutocorrection(true)
.onSubmit { performSearch() }
.onChange(of: searchText) { oldValue, newValue in
if newValue.count >= 2 {
performSearch()
}
}
if !searchText.isEmpty {
Button(action: {
searchText = ""
artists = []; albums = []; songs = []
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
VStack(spacing: 0) {
// Search bar
HStack(spacing: 10) {
Image(systemName: "magnifyingglass").foregroundColor(.gray)
TextField("Artists, Songs, Albums", text: $searchText)
.foregroundColor(.white)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($searchFocused)
.onSubmit { performSearch() }
.onChange(of: searchText) { _, new in
if new.count >= 2 { performSearch() }
else if new.isEmpty { clearSearch() }
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { searchFocused = false }
.foregroundColor(accentPink)
}
}
if !searchText.isEmpty {
Button(action: { searchText = ""; clearSearch() }) {
Image(systemName: "xmark.circle.fill").foregroundColor(.gray)
}
}
.padding(10)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.top, 10)
if isSearching {
ProgressView().tint(accentPink).padding(.top, 40)
} else if !searchText.isEmpty {
searchResults
} else {
emptyState
}
Color.clear.frame(height: 120)
}
.padding(10)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 8)
if isSearchActive {
searchResultsView
} else {
allSongsView
}
}
.background(Color(white: 0.06))
.navigationTitle("Search")
.navigationTitle("Songs")
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
.task { await loadAllSongs() }
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("Search your library")
.font(.system(size: 16))
.foregroundColor(.gray)
}
.padding(.top, 80)
}
private var searchResults: some View {
LazyVStack(alignment: .leading, spacing: 0) {
// Artists
if !artists.isEmpty {
sectionHeader("Artists")
ForEach(artists) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: artist.coverArt, size: 44)
.frame(width: 44, height: 44)
.clipShape(Circle())
Text(artist.name)
.font(.system(size: 15))
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
// MARK: - All Songs Browse
private var allSongsView: some View {
Group {
if isLoadingSongs {
Spacer()
ProgressView().tint(accentPink)
Spacer()
} else if allSongs.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: "music.note.list")
.font(.system(size: 40)).foregroundColor(.gray)
Text("No songs found")
.font(.system(size: 16)).foregroundColor(.gray)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 0) {
// Download All banner (offline only explicit user action)
downloadAllBanner
ForEach(allSongs) { song in
songRow(song, queue: allSongs)
Divider()
.background(Color.white.opacity(0.06))
.padding(.leading, 72)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
Color.clear.frame(height: 120)
}
}
}
// Albums
if !albums.isEmpty {
sectionHeader("Albums")
ForEach(albums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: album.coverArt, size: 48)
.frame(width: 48, height: 48)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
private var downloadAllBanner: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(allSongs.count) Songs")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
let downloaded = allSongs.filter { offlineManager.isSongDownloaded($0.id) }.count
if downloaded > 0 {
Text("\(downloaded) downloaded")
.font(.system(size: 11)).foregroundColor(.gray)
}
}
// Songs
if !songs.isEmpty {
sectionHeader("Songs")
ForEach(songs) { song in
let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: songs)
}
}) {
HStack(spacing: 12) {
ZStack {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.5))
.frame(width: 44, height: 44)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.frame(width: 24, height: 24)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.4))
.frame(width: 44, height: 44)
ProgressView().tint(accentPink).scaleEffect(0.6)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
Text("\(song.artist ?? "")\(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
Spacer()
Button(action: downloadAll) {
HStack(spacing: 5) {
Image(systemName: "arrow.down.circle")
.font(.system(size: 14))
Text("Download All")
.font(.system(size: 13, weight: .medium))
}
.foregroundColor(accentPink)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(accentPink.opacity(0.12))
.cornerRadius(16)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.white.opacity(0.03))
}
// MARK: - Search Results
private var searchResultsView: some View {
Group {
if isSearching {
Spacer()
ProgressView().tint(accentPink)
Spacer()
} else if searchArtists.isEmpty && searchAlbums.isEmpty && searchSongs.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 36)).foregroundColor(.gray)
Text("No results for \"\(searchText)\"")
.font(.system(size: 15)).foregroundColor(.gray)
}
Spacer()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if !searchArtists.isEmpty {
sectionHeader("Artists")
ForEach(searchArtists) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: artist.coverArt, size: 44)
.frame(width: 44, height: 44).clipShape(Circle())
Text(artist.name)
.font(.system(size: 15)).foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12)).foregroundColor(.gray)
}
.frame(height: 2)
.padding(.horizontal, 16).padding(.vertical, 8)
}
}
Spacer()
HStack(spacing: 4) {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 9))
.foregroundColor(.blue.opacity(0.7))
}
if isDownloaded {
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.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
if !searchAlbums.isEmpty {
sectionHeader("Albums")
ForEach(searchAlbums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: album.coverArt, size: 48)
.frame(width: 48, height: 48).cornerRadius(4)
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 15)).foregroundColor(.white).lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 12)).foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12)).foregroundColor(.gray)
}
.padding(.horizontal, 16).padding(.vertical, 8)
}
}
}
if !searchSongs.isEmpty {
sectionHeader("Songs")
ForEach(searchSongs) { song in
songRow(song, queue: searchSongs)
Divider()
.background(Color.white.opacity(0.06))
.padding(.leading, 72)
}
}
Color.clear.frame(height: 120)
}
}
}
}
}
// MARK: - Song Row (shared, with long-press menu)
private func songRow(_ song: Song, queue: [Song]) -> some View {
let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id]
let isCurrent = audioPlayer.currentSong?.id == song.id
return Button(action: {
if available { audioPlayer.play(song: song, fromQueue: queue) }
}) {
HStack(spacing: 12) {
// Artwork + download progress overlay
ZStack {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 44, height: 44).cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.5)).frame(width: 44, height: 44)
Circle().trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.frame(width: 24, height: 24).rotationEffect(.degrees(-90))
} else if case .queued = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.4)).frame(width: 44, height: 44)
ProgressView().tint(accentPink).scaleEffect(0.6)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(!available ? .gray.opacity(0.35) : isCurrent ? accentPink : .white)
.lineLimit(1)
Text("\(song.artist ?? "")\(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
}.frame(height: 2)
}
}
Spacer()
// Status badges
HStack(spacing: 4) {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 9)).foregroundColor(.blue.opacity(0.7))
}
if isDownloaded {
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.3))
}
.padding(.horizontal, 16).padding(.vertical, 9)
}
.contextMenu {
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Label("Add to Queue", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
if isDownloaded {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
} else {
Button(action: {
if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}) {
Label("Download", systemImage: "arrow.down.circle")
}
}
Button(action: {
if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) }
}) {
Label(isOnWatch ? "On Watch ✓" : "Send to Watch",
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward")
}
.disabled(isOnWatch)
Divider()
Button(action: {
playlistPickerSongId = song.id
Task {
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
showPlaylistPicker = true
}
}) {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
}
}
// MARK: - Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 8)
.font(.system(size: 20, weight: .bold)).foregroundColor(.white)
.padding(.horizontal, 16).padding(.top, 20).padding(.bottom, 8)
}
private func clearSearch() {
searchArtists = []; searchAlbums = []; searchSongs = []
}
private func performSearch() {
guard searchText.count >= 2 else { return }
isSearching = true
@ -242,14 +357,62 @@ struct SearchView: View {
do {
let result = try await serverManager.client.search3(query: searchText)
await MainActor.run {
artists = result?.artist ?? []
albums = result?.album ?? []
songs = result?.song ?? []
isSearching = false
searchArtists = result?.artist ?? []
searchAlbums = result?.album ?? []
searchSongs = result?.song ?? []
isSearching = false
}
} catch {
await MainActor.run { isSearching = false }
}
}
}
private func loadAllSongs() async {
guard !songsLoaded else { return }
isLoadingSongs = true
// Try loading from cache first for instant display
if let cached = libraryCache.load([Song].self, key: "all_songs_sorted") {
await MainActor.run {
allSongs = cached
isLoadingSongs = false
songsLoaded = true
}
return
}
// Fetch all albums, then their songs, sorted alphabetically by title
do {
var offset = 0
var collected: [Song] = []
while true {
let albums = try await serverManager.client.getAlbumList2(
type: "alphabeticalByName", size: 500, offset: offset)
for album in albums {
if let detail = try? await serverManager.client.getAlbum(id: album.id) {
collected.append(contentsOf: detail.song ?? [])
}
}
if albums.count < 500 { break }
offset += 500
}
let sorted = collected.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
libraryCache.save(sorted, key: "all_songs_sorted")
await MainActor.run {
allSongs = sorted
isLoadingSongs = false
songsLoaded = true
}
} catch {
await MainActor.run { isLoadingSongs = false }
}
}
private func downloadAll() {
guard let server = serverManager.activeServer else { return }
for song in allSongs where !offlineManager.isSongDownloaded(song.id) {
offlineManager.downloadSong(song, server: server)
}
}
}

View file

@ -221,6 +221,7 @@ struct AddServerSheet: View {
TextField("My Server", text: $name)
.textFieldStyle(DarkFieldStyle())
.keyboardDoneButton()
}
// URL
@ -234,6 +235,7 @@ struct AddServerSheet: View {
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardDoneButton()
}
// Username
@ -246,6 +248,7 @@ struct AddServerSheet: View {
.textFieldStyle(DarkFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardDoneButton()
}
// Password
@ -256,6 +259,7 @@ struct AddServerSheet: View {
SecureField("password", text: $password)
.textFieldStyle(DarkFieldStyle())
.keyboardDoneButton()
}
// Test Connection

View file

@ -1547,170 +1547,330 @@ struct ShazamMatch {
// 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 audioEngine: AVAudioEngine?
private var completion: ((ShazamMatch?) -> Void)?
private var timeoutTask: Task<Void, Never>?
private var session: SHSession?
private var completion: ((ShazamMatch?) -> Void)?
private var timeoutTask: Task<Void, Never>?
private var hasDeliveredResult = false
// Tap state
private var tap: Unmanaged<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() }
/// Start recognition via microphone using matchStreamingBuffer
// MARK: - Public entry point
func recognize(completion: @escaping (ShazamMatch?) -> Void) {
guard !isRecognizing else { return }
stopListening()
isRecognizing = true
stopAll()
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()
self.completion = completion
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)
session = shSession
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
// Prefer tap on AVPlayer (radio / stream / local AVPlayer path)
if let playerItem = AudioPlayer.shared.currentPlayerItem {
if installTap(on: playerItem) {
startTimeout(seconds: 15)
DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio")
return
}
}
// Fallback: microphone for local engine path
startMicFallback()
}
// MARK: - MTAudioProcessingTap
private func installTap(on playerItem: AVPlayerItem) -> Bool {
guard let audioTrack = playerItem.asset.tracks(withMediaType: .audio).first else {
DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio")
return false
}
// Store self as opaque pointer in tap storage
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
init: { tap, clientInfo, tapStorageOut in
tapStorageOut.pointee = clientInfo
},
finalize: nil,
prepare: { tap, maxFrames, processingFormat in
// Capture the source format so we can build the converter on first buffer
let storage = Unmanaged<ShazamRecognizer>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
let format = AVAudioFormat(streamDescription: processingFormat)
storage.analysisQueue.async {
storage.sourceFormat = format
if let src = format, let dst = storage.targetFormat as AVAudioFormat? {
storage.converter = try? AVAudioConverter(from: src, to: dst)
}
}
},
unprepare: nil,
process: shazamTapProcess
)
var newTap: Unmanaged<MTAudioProcessingTap>?
let status = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PostEffects, &newTap
)
guard status == noErr, let newTap else {
DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio")
return false
}
tap = newTap
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
inputParams.audioTapProcessor = newTap.takeRetainedValue()
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 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
var previewURL: URL?
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,
self.deliverResult(ShazamMatch(
title: title, artist: artist,
artworkURL: item.artworkURL,
previewURL: previewURL,
appleMusicURL: appleMusicURL
)
self.deliverResult(result)
appleMusicURL: item.appleMusicURL
))
}
}
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")
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
stopListening()
stopAll()
isRecognizing = false
completion?(result)
completion = nil
}
private func stopListening() {
timeoutTask?.cancel()
timeoutTask = nil
private func stopAll() {
timeoutTask?.cancel(); timeoutTask = nil
removeTap()
if let engine = audioEngine {
engine.inputNode.removeTap(onBus: 0)
if engine.inputNode.numberOfInputs > 0 {
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")
// 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

File diff suppressed because it is too large Load diff