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:
parent
00ffd7970e
commit
2bdac607b4
12 changed files with 1282 additions and 779 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,4 @@ struct BatchAlbumEditorSheet: View {
|
|||
TextField(label, text: text)
|
||||
.keyboardType(keyboard)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.keyboardDoneButton()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue