2026-03-28 13:49:47 -07:00
|
|
|
|
import Foundation
|
|
|
|
|
|
import AVFoundation
|
|
|
|
|
|
import Combine
|
|
|
|
|
|
import MediaPlayer
|
|
|
|
|
|
import Accelerate
|
|
|
|
|
|
import os
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
import UIKit
|
2026-04-12 12:57:42 -07:00
|
|
|
|
import WidgetKit
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
/// Shared audio player with real FFT visualizer for local files
|
|
|
|
|
|
class AudioPlayer: NSObject, ObservableObject {
|
|
|
|
|
|
|
|
|
|
|
|
static let shared = AudioPlayer()
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Published State
|
|
|
|
|
|
@Published var currentSong: Song?
|
|
|
|
|
|
@Published var queue: [Song] = []
|
|
|
|
|
|
@Published var queueIndex: Int = 0
|
|
|
|
|
|
@Published var isPlaying = false
|
2026-04-09 23:11:40 -07:00
|
|
|
|
// currentTime and duration are @Published but set only from addPeriodicTimeObserver
|
|
|
|
|
|
// on the main queue — safe for SwiftUI observation. NowPlayingView binds directly,
|
|
|
|
|
|
// eliminating the Timer.publish poller that caused runloop starvation.
|
2026-04-11 16:44:56 -07:00
|
|
|
|
// currentTime and duration are intentionally NOT @Published.
|
|
|
|
|
|
// @Published fires objectWillChange on AudioPlayer 10-20x/second, causing
|
|
|
|
|
|
// every @EnvironmentObject/ObservedObject subscriber (NowPlayingView,
|
|
|
|
|
|
// MiniPlayerBar, MyMusicView etc.) to re-evaluate their entire bodies at
|
|
|
|
|
|
// that rate. Progress bars use TimelineView(.periodic) instead, which reads
|
|
|
|
|
|
// these values directly on each tick without triggering objectWillChange.
|
|
|
|
|
|
// Both are written and read exclusively on the main queue — no race risk.
|
|
|
|
|
|
var currentTime: TimeInterval = 0
|
|
|
|
|
|
var duration: TimeInterval = 0
|
2026-03-28 13:49:47 -07:00
|
|
|
|
@Published var volume: Float = 0.8
|
|
|
|
|
|
@Published var repeatMode: RepeatMode = .off
|
|
|
|
|
|
@Published var shuffleEnabled = false
|
2026-04-10 18:05:10 -07:00
|
|
|
|
// audioLevels is NOT @Published — avoids SwiftUI state thrashing.
|
|
|
|
|
|
// The Canvas reads currentLevels() directly on every TimelineView tick.
|
|
|
|
|
|
// levelTick is kept as an internal counter but intentionally NOT @Published —
|
|
|
|
|
|
// making it @Published caused 30fps objectWillChange on AudioPlayer, forcing
|
|
|
|
|
|
// every observing view (NowPlayingView, MainTabView, etc.) to re-evaluate 30x/sec.
|
|
|
|
|
|
private(set) var levelTick: Int = 0
|
2026-03-28 13:49:47 -07:00
|
|
|
|
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
|
2026-04-05 12:05:20 -07:00
|
|
|
|
|
|
|
|
|
|
func currentLevels() -> [Float] { _audioLevels }
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
private func setLevels(_ levels: [Float]) {
|
|
|
|
|
|
_audioLevels = levels
|
2026-04-05 12:05:20 -07:00
|
|
|
|
levelTick &+= 1
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Published var isUsingRealFFT = false
|
|
|
|
|
|
@Published var isUsingOfflineVis = false
|
|
|
|
|
|
@Published var offlineVisProgress: Float = 0
|
|
|
|
|
|
@Published var sourcePlaylistId: String?
|
|
|
|
|
|
@Published var sourcePlaylistName: String?
|
|
|
|
|
|
|
|
|
|
|
|
enum RepeatMode {
|
|
|
|
|
|
case off, all, one
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - AVPlayer (for streaming)
|
|
|
|
|
|
private var player: AVPlayer?
|
|
|
|
|
|
private var playerItem: AVPlayerItem?
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
/// Exposed for ShazamRecognizer tap installation
|
|
|
|
|
|
var currentPlayerItem: AVPlayerItem? { playerItem }
|
2026-03-28 13:49:47 -07:00
|
|
|
|
private var timeObserver: Any?
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - AVAudioEngine (for local files with FFT)
|
|
|
|
|
|
private var audioEngine: AVAudioEngine?
|
|
|
|
|
|
private var enginePlayerNode: AVAudioPlayerNode?
|
|
|
|
|
|
private var engineTimeTimer: Timer?
|
|
|
|
|
|
private var engineFile: AVAudioFile?
|
|
|
|
|
|
private var engineStartFrame: AVAudioFramePosition = 0
|
|
|
|
|
|
private var scheduleGeneration: Int = 0
|
|
|
|
|
|
private var nowPlayingSyncTimer: Timer?
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// When true, SmartCrossfadeManager owns playback instead of the local player/engine
|
|
|
|
|
|
private var isUsingCrossfade = false
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - FFT (radix-2, vDSP_fft_zrip)
|
|
|
|
|
|
private var fftSetup: FFTSetup?
|
|
|
|
|
|
// Written from audio render thread, read from Timer on main thread.
|
|
|
|
|
|
// No lock needed: stale FFT frame is imperceptible at 30fps.
|
|
|
|
|
|
nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512)
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
|
|
|
|
|
// Pre-allocated FFT scratch buffers — allocated once in init, reused every render callback.
|
|
|
|
|
|
// Heap allocation from a real-time audio thread is forbidden (locks inside malloc can
|
|
|
|
|
|
// block indefinitely and cause the hardware deadline to be missed → audio glitch).
|
|
|
|
|
|
private let fftSize: vDSP_Length = 1024
|
|
|
|
|
|
nonisolated(unsafe) private var fftWindow: [Float] // Hann window coefficients — constant
|
|
|
|
|
|
nonisolated(unsafe) private var fftWindowed: [Float] // windowed input scratch
|
|
|
|
|
|
nonisolated(unsafe) private var fftRealp: [Float] // split-complex real part
|
|
|
|
|
|
nonisolated(unsafe) private var fftImagp: [Float] // split-complex imag part
|
|
|
|
|
|
nonisolated(unsafe) private var fftMagnitudes: [Float] // output magnitudes
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
// MARK: - Level Simulation (for streams)
|
|
|
|
|
|
private var levelTimer: Timer?
|
|
|
|
|
|
private var targetLevels: [Float] = Array(repeating: 0, count: 30)
|
|
|
|
|
|
private var internalLevels: [Float] = Array(repeating: 0, count: 30)
|
|
|
|
|
|
private var publishCounter: Int = 0
|
|
|
|
|
|
private var lastTargetPhase: Int = 0
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Offline Visualizer
|
|
|
|
|
|
#if os(iOS)
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
private var offlineVisBuffer = VisFrameBuffer.empty
|
2026-03-28 13:49:47 -07:00
|
|
|
|
private var offlineVisTimer: Timer?
|
|
|
|
|
|
private var offlineVisFPS: Double = 30.0
|
2026-04-10 17:50:26 -07:00
|
|
|
|
private var analysisTask: Task<Void, Never>?
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Now Playing Artwork
|
|
|
|
|
|
private var cachedArtwork: MPMediaItemArtwork?
|
|
|
|
|
|
private var cachedArtworkCoverArtId: String?
|
|
|
|
|
|
|
|
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Init
|
|
|
|
|
|
|
|
|
|
|
|
private override init() {
|
|
|
|
|
|
// Radix-2 FFT setup for 1024 samples (log2(1024) = 10)
|
|
|
|
|
|
fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2))
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
|
|
|
|
|
// Pre-allocate all FFT scratch buffers and compute the constant Hann window.
|
|
|
|
|
|
// These are reused on every render callback — zero heap allocation at render time.
|
|
|
|
|
|
let halfSize = Int(1024 / 2)
|
|
|
|
|
|
fftWindow = [Float](repeating: 0, count: 1024)
|
|
|
|
|
|
fftWindowed = [Float](repeating: 0, count: 1024)
|
|
|
|
|
|
fftRealp = [Float](repeating: 0, count: halfSize)
|
|
|
|
|
|
fftImagp = [Float](repeating: 0, count: halfSize)
|
|
|
|
|
|
fftMagnitudes = [Float](repeating: 0, count: halfSize)
|
|
|
|
|
|
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
super.init()
|
|
|
|
|
|
configureAudioSession()
|
|
|
|
|
|
setupRemoteControls()
|
2026-04-03 15:48:37 -07:00
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
// Refresh lock screen artwork when custom cover changes
|
|
|
|
|
|
AlbumCoverStore.shared.$updateTrigger
|
|
|
|
|
|
.sink { [weak self] _ in
|
|
|
|
|
|
guard let self, let id = self.currentSong?.coverArt else { return }
|
2026-04-10 17:39:46 -07:00
|
|
|
|
self.cachedArtworkCoverArtId = nil
|
2026-04-03 15:48:37 -07:00
|
|
|
|
self.fetchAndSetArtwork(coverArtId: id)
|
|
|
|
|
|
}
|
|
|
|
|
|
.store(in: &cancellables)
|
2026-04-10 17:39:46 -07:00
|
|
|
|
|
|
|
|
|
|
// Stop all visualizer timers when backgrounded — they serve no purpose without
|
|
|
|
|
|
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
|
|
|
|
|
|
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
|
|
|
|
|
.sink { [weak self] _ in self?.suspendVisTimers() }
|
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
|
|
|
|
|
|
// Restart the correct timer when returning to foreground
|
|
|
|
|
|
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
|
|
|
|
|
.sink { [weak self] _ in self?.resumeVisTimers() }
|
|
|
|
|
|
.store(in: &cancellables)
|
2026-04-11 15:09:06 -07:00
|
|
|
|
|
|
|
|
|
|
// ── AVAudioSession interruption handling ──────────────────────────────
|
|
|
|
|
|
// Required for correct behaviour during phone calls, Siri, alarms, and
|
|
|
|
|
|
// other apps taking the audio session. Without this, playback stops but
|
|
|
|
|
|
// isPlaying stays true — timers keep firing and music never auto-resumes.
|
|
|
|
|
|
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
|
|
|
|
|
|
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
|
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Audio route change (headphones unplugged etc.) ────────────────────
|
|
|
|
|
|
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
|
|
|
|
|
|
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
|
|
|
|
|
|
.sink { [weak self] notification in self?.handleRouteChange(notification) }
|
|
|
|
|
|
.store(in: &cancellables)
|
2026-04-03 15:48:37 -07:00
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-10 17:39:46 -07:00
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
2026-04-11 15:09:06 -07:00
|
|
|
|
private func handleAudioInterruption(_ notification: Notification) {
|
|
|
|
|
|
guard let info = notification.userInfo,
|
|
|
|
|
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
|
|
|
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
|
|
|
|
|
|
|
|
|
|
|
switch type {
|
|
|
|
|
|
case .began:
|
|
|
|
|
|
// System took the session (phone call, Siri, alarm).
|
|
|
|
|
|
// Mark as not playing — timers/observers were already suspended
|
|
|
|
|
|
// by didEnterBackground if the app backgrounded; this handles the
|
|
|
|
|
|
// foreground case (e.g. incoming call while app is visible).
|
|
|
|
|
|
if isPlaying {
|
|
|
|
|
|
pause()
|
|
|
|
|
|
alog("Interruption began — paused playback")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case .ended:
|
|
|
|
|
|
// Session returned to us. Resume only if the system says we should.
|
|
|
|
|
|
if let optsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
|
|
|
|
|
let opts = AVAudioSession.InterruptionOptions(rawValue: optsValue)
|
|
|
|
|
|
if opts.contains(.shouldResume) {
|
|
|
|
|
|
// Re-activate the session before resuming — it was deactivated
|
|
|
|
|
|
// by the system during the interruption.
|
|
|
|
|
|
do {
|
|
|
|
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
|
|
|
|
resume()
|
|
|
|
|
|
alog("Interruption ended — resumed playback")
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
alog("Interruption ended — session reactivation failed: \(error)")
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alog("Interruption ended — shouldResume not set, staying paused")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func handleRouteChange(_ notification: Notification) {
|
|
|
|
|
|
guard let info = notification.userInfo,
|
|
|
|
|
|
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
|
|
|
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
|
|
|
|
|
|
|
|
|
|
|
|
// Pause when headphones are unplugged — matches system Music app behaviour.
|
|
|
|
|
|
if reason == .oldDeviceUnavailable, isPlaying {
|
|
|
|
|
|
pause()
|
|
|
|
|
|
alog("Route change: output removed — paused playback")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 17:39:46 -07:00
|
|
|
|
private func suspendVisTimers() {
|
|
|
|
|
|
stopOfflineVisTimer()
|
|
|
|
|
|
stopLevelTimer()
|
2026-04-12 16:16:32 -07:00
|
|
|
|
nowPlayingSyncTimer?.invalidate()
|
|
|
|
|
|
nowPlayingSyncTimer = nil
|
2026-04-10 17:50:26 -07:00
|
|
|
|
analysisTask?.cancel()
|
|
|
|
|
|
analysisTask = nil
|
2026-04-10 18:05:10 -07:00
|
|
|
|
AudioPreFetcher.shared.cancelAll()
|
2026-04-10 17:39:46 -07:00
|
|
|
|
removeTimeObserver()
|
2026-04-10 18:23:58 -07:00
|
|
|
|
// SmartCrossfadeManager has its own 10Hz time observer that writes
|
|
|
|
|
|
// @Published currentTime/duration — same SwiftUI churn as AudioPlayer's
|
|
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.suspendForBackground()
|
|
|
|
|
|
}
|
|
|
|
|
|
alog("Background: all timers + observers suspended")
|
2026-04-10 17:39:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func resumeVisTimers() {
|
2026-04-11 15:09:06 -07:00
|
|
|
|
// Re-activate the audio session — another app may have taken it while we
|
|
|
|
|
|
// were in the background (e.g. a game or Spotify). Without this, player.play()
|
|
|
|
|
|
// silently fails and isPlaying shows true with no audio output.
|
|
|
|
|
|
do {
|
|
|
|
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
alog("Foreground: session reactivation failed: \(error)")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 16:16:32 -07:00
|
|
|
|
// Pick up any widget commands that arrived while the process was suspended.
|
|
|
|
|
|
// Darwin notifications can't wake suspended processes, so commands written
|
|
|
|
|
|
// by widget intents sit in App Group UserDefaults until we check here.
|
|
|
|
|
|
WidgetBridge.shared.processAnyPendingCommand()
|
|
|
|
|
|
|
2026-04-12 17:12:07 -07:00
|
|
|
|
// Sync currentTime/duration from the live AVPlayer state.
|
|
|
|
|
|
// While backgrounded, the time observer was removed (suspendVisTimers)
|
|
|
|
|
|
// but AVPlayer kept advancing. Without this sync, the vis timer's first
|
|
|
|
|
|
// frames read the stale position from when the app backgrounded, causing
|
|
|
|
|
|
// the waveform to render from the wrong song position then snap-correct
|
|
|
|
|
|
// once the reinstalled time observer fires (~0.1s later).
|
|
|
|
|
|
if let p = player, p.currentTime().isNumeric {
|
|
|
|
|
|
currentTime = p.currentTime().seconds
|
|
|
|
|
|
}
|
|
|
|
|
|
if let dur = playerItem?.duration.seconds, dur.isFinite, dur > 0 {
|
|
|
|
|
|
duration = dur
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 19:44:55 -07:00
|
|
|
|
// Always reinstall time observers — they were removed on background regardless
|
|
|
|
|
|
// of play state. Without this, currentTime is frozen after background+pause+play.
|
2026-04-10 17:39:46 -07:00
|
|
|
|
reinstallTimeObserver()
|
2026-04-10 18:23:58 -07:00
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.resumeFromBackground()
|
|
|
|
|
|
}
|
2026-04-10 19:44:55 -07:00
|
|
|
|
// Only restart vis timers if actually playing
|
|
|
|
|
|
guard isPlaying else { return }
|
2026-04-12 16:16:32 -07:00
|
|
|
|
|
|
|
|
|
|
// Restart Now Playing sync timer — keeps Lock Screen / Dynamic Island
|
|
|
|
|
|
// seek bar accurate. Created in playWithAVPlayer but never restarted
|
|
|
|
|
|
// after background cycle; without this the system seek bar drifts.
|
|
|
|
|
|
if nowPlayingSyncTimer == nil {
|
|
|
|
|
|
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self, self.isPlaying else { return }
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 17:39:46 -07:00
|
|
|
|
if isUsingOfflineVis {
|
|
|
|
|
|
startOfflineVisSync()
|
2026-04-10 18:23:58 -07:00
|
|
|
|
} else if !isUsingCrossfade {
|
2026-04-10 17:39:46 -07:00
|
|
|
|
startLevelSimulation()
|
|
|
|
|
|
}
|
2026-04-11 15:09:06 -07:00
|
|
|
|
alog("Foreground: session reactivated, observers + vis timers resumed (crossfade=\(isUsingCrossfade) offlineVis=\(isUsingOfflineVis))")
|
2026-04-10 17:39:46 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func removeTimeObserver() {
|
|
|
|
|
|
if let observer = timeObserver {
|
|
|
|
|
|
player?.removeTimeObserver(observer)
|
|
|
|
|
|
timeObserver = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func reinstallTimeObserver() {
|
|
|
|
|
|
guard player != nil, timeObserver == nil else { return }
|
|
|
|
|
|
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
|
|
|
|
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
self.currentTime = time.seconds
|
2026-04-11 16:15:27 -07:00
|
|
|
|
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
|
2026-04-10 17:39:46 -07:00
|
|
|
|
self.duration = dur
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
// MARK: - Audio Session
|
|
|
|
|
|
|
|
|
|
|
|
private func alog(_ msg: String) {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
DebugLogger.shared.log(msg, category: "Audio")
|
|
|
|
|
|
#else
|
|
|
|
|
|
print("[Audio] \(msg)")
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func configureAudioSession() {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
do {
|
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
|
try session.setCategory(.playback, mode: .default, options: [])
|
|
|
|
|
|
} catch {
|
2026-04-04 16:12:28 -07:00
|
|
|
|
alog("Audio session category failed: \(error)")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#elseif os(watchOS)
|
|
|
|
|
|
do {
|
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
|
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
|
|
|
|
|
} catch {
|
2026-04-04 16:12:28 -07:00
|
|
|
|
alog("watchOS audio session category failed: \(error)")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func activateAudioSession() {
|
|
|
|
|
|
do {
|
|
|
|
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
|
|
|
|
} catch {
|
2026-04-04 16:12:28 -07:00
|
|
|
|
alog("Audio session activation failed: \(error)")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Playback Controls
|
2026-04-04 16:12:28 -07:00
|
|
|
|
|
|
|
|
|
|
/// Jump to a song already in the queue by index without replacing the queue.
|
|
|
|
|
|
/// Used by QueueView tap — keeps the existing queue intact.
|
|
|
|
|
|
func play(song: Song, at index: Int) {
|
|
|
|
|
|
queueIndex = index
|
|
|
|
|
|
play(song: song)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) {
|
|
|
|
|
|
if let q = fromQueue {
|
|
|
|
|
|
queue = shuffleEnabled ? q.shuffled() : q
|
|
|
|
|
|
queueIndex = index
|
|
|
|
|
|
sourcePlaylistId = playlistId
|
|
|
|
|
|
sourcePlaylistName = playlistName
|
|
|
|
|
|
|
|
|
|
|
|
// Prefetch Smart DJ profiles for upcoming tracks
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if CompanionSettings.shared.smartDJEnabled {
|
|
|
|
|
|
let upcoming = Array(queue.dropFirst(index).prefix(10))
|
2026-04-11 15:09:06 -07:00
|
|
|
|
Task { await CompanionAPIService.shared.prefetchProfiles(for: upcoming) }
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentSong = song
|
2026-04-11 15:09:06 -07:00
|
|
|
|
|
|
|
|
|
|
// Persist queue state so it survives app termination (AUDIT-055).
|
|
|
|
|
|
// Position is saved as 0 here; the time observer updates it every 0.1s.
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
PlaybackStateStore.shared.save(
|
|
|
|
|
|
queue: queue,
|
|
|
|
|
|
index: queueIndex,
|
|
|
|
|
|
currentTime: 0,
|
|
|
|
|
|
currentSongId: song.id
|
|
|
|
|
|
)
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
alog("Playing: \(song.title) by \(song.artist ?? "Unknown")")
|
|
|
|
|
|
|
|
|
|
|
|
// Stop radio buffer if switching from radio to music
|
|
|
|
|
|
if isRadioStream {
|
|
|
|
|
|
isRadioStream = false
|
|
|
|
|
|
radioStreamURL = nil
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
RadioStreamBuffer.shared.stopBuffering()
|
|
|
|
|
|
isPlayingFromBuffer = false
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
// Smart DJ Crossfade path — dual-AVPlayer engine handles everything
|
|
|
|
|
|
let crossfade = SmartCrossfadeManager.shared
|
|
|
|
|
|
if crossfade.isEnabled && CompanionSettings.shared.smartDJEnabled && !isRadioStream {
|
|
|
|
|
|
if let url = crossfade.resolveURL(for: song) {
|
|
|
|
|
|
stopAll()
|
|
|
|
|
|
isUsingCrossfade = true
|
|
|
|
|
|
|
|
|
|
|
|
// Wire callbacks
|
|
|
|
|
|
crossfade.timeUpdate = { [weak self] time, dur in
|
|
|
|
|
|
self?.currentTime = time
|
|
|
|
|
|
self?.duration = dur
|
|
|
|
|
|
}
|
|
|
|
|
|
crossfade.needsNextTrack = { [weak self] in
|
|
|
|
|
|
self?.prepareNextForCrossfade()
|
|
|
|
|
|
}
|
2026-04-04 07:42:23 -07:00
|
|
|
|
// At crossfade midpoint, start loading the incoming song's vis frames
|
|
|
|
|
|
// so they're ready (or nearly ready) by the time finalizeCrossfade fires.
|
|
|
|
|
|
crossfade.visualizerHandoff = { [weak self] _ in
|
|
|
|
|
|
guard let self = self,
|
|
|
|
|
|
VisualizerSettings.shared.realAudioAnalysis,
|
|
|
|
|
|
let nextSong = SmartCrossfadeManager.shared.pendingNextSong,
|
|
|
|
|
|
let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong)
|
|
|
|
|
|
else { return }
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Clear stale vis from outgoing song before loading new
|
|
|
|
|
|
self.offlineVisBuffer = VisFrameBuffer.empty
|
|
|
|
|
|
self.setLevels(Array(repeating: 0, count: self._audioLevels.count))
|
2026-04-04 07:42:23 -07:00
|
|
|
|
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
|
|
|
|
|
|
}
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// At crossfade midpoint, transition metadata/artwork/colors/widget
|
|
|
|
|
|
// so the UI reflects the incoming song while audio is still fading.
|
|
|
|
|
|
crossfade.songHandoff = { [weak self] incomingSong in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
// Scrobble the outgoing song before updating currentSong
|
|
|
|
|
|
if let outgoing = self.currentSong {
|
|
|
|
|
|
Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) }
|
|
|
|
|
|
}
|
|
|
|
|
|
self.currentSong = incomingSong
|
|
|
|
|
|
if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) {
|
|
|
|
|
|
self.queueIndex = idx
|
|
|
|
|
|
}
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt)
|
|
|
|
|
|
self.pushWidgetState()
|
|
|
|
|
|
LyricsManager.shared.songChanged(
|
|
|
|
|
|
songId: incomingSong.id,
|
|
|
|
|
|
artist: incomingSong.artist ?? "Unknown",
|
|
|
|
|
|
title: incomingSong.title,
|
|
|
|
|
|
path: incomingSong.path,
|
|
|
|
|
|
duration: self.duration
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
crossfade.playSong(song, url: url)
|
|
|
|
|
|
isPlaying = true
|
|
|
|
|
|
|
|
|
|
|
|
// Set AudioPlayer.player to the active crossfade player
|
|
|
|
|
|
// so Lock Screen controls and visualizer still work
|
|
|
|
|
|
player = crossfade.activePlayer
|
|
|
|
|
|
|
|
|
|
|
|
// Safety: if boundary observer doesn't fire (short track, bad profile),
|
|
|
|
|
|
// this catches the natural end and advances normally
|
|
|
|
|
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
|
self, selector: #selector(playerDidFinish),
|
|
|
|
|
|
name: .AVPlayerItemDidPlayToEndTime, object: crossfade.activePlayer.currentItem
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
fetchAndSetArtwork(coverArtId: song.coverArt)
|
|
|
|
|
|
startLevelSimulation()
|
|
|
|
|
|
|
|
|
|
|
|
// Load offline visualizer for crossfade path too
|
|
|
|
|
|
if VisualizerSettings.shared.realAudioAnalysis {
|
|
|
|
|
|
loadOfflineVisualizer(songId: song.id, url: url)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prepare next track in standby
|
|
|
|
|
|
prepareNextForCrossfade()
|
2026-04-12 17:12:07 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
isUsingCrossfade = false
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-04-11 16:44:56 -07:00
|
|
|
|
// Check for offline version first — use Song overload which falls back to
|
|
|
|
|
|
// title/artist/duration matching if the ID changed after a Companion restructure
|
|
|
|
|
|
if let localURL = OfflineManager.shared.localURL(for: song) {
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if VisualizerSettings.shared.realAudioAnalysis {
|
|
|
|
|
|
// Use AVPlayer for reliable playback + offline vis for the visualizer
|
|
|
|
|
|
playWithAVPlayer(localURL)
|
|
|
|
|
|
loadOfflineVisualizer(songId: song.id, url: localURL)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
playWithAVPlayer(localURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
|
|
|
|
|
playWithAVPlayer(localURL)
|
|
|
|
|
|
#endif
|
|
|
|
|
|
} else {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
// Check prefetch cache — already downloaded in background
|
|
|
|
|
|
if let prefetchURL = AudioPreFetcher.shared.prefetchedURL(for: song.id, suffix: song.suffix) {
|
|
|
|
|
|
alog("Playing from prefetch cache")
|
|
|
|
|
|
playWithAVPlayer(prefetchURL)
|
|
|
|
|
|
if VisualizerSettings.shared.realAudioAnalysis {
|
|
|
|
|
|
loadOfflineVisualizer(songId: song.id, url: prefetchURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let quality = StreamingQuality.shared
|
|
|
|
|
|
let streamURL = ServerManager.shared.client.streamURL(
|
|
|
|
|
|
songId: song.id,
|
|
|
|
|
|
format: quality.format,
|
|
|
|
|
|
maxBitRate: quality.maxBitRate
|
|
|
|
|
|
)
|
|
|
|
|
|
if let url = streamURL {
|
|
|
|
|
|
playWithAVPlayer(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
|
|
|
|
|
let streamURL = ServerManager.shared.client.streamURL(
|
|
|
|
|
|
songId: song.id,
|
|
|
|
|
|
format: "mp3",
|
|
|
|
|
|
maxBitRate: 192
|
|
|
|
|
|
)
|
|
|
|
|
|
if let url = streamURL {
|
|
|
|
|
|
playWithAVPlayer(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Scrobble
|
|
|
|
|
|
Task {
|
|
|
|
|
|
try? await ServerManager.shared.client.scrobble(id: song.id, submission: false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-fetch next tracks for instant playback
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
|
2026-04-12 12:57:42 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 23:39:52 -07:00
|
|
|
|
/// Play a radio stream from a direct URL.
|
|
|
|
|
|
/// Automatically starts the stream buffer so timeshift is available immediately.
|
|
|
|
|
|
/// Destroys any existing buffer first to prevent audio bleed between stations.
|
2026-03-28 13:49:47 -07:00
|
|
|
|
func playRadio(song: Song, streamURL: URL) {
|
|
|
|
|
|
alog("Radio: \(song.title) → \(streamURL.absoluteString)")
|
|
|
|
|
|
currentSong = song
|
|
|
|
|
|
queue = [song]
|
|
|
|
|
|
queueIndex = 0
|
|
|
|
|
|
isRadioStream = true
|
|
|
|
|
|
radioStreamURL = streamURL
|
2026-04-09 23:39:52 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#if os(iOS)
|
2026-04-09 23:39:52 -07:00
|
|
|
|
// Always destroy the previous buffer cleanly before starting a new station.
|
|
|
|
|
|
// Prevents stale bytes / state from the old station bleeding into the new one.
|
|
|
|
|
|
RadioStreamBuffer.shared.stopBuffering()
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
if RadioStreamBuffer.isPlaylistURL(streamURL) {
|
|
|
|
|
|
alog("Radio: resolving playlist URL...")
|
|
|
|
|
|
Task {
|
|
|
|
|
|
let resolved = await RadioStreamBuffer.resolveStreamURL(streamURL)
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.radioStreamURL = resolved
|
|
|
|
|
|
self.playWithAVPlayer(resolved)
|
2026-04-09 23:39:52 -07:00
|
|
|
|
// Auto-start buffer immediately after resolution (non-HLS only)
|
|
|
|
|
|
// isHLSStream is set inside startBuffering if the URL is M3U8
|
|
|
|
|
|
RadioStreamBuffer.shared.startBuffering(
|
|
|
|
|
|
url: resolved,
|
|
|
|
|
|
stationName: song.title
|
|
|
|
|
|
)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
playWithAVPlayer(streamURL)
|
2026-04-09 23:39:52 -07:00
|
|
|
|
// Auto-start buffer — will mark isHLSStream=true and bail if it's HLS
|
|
|
|
|
|
RadioStreamBuffer.shared.startBuffering(url: streamURL, stationName: song.title)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#else
|
|
|
|
|
|
playWithAVPlayer(streamURL)
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether current playback is a radio stream (for buffer/scrub behavior)
|
|
|
|
|
|
@Published var isRadioStream = false
|
|
|
|
|
|
private(set) var radioStreamURL: URL?
|
2026-04-09 23:39:52 -07:00
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// Toggle recording the current radio stream on/off.
|
|
|
|
|
|
/// No-op for HLS streams or when not buffering.
|
|
|
|
|
|
func toggleRecording() {
|
|
|
|
|
|
let buf = RadioStreamBuffer.shared
|
|
|
|
|
|
if buf.isRecording {
|
|
|
|
|
|
_ = buf.stopRecording()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
buf.startRecording()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
2026-04-04 19:00:31 -07:00
|
|
|
|
/// When true, AVPlayer is playing a snapshot file rather than the live stream
|
|
|
|
|
|
@Published private(set) var isPlayingFromBuffer = false
|
|
|
|
|
|
/// Current playback position within the buffer timeline (0 = start of buffer, bufferSec = live edge)
|
|
|
|
|
|
@Published var radioBufferPlayheadSec: TimeInterval = 0
|
|
|
|
|
|
|
|
|
|
|
|
private var currentSnapshotURL: URL?
|
|
|
|
|
|
private var snapshotStatusObservation: NSKeyValueObservation?
|
|
|
|
|
|
private var snapshotEndObserver: Any?
|
|
|
|
|
|
|
|
|
|
|
|
func radioSeekBack(to positionInBuffer: TimeInterval) {
|
2026-04-04 18:41:21 -07:00
|
|
|
|
guard isRadioStream else { return }
|
|
|
|
|
|
let buffer = RadioStreamBuffer.shared
|
|
|
|
|
|
guard let srcURL = buffer.bufferPlaybackURL else {
|
2026-04-04 19:00:31 -07:00
|
|
|
|
alog("Radio seek: no buffer file yet")
|
2026-04-04 18:41:21 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
buffer.isLive = false
|
|
|
|
|
|
let seekTime = max(0.1, positionInBuffer)
|
|
|
|
|
|
let snapshotBufSec = buffer.estimatedBufferSeconds
|
2026-04-04 18:41:21 -07:00
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
// CRITICAL: use the correct audio extension (not .raw) so AVPlayer detects the format.
|
|
|
|
|
|
// .raw causes AVPlayer to fail format detection → status never becomes readyToPlay.
|
|
|
|
|
|
let ext = buffer.guessExtension()
|
2026-04-04 18:41:21 -07:00
|
|
|
|
let snapURL = FileManager.default.temporaryDirectory
|
2026-04-04 19:00:31 -07:00
|
|
|
|
.appendingPathComponent("radio_snap_\(UUID().uuidString).\(ext)")
|
|
|
|
|
|
|
|
|
|
|
|
// Clean up any previous snapshot resources
|
|
|
|
|
|
snapshotStatusObservation?.invalidate()
|
|
|
|
|
|
snapshotStatusObservation = nil
|
|
|
|
|
|
if let obs = snapshotEndObserver {
|
|
|
|
|
|
NotificationCenter.default.removeObserver(obs)
|
|
|
|
|
|
snapshotEndObserver = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if let old = currentSnapshotURL {
|
|
|
|
|
|
try? FileManager.default.removeItem(at: old)
|
|
|
|
|
|
currentSnapshotURL = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 18:41:21 -07:00
|
|
|
|
do {
|
|
|
|
|
|
try FileManager.default.copyItem(at: srcURL, to: snapURL)
|
|
|
|
|
|
} catch {
|
2026-04-04 19:00:31 -07:00
|
|
|
|
alog("Radio seek: snapshot copy failed — \(error.localizedDescription)")
|
2026-04-04 18:41:21 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-04 19:00:31 -07:00
|
|
|
|
currentSnapshotURL = snapURL
|
2026-04-04 18:41:21 -07:00
|
|
|
|
isPlayingFromBuffer = true
|
2026-04-04 19:00:31 -07:00
|
|
|
|
radioBufferPlayheadSec = seekTime
|
|
|
|
|
|
|
2026-04-04 18:41:21 -07:00
|
|
|
|
let asset = AVURLAsset(url: snapURL)
|
|
|
|
|
|
let item = AVPlayerItem(asset: asset)
|
|
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
// Auto-return to live when snapshot playback reaches the end
|
|
|
|
|
|
snapshotEndObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
|
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
|
|
|
|
|
|
) { [weak self] _ in
|
|
|
|
|
|
self?.alog("Radio: snapshot reached end — returning to live")
|
|
|
|
|
|
self?.radioGoLive()
|
2026-04-04 18:41:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
player?.replaceCurrentItem(with: item)
|
|
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
// KVO on status — more reliable than polling; fires immediately on failure too
|
|
|
|
|
|
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
switch observedItem.status {
|
|
|
|
|
|
case .readyToPlay:
|
|
|
|
|
|
self.snapshotStatusObservation?.invalidate()
|
|
|
|
|
|
self.snapshotStatusObservation = nil
|
|
|
|
|
|
// Generous tolerance for raw audio without index tables
|
|
|
|
|
|
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
|
|
|
|
|
|
let tol = CMTime(seconds: 1, preferredTimescale: 600)
|
|
|
|
|
|
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
|
|
|
|
|
|
self?.player?.play()
|
|
|
|
|
|
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
|
2026-04-04 18:41:21 -07:00
|
|
|
|
}
|
2026-04-04 19:00:31 -07:00
|
|
|
|
case .failed:
|
|
|
|
|
|
self.snapshotStatusObservation?.invalidate()
|
|
|
|
|
|
self.snapshotStatusObservation = nil
|
|
|
|
|
|
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
|
|
|
|
|
|
try? FileManager.default.removeItem(at: snapURL)
|
|
|
|
|
|
self.currentSnapshotURL = nil
|
|
|
|
|
|
self.radioGoLive() // fall back to live
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-04 18:41:21 -07:00
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
/// Return to the live radio stream, discarding any snapshot
|
2026-03-28 13:49:47 -07:00
|
|
|
|
func radioGoLive() {
|
|
|
|
|
|
guard isRadioStream, let liveURL = radioStreamURL else { return }
|
|
|
|
|
|
RadioStreamBuffer.shared.isLive = true
|
|
|
|
|
|
isPlayingFromBuffer = false
|
2026-04-04 19:00:31 -07:00
|
|
|
|
radioBufferPlayheadSec = 0
|
|
|
|
|
|
|
|
|
|
|
|
snapshotStatusObservation?.invalidate()
|
|
|
|
|
|
snapshotStatusObservation = nil
|
|
|
|
|
|
if let obs = snapshotEndObserver {
|
|
|
|
|
|
NotificationCenter.default.removeObserver(obs)
|
|
|
|
|
|
snapshotEndObserver = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if let old = currentSnapshotURL {
|
|
|
|
|
|
try? FileManager.default.removeItem(at: old)
|
|
|
|
|
|
currentSnapshotURL = nil
|
|
|
|
|
|
}
|
2026-04-04 18:41:21 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let asset = AVURLAsset(url: liveURL)
|
|
|
|
|
|
let item = AVPlayerItem(asset: asset)
|
|
|
|
|
|
player?.replaceCurrentItem(with: item)
|
|
|
|
|
|
player?.play()
|
2026-04-04 19:00:31 -07:00
|
|
|
|
alog("Radio: ▶ live")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-04 18:25:41 -07:00
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
/// Skip ±seconds within the buffer. Negative = rewind. Snaps to live at edge.
|
2026-04-04 18:25:41 -07:00
|
|
|
|
func radioSkip(by seconds: TimeInterval) {
|
|
|
|
|
|
guard isRadioStream else { return }
|
|
|
|
|
|
let buffer = RadioStreamBuffer.shared
|
|
|
|
|
|
let bufferSec = buffer.estimatedBufferSeconds
|
2026-04-04 18:41:21 -07:00
|
|
|
|
guard bufferSec > 1 else {
|
2026-04-04 19:00:31 -07:00
|
|
|
|
alog("Radio skip: not enough buffer (\(String(format: "%.1f", bufferSec))s)")
|
2026-04-04 18:41:21 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-04 18:25:41 -07:00
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
// Current position in buffer timeline
|
2026-04-04 18:25:41 -07:00
|
|
|
|
let currentPos: TimeInterval
|
2026-04-04 19:00:31 -07:00
|
|
|
|
if isPlayingFromBuffer {
|
|
|
|
|
|
let t = player?.currentTime()
|
|
|
|
|
|
currentPos = (t?.isNumeric == true) ? t!.seconds : radioBufferPlayheadSec
|
2026-04-04 18:25:41 -07:00
|
|
|
|
} else {
|
2026-04-04 19:00:31 -07:00
|
|
|
|
currentPos = bufferSec // at live edge
|
2026-04-04 18:25:41 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let newPos = currentPos + seconds
|
2026-04-04 19:00:31 -07:00
|
|
|
|
alog("Radio skip \(seconds > 0 ? "+" : "")\(Int(seconds))s: \(String(format: "%.1f", currentPos))s → \(String(format: "%.1f", newPos))s (buf \(String(format: "%.1f", bufferSec))s)")
|
2026-04-04 18:25:41 -07:00
|
|
|
|
|
2026-04-04 19:00:31 -07:00
|
|
|
|
if newPos >= bufferSec - 0.5 {
|
2026-04-04 18:25:41 -07:00
|
|
|
|
radioGoLive()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
radioSeekBack(to: max(0.1, newPos))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-04 18:45:10 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// Set now playing artwork from a UIImage (for radio station custom covers)
|
|
|
|
|
|
func setNowPlayingArtwork(image: UIImage) {
|
|
|
|
|
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
|
|
|
|
cachedArtwork = artwork
|
|
|
|
|
|
cachedArtworkCoverArtId = currentSong?.id
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - AVPlayer Path (streams + fallback)
|
|
|
|
|
|
|
|
|
|
|
|
private func playWithAVPlayer(_ url: URL) {
|
2026-04-11 17:33:13 -07:00
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
let isStream = url.scheme == "http" || url.scheme == "https"
|
|
|
|
|
|
let display = isStream
|
|
|
|
|
|
? "\(url.scheme ?? "")://\(url.host ?? "")...\(url.lastPathComponent)"
|
|
|
|
|
|
: url.lastPathComponent
|
|
|
|
|
|
DebugLogger.shared.log("▶ \(display)", category: "Audio")
|
|
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
alog("AVPlayer path: \(url.scheme ?? "file")://...\(url.lastPathComponent)")
|
|
|
|
|
|
stopAll()
|
|
|
|
|
|
|
|
|
|
|
|
// Reset audio session to pure playback (fixes radio→music conflict)
|
|
|
|
|
|
do {
|
|
|
|
|
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
|
|
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
|
|
|
|
} catch {
|
2026-04-04 16:12:28 -07:00
|
|
|
|
alog("Audio session reset failed: \(error)")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isUsingRealFFT = false
|
|
|
|
|
|
|
|
|
|
|
|
let asset = AVURLAsset(url: url)
|
|
|
|
|
|
playerItem = AVPlayerItem(asset: asset)
|
2026-04-11 17:33:13 -07:00
|
|
|
|
|
|
|
|
|
|
// Observe item status — catches stream 404s, auth failures, and codec
|
|
|
|
|
|
// errors that AVPlayer swallows silently otherwise
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
let itemToObserve = playerItem!
|
|
|
|
|
|
Task { @MainActor [weak self] in
|
|
|
|
|
|
for await status in itemToObserve.publisher(for: \.status).values {
|
|
|
|
|
|
guard let self = self, self.playerItem === itemToObserve else { break }
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case .failed:
|
|
|
|
|
|
let err = itemToObserve.error?.localizedDescription ?? "unknown"
|
|
|
|
|
|
let urlStr = url.absoluteString.contains("stream") ? url.absoluteString : url.lastPathComponent
|
|
|
|
|
|
DebugLogger.shared.log("✗ Playback failed: \(err) | \(urlStr)",
|
|
|
|
|
|
category: "Audio", level: .error)
|
|
|
|
|
|
case .readyToPlay:
|
|
|
|
|
|
DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio")
|
|
|
|
|
|
default:
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
if status == .failed { break }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
if player == nil {
|
|
|
|
|
|
player = AVPlayer(playerItem: playerItem)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
player?.replaceCurrentItem(with: playerItem)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
player?.volume = volume
|
|
|
|
|
|
player?.play()
|
|
|
|
|
|
isPlaying = true
|
|
|
|
|
|
|
|
|
|
|
|
// Time observer
|
|
|
|
|
|
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
|
|
|
|
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
self.currentTime = time.seconds
|
2026-04-11 16:15:27 -07:00
|
|
|
|
// Guard duration write: @Published fires objectWillChange on every
|
|
|
|
|
|
// assignment even if the value is identical. For a 4-min song this
|
|
|
|
|
|
// would fire an extra objectWillChange 10x/sec for the entire track.
|
|
|
|
|
|
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.duration = dur
|
|
|
|
|
|
}
|
2026-04-12 12:57:42 -07:00
|
|
|
|
// Throttle position saves to once per 5 seconds.
|
|
|
|
|
|
// Only writes a single Double — queue shape is saved separately
|
|
|
|
|
|
// in play(song:) and notifyQueueChanged().
|
2026-04-11 15:09:06 -07:00
|
|
|
|
if Int(time.seconds * 10) % 50 == 0 {
|
2026-04-12 12:57:42 -07:00
|
|
|
|
PlaybackStateStore.shared.savePosition(time.seconds)
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
|
|
|
|
|
|
#endif
|
2026-04-11 15:09:06 -07:00
|
|
|
|
}
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
// Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval)
|
|
|
|
|
|
if Int(time.seconds * 10) % 5 == 0 {
|
|
|
|
|
|
LyricsManager.shared.syncToTime(time.seconds)
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
|
|
|
|
|
|
nowPlayingSyncTimer?.invalidate()
|
|
|
|
|
|
nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self, self.isPlaying else { return }
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
|
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
|
|
|
|
|
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
2026-04-11 19:01:10 -07:00
|
|
|
|
// Radio streams use a smooth sinusoidal simulation — random phase
|
|
|
|
|
|
// simulation reacts to currentTime jumps (buffer seeks) causing
|
|
|
|
|
|
// the peakFollower to spike and "raise" the wave unexpectedly.
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if isRadioStream {
|
|
|
|
|
|
startRadioSimulation()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startLevelSimulation()
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
2026-03-28 13:49:47 -07:00
|
|
|
|
startLevelSimulation()
|
2026-04-11 19:01:10 -07:00
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - AVAudioEngine Path (local files with real FFT)
|
|
|
|
|
|
|
|
|
|
|
|
private func playLocalWithEngine(_ url: URL) {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
alog("Engine path: \(url.lastPathComponent)")
|
|
|
|
|
|
activateAudioSession()
|
|
|
|
|
|
stopAll()
|
|
|
|
|
|
|
|
|
|
|
|
isUsingRealFFT = true
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
let file = try AVAudioFile(forReading: url)
|
|
|
|
|
|
engineFile = file
|
|
|
|
|
|
|
|
|
|
|
|
let engine = AVAudioEngine()
|
|
|
|
|
|
let playerNode = AVAudioPlayerNode()
|
|
|
|
|
|
|
|
|
|
|
|
engine.attach(playerNode)
|
|
|
|
|
|
engine.connect(playerNode, to: engine.mainMixerNode, format: file.processingFormat)
|
|
|
|
|
|
|
|
|
|
|
|
// Install FFT tap on the mixer output
|
|
|
|
|
|
let format = engine.mainMixerNode.outputFormat(forBus: 0)
|
|
|
|
|
|
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
|
|
|
|
|
self?.processFFT(buffer: buffer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try engine.start()
|
|
|
|
|
|
|
|
|
|
|
|
// Schedule file and play
|
|
|
|
|
|
scheduleGeneration += 1
|
|
|
|
|
|
let gen = scheduleGeneration
|
|
|
|
|
|
playerNode.scheduleFile(file, at: nil) { [weak self] in
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
|
guard let self = self, self.scheduleGeneration == gen else { return }
|
|
|
|
|
|
self.playerDidFinishEngine()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
playerNode.play()
|
|
|
|
|
|
|
|
|
|
|
|
self.audioEngine = engine
|
|
|
|
|
|
self.enginePlayerNode = playerNode
|
|
|
|
|
|
self.engineStartFrame = 0
|
|
|
|
|
|
isPlaying = true
|
|
|
|
|
|
|
|
|
|
|
|
// Duration from file
|
|
|
|
|
|
duration = Double(file.length) / file.processingFormat.sampleRate
|
|
|
|
|
|
|
|
|
|
|
|
// Time tracking for engine (accounts for seek offset)
|
|
|
|
|
|
engineTimeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self, let node = self.enginePlayerNode,
|
|
|
|
|
|
let file = self.engineFile,
|
|
|
|
|
|
let nodeTime = node.lastRenderTime,
|
|
|
|
|
|
let playerTime = node.playerTime(forNodeTime: nodeTime) else { return }
|
|
|
|
|
|
let sampleRate = file.processingFormat.sampleRate
|
|
|
|
|
|
let offsetSeconds = Double(self.engineStartFrame) / sampleRate
|
|
|
|
|
|
self.currentTime = offsetSeconds + Double(playerTime.sampleTime) / playerTime.sampleRate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Publish FFT levels at ~30fps (smoothing already applied in processFFT)
|
|
|
|
|
|
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
let levels = self.rawFFTLevels
|
|
|
|
|
|
self.setLevels(levels)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
|
|
|
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
alog("Engine failed: \(error.localizedDescription), falling back to AVPlayer")
|
|
|
|
|
|
playWithAVPlayer(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - FFT Processing
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
private func processFFT(buffer: AVAudioPCMBuffer) {
|
|
|
|
|
|
guard let fftSetup = fftSetup else { return }
|
|
|
|
|
|
guard let channelData = buffer.floatChannelData?[0] else { return }
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let frameCount = buffer.frameLength
|
|
|
|
|
|
let log2n: vDSP_Length = 10
|
|
|
|
|
|
guard frameCount >= fftSize else { return }
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let halfSize = Int(fftSize / 2)
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
|
|
|
|
|
// 1. Apply pre-computed Hann window — no allocation, mutates pre-allocated scratch buffer
|
|
|
|
|
|
vDSP_vmul(channelData, 1, fftWindow, 1, &fftWindowed, 1, fftSize)
|
|
|
|
|
|
|
|
|
|
|
|
// 2. FFT using pre-allocated split-complex buffers
|
|
|
|
|
|
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
|
|
|
|
|
|
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
|
2026-03-28 13:49:47 -07:00
|
|
|
|
var splitComplex = DSPSplitComplex(
|
|
|
|
|
|
realp: realpBuf.baseAddress!,
|
|
|
|
|
|
imagp: imagpBuf.baseAddress!
|
|
|
|
|
|
)
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
fftWindowed.withUnsafeBytes { rawBuffer in
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress!
|
|
|
|
|
|
vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize))
|
|
|
|
|
|
}
|
|
|
|
|
|
vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD))
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
|
|
|
|
|
// 3. Normalize by N² and take sqrt for amplitude — in-place on pre-allocated buffer
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let fftSizeF = Float(fftSize)
|
|
|
|
|
|
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
vDSP_vsmul(fftMagnitudes, 1, &scale, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
for i in 0..<halfSize {
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
fftMagnitudes[i] = sqrt(fftMagnitudes[i])
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00
|
|
|
|
|
|
|
|
|
|
// 4. Push to rawFFTLevels — single array copy, no intermediate allocation
|
|
|
|
|
|
rawFFTLevels = fftMagnitudes
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Engine Finish
|
|
|
|
|
|
|
|
|
|
|
|
private func playerDidFinishEngine() {
|
|
|
|
|
|
if repeatMode == .one, let file = engineFile, let node = enginePlayerNode {
|
|
|
|
|
|
node.stop()
|
|
|
|
|
|
node.scheduleFile(file, at: nil) { [weak self] in
|
|
|
|
|
|
DispatchQueue.main.async { self?.playerDidFinishEngine() }
|
|
|
|
|
|
}
|
|
|
|
|
|
node.play()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Transport
|
|
|
|
|
|
|
|
|
|
|
|
func togglePlayPause() {
|
|
|
|
|
|
if isPlaying {
|
|
|
|
|
|
pause()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resume()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func pause() {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.pause()
|
|
|
|
|
|
isPlaying = false
|
|
|
|
|
|
stopOfflineVisTimer()
|
2026-04-04 23:17:47 -07:00
|
|
|
|
stopLevelTimer()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
updateNowPlayingInfo()
|
2026-04-12 12:57:42 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
player?.pause()
|
|
|
|
|
|
enginePlayerNode?.pause()
|
|
|
|
|
|
isPlaying = false
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
stopOfflineVisTimer()
|
2026-04-05 12:05:20 -07:00
|
|
|
|
// Don't stop levelTimer — the simulation timer's !isPlaying branch decays
|
|
|
|
|
|
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
|
|
|
|
|
|
// so the Canvas keeps re-rendering and the wave visually decays during pause.
|
|
|
|
|
|
// For offline vis path the offlineVisTimer (restarted above) handles this.
|
2026-04-04 23:17:47 -07:00
|
|
|
|
internalLevels = Array(repeating: 0, count: 30)
|
2026-04-12 12:57:42 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func resume() {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.resume()
|
|
|
|
|
|
isPlaying = true
|
2026-04-10 19:29:42 -07:00
|
|
|
|
// offlineVisTimer was stopped on pause — restart it so currentLevels() advances
|
|
|
|
|
|
if isUsingOfflineVis { startOfflineVisSync() }
|
2026-03-28 13:49:47 -07:00
|
|
|
|
updateNowPlayingInfo()
|
2026-04-12 12:57:42 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
|
|
|
|
|
|
node.play()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
player?.play()
|
|
|
|
|
|
}
|
|
|
|
|
|
isPlaying = true
|
|
|
|
|
|
#if os(iOS)
|
2026-04-05 12:05:20 -07:00
|
|
|
|
// Prime levels immediately before any timer fires.
|
|
|
|
|
|
// The CADisplayLink draws at 60fps; the level timers fire at 30fps.
|
|
|
|
|
|
// Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels
|
|
|
|
|
|
// (zeroed by pause) and the wave starts flat then slowly ramps up.
|
2026-03-28 13:49:47 -07:00
|
|
|
|
if isUsingOfflineVis {
|
2026-04-10 19:09:12 -07:00
|
|
|
|
DebugLogger.shared.log("resume: offlineVis path — startOfflineVisSync", category: "VisDebug")
|
2026-04-05 12:05:20 -07:00
|
|
|
|
// Offline vis: prime with the last-rendered frame at current position
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
if !offlineVisBuffer.isEmpty {
|
2026-04-05 12:05:20 -07:00
|
|
|
|
let dur = duration
|
|
|
|
|
|
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
let idx = min(Int(pct * Double(offlineVisBuffer.frameCount)), offlineVisBuffer.frameCount - 1)
|
|
|
|
|
|
offlineVisBuffer.copyFrame(at: idx, into: &_audioLevels)
|
|
|
|
|
|
levelTick &+= 1
|
2026-04-05 12:05:20 -07:00
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
startOfflineVisSync()
|
2026-04-05 08:23:07 -07:00
|
|
|
|
} else if isUsingRealFFT, !isUsingOfflineVis {
|
2026-04-10 19:09:12 -07:00
|
|
|
|
DebugLogger.shared.log("resume: realFFT path — rebuilding levelTimer", category: "VisDebug")
|
2026-04-05 12:05:20 -07:00
|
|
|
|
// Engine FFT path: rawFFTLevels still holds pre-pause data — push it immediately
|
|
|
|
|
|
setLevels(rawFFTLevels)
|
2026-04-05 08:23:07 -07:00
|
|
|
|
stopLevelTimer()
|
|
|
|
|
|
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
self.setLevels(self.rawFFTLevels)
|
|
|
|
|
|
}
|
2026-04-04 23:17:47 -07:00
|
|
|
|
} else {
|
2026-04-10 19:09:12 -07:00
|
|
|
|
DebugLogger.shared.log("resume: simulation path — levelTimer nil=\(levelTimer == nil)", category: "VisDebug")
|
2026-04-05 12:05:20 -07:00
|
|
|
|
// Simulation path: seed internalLevels from a fresh random target
|
|
|
|
|
|
// so the wave has height immediately rather than starting from floor.
|
|
|
|
|
|
lastTargetPhase = -1
|
|
|
|
|
|
targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) }
|
|
|
|
|
|
internalLevels = targetLevels.map { $0 * 0.8 }
|
|
|
|
|
|
setLevels(internalLevels)
|
|
|
|
|
|
if levelTimer == nil { startLevelSimulation() }
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-12 12:57:42 -07:00
|
|
|
|
pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func next() {
|
|
|
|
|
|
guard !queue.isEmpty else { return }
|
|
|
|
|
|
if repeatMode == .all {
|
|
|
|
|
|
queueIndex = (queueIndex + 1) % queue.count
|
|
|
|
|
|
} else {
|
|
|
|
|
|
queueIndex = min(queueIndex + 1, queue.count - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
play(song: queue[queueIndex])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func previous() {
|
|
|
|
|
|
if currentTime > 3 {
|
|
|
|
|
|
seek(to: 0)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
queueIndex = max(queueIndex - 1, 0)
|
|
|
|
|
|
play(song: queue[queueIndex])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// Loads the next queue track into the crossfade standby player.
|
|
|
|
|
|
/// If no next track exists, SmartCrossfadeManager will self-crossfade.
|
|
|
|
|
|
private func prepareNextForCrossfade() {
|
|
|
|
|
|
let crossfade = SmartCrossfadeManager.shared
|
|
|
|
|
|
guard crossfade.isEnabled, !queue.isEmpty else { return }
|
|
|
|
|
|
|
2026-04-04 07:42:23 -07:00
|
|
|
|
// Wire the callback — called by finalizeCrossfade after every successful crossfade
|
2026-03-28 13:49:47 -07:00
|
|
|
|
crossfade.needsNextTrack = { [weak self] in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Scrobble + currentSong + artwork already handled by songHandoff at midpoint.
|
|
|
|
|
|
// Here we only handle post-swap housekeeping.
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Ensure queueIndex is synced (songHandoff may have set it, but verify)
|
2026-04-04 07:42:23 -07:00
|
|
|
|
if let nowPlaying = crossfade.pendingNextSong,
|
|
|
|
|
|
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
|
|
|
|
|
|
self.queueIndex = idx
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Only update currentSong if songHandoff somehow didn't fire
|
|
|
|
|
|
if self.currentSong?.id != nowPlaying.id {
|
|
|
|
|
|
self.currentSong = nowPlaying
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt)
|
|
|
|
|
|
self.pushWidgetState()
|
|
|
|
|
|
}
|
2026-04-04 07:42:23 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback: queue was cleared or song removed — advance sequentially
|
|
|
|
|
|
let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count
|
|
|
|
|
|
if hasNext {
|
|
|
|
|
|
if self.repeatMode == .all {
|
|
|
|
|
|
self.queueIndex = (self.queueIndex + 1) % self.queue.count
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
self.currentSong = self.queue[self.queueIndex]
|
2026-04-14 00:27:10 -07:00
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
|
|
|
|
|
|
self.pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Persist updated queue position
|
2026-04-12 13:21:20 -07:00
|
|
|
|
PlaybackStateStore.shared.save(
|
|
|
|
|
|
queue: self.queue, index: self.queueIndex,
|
|
|
|
|
|
currentTime: 0, currentSongId: self.currentSong?.id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Swap AudioPlayer.player to the new active crossfade player
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.player = crossfade.activePlayer
|
2026-04-04 07:42:23 -07:00
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
|
2026-04-04 07:42:23 -07:00
|
|
|
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
|
|
|
|
|
if let newItem = crossfade.activePlayer.currentItem {
|
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
|
self, selector: #selector(playerDidFinish),
|
|
|
|
|
|
name: .AVPlayerItemDidPlayToEndTime, object: newItem
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prepare next-next (picks up any queue changes that happened mid-fade)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.prepareNextForCrossfade()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 07:42:23 -07:00
|
|
|
|
// Determine what to pre-load in standby
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let nextIdx: Int
|
|
|
|
|
|
if repeatMode == .all {
|
|
|
|
|
|
nextIdx = (queueIndex + 1) % queue.count
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextIdx = queueIndex + 1
|
|
|
|
|
|
guard nextIdx < queue.count else {
|
|
|
|
|
|
// No next track — SmartCrossfadeManager will self-crossfade
|
|
|
|
|
|
alog("SmartDJ: no next track — will self-crossfade")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let nextSong = queue[nextIdx]
|
2026-04-12 16:16:32 -07:00
|
|
|
|
|
|
|
|
|
|
// Guard: don't load the SAME song into the standby player.
|
|
|
|
|
|
// With repeat-all + single-song queue, nextIdx wraps to 0 (same song).
|
|
|
|
|
|
// Loading it into standby creates two AVPlayers with identical content —
|
|
|
|
|
|
// if the crossfade boundary fires, both play simultaneously and drift
|
|
|
|
|
|
// out of sync, producing the "duplicate audio" artifact.
|
|
|
|
|
|
if nextSong.id == currentSong?.id {
|
|
|
|
|
|
alog("SmartDJ: next is same song — skipping standby prep (repeat-one will handle)")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
guard let url = crossfade.resolveURL(for: nextSong) else { return }
|
|
|
|
|
|
|
|
|
|
|
|
alog("SmartDJ: preparing \(nextSong.title) in standby")
|
|
|
|
|
|
crossfade.prepareNext(nextSong, url: url)
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
func seek(to time: TimeInterval) {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.seek(to: time)
|
|
|
|
|
|
currentTime = time
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
let wasPaused = !isPlaying
|
|
|
|
|
|
|
|
|
|
|
|
if let node = enginePlayerNode, let file = engineFile, let engine = audioEngine {
|
|
|
|
|
|
let sampleRate = file.processingFormat.sampleRate
|
|
|
|
|
|
let startFrame = AVAudioFramePosition(time * sampleRate)
|
|
|
|
|
|
let totalFrames = file.length
|
|
|
|
|
|
guard startFrame >= 0, startFrame < totalFrames else { return }
|
|
|
|
|
|
let remainingFrames = AVAudioFrameCount(totalFrames - startFrame)
|
|
|
|
|
|
|
|
|
|
|
|
scheduleGeneration += 1
|
|
|
|
|
|
let gen = scheduleGeneration
|
|
|
|
|
|
node.stop()
|
|
|
|
|
|
|
|
|
|
|
|
node.scheduleSegment(file, startingFrame: startFrame, frameCount: remainingFrames, at: nil) { [weak self] in
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
|
guard let self = self, self.scheduleGeneration == gen else { return }
|
|
|
|
|
|
self.playerDidFinishEngine()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if engine.isRunning {
|
|
|
|
|
|
node.play()
|
|
|
|
|
|
// If we were paused, immediately pause again (we only played to set position)
|
|
|
|
|
|
if wasPaused {
|
|
|
|
|
|
node.pause()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentTime = time
|
|
|
|
|
|
engineStartFrame = startFrame
|
|
|
|
|
|
updateNowPlayingInfo()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
currentTime = time // Update immediately for responsive UI
|
|
|
|
|
|
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
|
|
|
|
|
|
player?.seek(to: cmTime) { [weak self] finished in
|
|
|
|
|
|
guard let self = self, finished else { return }
|
|
|
|
|
|
if wasPaused {
|
|
|
|
|
|
self.player?.pause()
|
|
|
|
|
|
}
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func seekToPercent(_ pct: Double) {
|
|
|
|
|
|
seek(to: duration * pct)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 11:35:42 -07:00
|
|
|
|
func toggleShuffle() {
|
|
|
|
|
|
shuffleEnabled.toggle()
|
|
|
|
|
|
if !queue.isEmpty {
|
|
|
|
|
|
if shuffleEnabled {
|
|
|
|
|
|
// Save current song, shuffle the rest
|
|
|
|
|
|
let current = queue[queueIndex]
|
|
|
|
|
|
var rest = queue
|
|
|
|
|
|
rest.remove(at: queueIndex)
|
|
|
|
|
|
rest.shuffle()
|
|
|
|
|
|
queue = [current] + rest
|
|
|
|
|
|
queueIndex = 0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Unshuffle — restore original order would need storing it.
|
|
|
|
|
|
// For now, keep current order but ensure current song stays in place.
|
|
|
|
|
|
}
|
2026-04-12 13:21:20 -07:00
|
|
|
|
// Persist shuffled queue shape — time observer only saves position now
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
PlaybackStateStore.shared.save(
|
|
|
|
|
|
queue: queue, index: queueIndex,
|
|
|
|
|
|
currentTime: currentTime, currentSongId: currentSong?.id
|
|
|
|
|
|
)
|
|
|
|
|
|
#endif
|
2026-03-31 11:35:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
func cycleRepeat() {
|
|
|
|
|
|
switch repeatMode {
|
|
|
|
|
|
case .off: repeatMode = .all
|
|
|
|
|
|
case .all: repeatMode = .one
|
|
|
|
|
|
case .one: repeatMode = .off
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Queue Management
|
|
|
|
|
|
|
|
|
|
|
|
func playNext(_ song: Song) {
|
|
|
|
|
|
let insertAt = queueIndex + 1
|
|
|
|
|
|
if insertAt <= queue.count {
|
|
|
|
|
|
queue.insert(song, at: insertAt)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
queue.append(song)
|
|
|
|
|
|
}
|
2026-04-04 00:14:41 -07:00
|
|
|
|
notifyQueueChanged()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func playLater(_ song: Song) {
|
|
|
|
|
|
queue.append(song)
|
2026-04-04 07:42:23 -07:00
|
|
|
|
notifyQueueChanged()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func playNow(_ song: Song) {
|
|
|
|
|
|
queue.insert(song, at: queueIndex + 1)
|
|
|
|
|
|
queueIndex += 1
|
|
|
|
|
|
play(song: song)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 11:35:42 -07:00
|
|
|
|
/// Move a song in the queue (for drag reorder)
|
|
|
|
|
|
func moveQueueItem(from source: IndexSet, to destination: Int) {
|
|
|
|
|
|
let oldIndex = queueIndex
|
|
|
|
|
|
let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil
|
|
|
|
|
|
|
|
|
|
|
|
queue.move(fromOffsets: source, toOffset: destination)
|
|
|
|
|
|
|
|
|
|
|
|
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
|
|
|
|
|
queueIndex = newIdx
|
|
|
|
|
|
}
|
2026-04-04 00:14:41 -07:00
|
|
|
|
notifyQueueChanged()
|
2026-03-31 11:35:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove a song from the queue
|
|
|
|
|
|
func removeFromQueue(at offsets: IndexSet) {
|
|
|
|
|
|
let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil
|
|
|
|
|
|
queue.remove(atOffsets: offsets)
|
|
|
|
|
|
|
|
|
|
|
|
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
|
|
|
|
|
queueIndex = newIdx
|
|
|
|
|
|
} else {
|
|
|
|
|
|
queueIndex = min(queueIndex, max(queue.count - 1, 0))
|
|
|
|
|
|
}
|
2026-04-04 00:14:41 -07:00
|
|
|
|
notifyQueueChanged()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 07:42:23 -07:00
|
|
|
|
/// Re-prepare crossfade when queue is modified (Play Next / Later / reorder).
|
2026-04-04 00:14:41 -07:00
|
|
|
|
private func notifyQueueChanged() {
|
|
|
|
|
|
#if os(iOS)
|
2026-04-12 12:57:42 -07:00
|
|
|
|
// Persist queue shape immediately so Play Next / Later / reorder / remove survives termination
|
|
|
|
|
|
PlaybackStateStore.shared.save(
|
|
|
|
|
|
queue: queue,
|
|
|
|
|
|
index: queueIndex,
|
|
|
|
|
|
currentTime: currentTime,
|
|
|
|
|
|
currentSongId: currentSong?.id
|
|
|
|
|
|
)
|
2026-04-04 00:14:41 -07:00
|
|
|
|
let crossfade = SmartCrossfadeManager.shared
|
|
|
|
|
|
guard crossfade.isEnabled, !queue.isEmpty else { return }
|
2026-04-04 07:42:23 -07:00
|
|
|
|
if crossfade.isCrossfading {
|
|
|
|
|
|
// Don't replace the standby item mid-fade — mark stale so finalizeCrossfade
|
|
|
|
|
|
// calls prepareNextForCrossfade() after the swap completes.
|
|
|
|
|
|
crossfade.queueChangedDuringFade = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
prepareNextForCrossfade()
|
|
|
|
|
|
}
|
2026-04-04 00:14:41 -07:00
|
|
|
|
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
|
|
|
|
|
|
#endif
|
2026-03-31 11:35:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
func playInstantMix(basedOn song: Song) {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50)
|
|
|
|
|
|
if let songs = similar, !songs.isEmpty {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.play(song: songs[0], fromQueue: songs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@objc private func playerDidFinish() {
|
2026-04-04 16:12:28 -07:00
|
|
|
|
// When crossfade is active, needsNextTrack() already scrobbled the outgoing song.
|
|
|
|
|
|
// playerDidFinish fires as a safety-net fallback (boundary observer miss) — skip
|
|
|
|
|
|
// scrobble here to avoid double-counting.
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
if !isUsingCrossfade, let song = currentSong {
|
|
|
|
|
|
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
2026-03-28 13:49:47 -07:00
|
|
|
|
if let song = currentSong {
|
|
|
|
|
|
Task { try? await ServerManager.shared.client.scrobble(id: song.id) }
|
|
|
|
|
|
}
|
2026-04-04 16:12:28 -07:00
|
|
|
|
#endif
|
2026-03-28 13:49:47 -07:00
|
|
|
|
|
|
|
|
|
|
if repeatMode == .one {
|
|
|
|
|
|
seek(to: 0)
|
|
|
|
|
|
player?.play()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Now Playing Info Center
|
|
|
|
|
|
|
|
|
|
|
|
private func updateNowPlayingInfo() {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
var info: [String: Any] = [:]
|
|
|
|
|
|
info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown"
|
|
|
|
|
|
info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown"
|
|
|
|
|
|
info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? ""
|
|
|
|
|
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
|
|
|
|
|
info[MPMediaItemPropertyPlaybackDuration] = duration
|
|
|
|
|
|
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
|
|
|
|
|
if let artwork = cachedArtwork {
|
|
|
|
|
|
info[MPMediaItemPropertyArtwork] = artwork
|
|
|
|
|
|
}
|
|
|
|
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func fetchAndSetArtwork(coverArtId: String?) {
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
guard let id = coverArtId else { return }
|
|
|
|
|
|
if id == cachedArtworkCoverArtId, cachedArtwork != nil { return }
|
|
|
|
|
|
|
|
|
|
|
|
// Check user-set custom cover first
|
|
|
|
|
|
if let customImage = AlbumCoverStore.shared.loadCover(for: id) {
|
|
|
|
|
|
let artwork = MPMediaItemArtwork(boundsSize: customImage.size) { _ in customImage }
|
|
|
|
|
|
self.cachedArtwork = artwork
|
|
|
|
|
|
self.cachedArtworkCoverArtId = id
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
2026-04-12 12:57:42 -07:00
|
|
|
|
self.pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) else { return }
|
|
|
|
|
|
|
|
|
|
|
|
if let cached = ImageCache.shared.cachedImage(for: url) {
|
|
|
|
|
|
let artwork = MPMediaItemArtwork(boundsSize: cached.size) { _ in cached }
|
|
|
|
|
|
self.cachedArtwork = artwork
|
|
|
|
|
|
self.cachedArtworkCoverArtId = id
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
2026-04-12 12:57:42 -07:00
|
|
|
|
self.pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
|
do {
|
|
|
|
|
|
let (data, _) = try await URLSession.shared.data(from: url)
|
|
|
|
|
|
guard let image = UIImage(data: data) else { return }
|
|
|
|
|
|
ImageCache.shared.store(image, for: url)
|
|
|
|
|
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.cachedArtwork = artwork
|
|
|
|
|
|
self.cachedArtworkCoverArtId = id
|
|
|
|
|
|
self.updateNowPlayingInfo()
|
2026-04-12 12:57:42 -07:00
|
|
|
|
self.pushWidgetState()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-11 15:09:06 -07:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Log the failure so it's visible in the debug console.
|
|
|
|
|
|
// Common cause: Tailscale reconnecting after network switch.
|
|
|
|
|
|
alog("Artwork fetch failed for \(id): \(error.localizedDescription)")
|
|
|
|
|
|
// Clear the cached ID so the next song play retries this artwork.
|
|
|
|
|
|
await MainActor.run { self.cachedArtworkCoverArtId = nil }
|
|
|
|
|
|
}
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func setupRemoteControls() {
|
|
|
|
|
|
#if os(iOS) || os(watchOS)
|
|
|
|
|
|
let center = MPRemoteCommandCenter.shared()
|
|
|
|
|
|
center.playCommand.addTarget { [weak self] _ in self?.resume(); return .success }
|
|
|
|
|
|
center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
|
|
|
|
|
center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
|
|
|
|
|
|
center.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success }
|
|
|
|
|
|
center.previousTrackCommand.addTarget { [weak self] _ in
|
|
|
|
|
|
self?.previous()
|
|
|
|
|
|
return .success
|
|
|
|
|
|
}
|
|
|
|
|
|
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
|
|
|
|
guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
|
|
|
|
|
self?.seek(to: posEvent.positionTime)
|
|
|
|
|
|
return .success
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Offline Visualizer Pipeline
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// Load or generate cached visualizer data for a downloaded song
|
|
|
|
|
|
private func loadOfflineVisualizer(songId: String, url: URL) {
|
|
|
|
|
|
stopOfflineVisTimer()
|
|
|
|
|
|
isUsingOfflineVis = false
|
|
|
|
|
|
offlineVisProgress = 0
|
|
|
|
|
|
offlineVisFPS = VisualizerSettings.shared.effectiveFPS
|
2026-04-10 17:05:12 -07:00
|
|
|
|
let points = VisualizerSettings.shared.nowPlaying.numberOfPoints
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let cutoff = VisualizerSettings.shared.frequencyCutoff
|
2026-04-10 18:05:10 -07:00
|
|
|
|
|
|
|
|
|
|
analysisTask?.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
// Don't start analysis if already in background — it will be triggered
|
|
|
|
|
|
// when the app returns to foreground instead (via resumeVisTimers path
|
|
|
|
|
|
// or next song load in foreground).
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
guard UIApplication.shared.applicationState != .background else {
|
|
|
|
|
|
alog("Offline vis: skipping analysis in background for \(songId)")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-04-10 17:50:26 -07:00
|
|
|
|
analysisTask = Task {
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let storage = VisualizerStorageManager.shared
|
|
|
|
|
|
let hasCache = await storage.hasCache(for: songId)
|
|
|
|
|
|
|
|
|
|
|
|
if hasCache {
|
|
|
|
|
|
alog("Offline vis: loading cache for \(songId)")
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
if let buffer = try? await storage.loadCache(for: songId) {
|
|
|
|
|
|
let normalized = Self.normalizeVisBuffer(buffer)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
await MainActor.run {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
self.offlineVisBuffer = normalized
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.isUsingOfflineVis = true
|
|
|
|
|
|
self.isUsingRealFFT = true
|
|
|
|
|
|
self.offlineVisProgress = 1.0
|
|
|
|
|
|
self.startOfflineVisSync()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Try Companion API first (pre-computed server frames)
|
|
|
|
|
|
var fetched = false
|
|
|
|
|
|
if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path {
|
|
|
|
|
|
do {
|
2026-04-11 15:09:06 -07:00
|
|
|
|
if let serverFrames = try await CompanionAPIService.shared.fetchVisualizerFrames(relativePath: path) {
|
2026-03-28 13:49:47 -07:00
|
|
|
|
try? await storage.saveCache(frames: serverFrames, for: songId)
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
// 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)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
alog("Offline vis: fetched \(serverFrames.count) frames from server")
|
|
|
|
|
|
await MainActor.run {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
self.offlineVisBuffer = normalized
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.isUsingOfflineVis = true
|
|
|
|
|
|
self.isUsingRealFFT = true
|
|
|
|
|
|
self.offlineVisProgress = 1.0
|
|
|
|
|
|
self.startOfflineVisSync()
|
|
|
|
|
|
}
|
|
|
|
|
|
fetched = true
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
alog("Offline vis: server fetch failed, will analyze locally")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !fetched {
|
2026-04-04 23:17:47 -07:00
|
|
|
|
alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...")
|
|
|
|
|
|
let song = self.currentSong
|
2026-03-28 13:49:47 -07:00
|
|
|
|
do {
|
2026-04-04 23:17:47 -07:00
|
|
|
|
// ── Combined pass: vis frames + SmartDJ profile in one read ─
|
|
|
|
|
|
// extractSmartDJ only if Companion is disabled or unavailable AND
|
|
|
|
|
|
// SmartDJ crossfade is enabled so we actually need the profile.
|
|
|
|
|
|
let needsSmartDJ = CompanionSettings.shared.smartDJEnabled
|
|
|
|
|
|
&& !CompanionSettings.shared.isEnabled
|
|
|
|
|
|
&& song?.path != nil
|
|
|
|
|
|
&& SmartDJCache.shared.get(song?.path ?? "") == nil
|
|
|
|
|
|
|
|
|
|
|
|
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
2026-03-28 13:49:47 -07:00
|
|
|
|
url: url,
|
|
|
|
|
|
pointsCount: points,
|
|
|
|
|
|
fps: offlineVisFPS,
|
2026-04-04 23:17:47 -07:00
|
|
|
|
cutoff: cutoff,
|
|
|
|
|
|
extractSmartDJ: needsSmartDJ
|
2026-03-28 13:49:47 -07:00
|
|
|
|
) { [weak self] pct in
|
2026-04-04 23:17:47 -07:00
|
|
|
|
Task { @MainActor in self?.offlineVisProgress = pct }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Seed SmartDJ cache from on-device analysis if we extracted it
|
|
|
|
|
|
if needsSmartDJ, let path = song?.path {
|
|
|
|
|
|
let profile = SmartDJProfile(
|
|
|
|
|
|
bpm: nil,
|
|
|
|
|
|
silenceStart: result.silenceStart,
|
|
|
|
|
|
silenceEnd: result.silenceEnd,
|
|
|
|
|
|
loudnessLUFS: result.loudnessLUFS
|
|
|
|
|
|
)
|
|
|
|
|
|
SmartDJCache.shared.store(profile, for: path)
|
|
|
|
|
|
alog("Offline vis: seeded on-device SmartDJ profile for \(path) " +
|
|
|
|
|
|
"(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " +
|
|
|
|
|
|
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
|
|
|
|
|
|
"trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)")
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-04 23:17:47 -07:00
|
|
|
|
|
|
|
|
|
|
let frames = result.visFrames
|
2026-03-28 13:49:47 -07:00
|
|
|
|
try? await storage.saveCache(frames: frames, for: songId)
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
// 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)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
alog("Offline vis: cached \(frames.count) frames for \(songId)")
|
2026-04-04 23:17:47 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
await MainActor.run {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
self.offlineVisBuffer = normalized
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.isUsingOfflineVis = true
|
|
|
|
|
|
self.isUsingRealFFT = true
|
|
|
|
|
|
self.offlineVisProgress = 1.0
|
|
|
|
|
|
self.startOfflineVisSync()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
alog("Offline vis: analysis failed: \(error.localizedDescription)")
|
2026-04-04 23:17:47 -07:00
|
|
|
|
await MainActor.run { self.startLevelSimulation() }
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
} // end if !fetched
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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.
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
/// 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
|
|
|
|
|
|
)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Sync cached frames to current playback time.
|
|
|
|
|
|
/// Uses proportional mapping (time/duration → frame index) so it works
|
|
|
|
|
|
/// regardless of what FPS the frames were analyzed at.
|
|
|
|
|
|
private func startOfflineVisSync() {
|
|
|
|
|
|
stopOfflineVisTimer()
|
|
|
|
|
|
stopLevelTimer()
|
|
|
|
|
|
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
let frameCount = offlineVisBuffer.frameCount
|
2026-03-28 13:49:47 -07:00
|
|
|
|
alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s")
|
|
|
|
|
|
|
|
|
|
|
|
if duration <= 0 {
|
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
|
|
|
|
self?.startOfflineVisSyncTimer()
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startOfflineVisSyncTimer()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func startOfflineVisSyncTimer() {
|
|
|
|
|
|
stopOfflineVisTimer()
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
2026-04-05 12:05:20 -07:00
|
|
|
|
guard let self = self, self.isUsingOfflineVis else { return }
|
|
|
|
|
|
guard self.isPlaying else {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
// Zero out levels so the visualizer decays during pause
|
2026-04-05 12:05:20 -07:00
|
|
|
|
if self._audioLevels.contains(where: { $0 > 0.005 }) {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
for i in 0..<self._audioLevels.count { self._audioLevels[i] = 0 }
|
|
|
|
|
|
self.levelTick &+= 1
|
2026-04-05 12:05:20 -07:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
|
|
|
|
|
|
let buf = self.offlineVisBuffer
|
|
|
|
|
|
guard buf.frameCount > 0 else { return }
|
|
|
|
|
|
|
|
|
|
|
|
let dur = self.duration
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let time = self.currentTime
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let frameIndex: Int
|
|
|
|
|
|
if dur > 0 {
|
|
|
|
|
|
let pct = min(time / dur, 1.0)
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
frameIndex = min(Int(pct * Double(buf.frameCount)), buf.frameCount - 1)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
} else {
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
frameIndex = min(Int(time * 30.0), buf.frameCount - 1)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
|
|
|
|
|
|
// Zero-allocation frame dispatch: copy directly from flat buffer into _audioLevels
|
|
|
|
|
|
buf.copyFrame(at: frameIndex, into: &self._audioLevels)
|
|
|
|
|
|
self.levelTick &+= 1
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func stopOfflineVisTimer() {
|
|
|
|
|
|
offlineVisTimer?.invalidate()
|
|
|
|
|
|
offlineVisTimer = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Simulated Level Animation (for streams)
|
2026-04-11 19:01:10 -07:00
|
|
|
|
|
|
|
|
|
|
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
|
|
|
|
|
|
/// Radio streams can't be pre-analyzed and their AVPlayer currentTime can
|
|
|
|
|
|
/// jump on buffer seeks — using the same random-phase simulation as music
|
|
|
|
|
|
/// causes the peakFollower in the visualizer to spike and "raise" the wave.
|
|
|
|
|
|
/// This uses a time-based sine wave that's completely independent of
|
|
|
|
|
|
/// currentTime, so buffer resets and seek-backs have no visual effect.
|
|
|
|
|
|
private func startRadioSimulation() {
|
|
|
|
|
|
stopLevelTimer()
|
|
|
|
|
|
var radioPhase: Double = 0
|
|
|
|
|
|
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
guard VisualizerSettings.shared.enabled else { return }
|
|
|
|
|
|
#endif
|
|
|
|
|
|
guard self.isPlaying else {
|
|
|
|
|
|
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
|
|
|
|
|
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
|
|
|
|
|
|
self.setLevels(self.internalLevels)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
radioPhase += 1.0 / 30.0
|
|
|
|
|
|
|
|
|
|
|
|
// Smooth sine wave across the frequency bands — gentle, never spikes.
|
|
|
|
|
|
// Base amplitude 0.35, modulated by a slow breathing envelope (0.15 range)
|
|
|
|
|
|
// so it looks alive without jarring jumps.
|
|
|
|
|
|
let count = self.internalLevels.count
|
|
|
|
|
|
let breathe = Float(0.35 + 0.15 * sin(radioPhase * 0.4))
|
|
|
|
|
|
for i in 0..<count {
|
|
|
|
|
|
let x = Float(i) / Float(max(count - 1, 1))
|
|
|
|
|
|
// Two overlapping sine waves at different frequencies for organic shape
|
|
|
|
|
|
let wave1 = sin(Float(radioPhase * 1.1) + x * .pi * 2.0)
|
|
|
|
|
|
let wave2 = sin(Float(radioPhase * 0.7) + x * .pi * 3.5) * 0.4
|
|
|
|
|
|
self.internalLevels[i] = breathe * (0.5 + 0.5 * (wave1 + wave2) / 1.4)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.setLevels(self.internalLevels)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
private func startLevelSimulation() {
|
|
|
|
|
|
stopLevelTimer()
|
2026-04-05 08:23:07 -07:00
|
|
|
|
lastTargetPhase = -1
|
2026-03-28 13:49:47 -07:00
|
|
|
|
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
2026-04-11 16:15:27 -07:00
|
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
// Skip all work when visualizer is disabled — avoids 60fps float math
|
|
|
|
|
|
// and setLevels() calls that serve no purpose with no Canvas reading them.
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
guard VisualizerSettings.shared.enabled else { return }
|
|
|
|
|
|
#endif
|
|
|
|
|
|
guard self.isPlaying else {
|
|
|
|
|
|
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
|
|
|
|
|
for i in 0..<self.internalLevels.count {
|
|
|
|
|
|
self.internalLevels[i] = 0
|
2026-04-10 17:39:46 -07:00
|
|
|
|
}
|
2026-04-11 16:15:27 -07:00
|
|
|
|
self.setLevels(self.internalLevels)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-10 17:39:46 -07:00
|
|
|
|
|
|
|
|
|
|
let time = self.currentTime
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let phase = Int(time * 12)
|
|
|
|
|
|
if phase != self.lastTargetPhase {
|
|
|
|
|
|
self.lastTargetPhase = phase
|
2026-04-10 17:39:46 -07:00
|
|
|
|
// In-place random target — no allocation
|
|
|
|
|
|
for i in 0..<self.targetLevels.count {
|
|
|
|
|
|
self.targetLevels[i] = Float.random(in: 0.1...0.85)
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-10 17:39:46 -07:00
|
|
|
|
|
|
|
|
|
|
// In-place smooth — no zip/map allocation
|
2026-03-28 13:49:47 -07:00
|
|
|
|
let smoothing: Float = 0.15
|
2026-04-10 17:39:46 -07:00
|
|
|
|
for i in 0..<self.internalLevels.count {
|
|
|
|
|
|
self.internalLevels[i] += (self.targetLevels[i] - self.internalLevels[i]) * smoothing
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
2026-04-10 17:39:46 -07:00
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
self.publishCounter += 1
|
|
|
|
|
|
if self.publishCounter % 2 == 0 {
|
|
|
|
|
|
self.setLevels(self.internalLevels)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 12:57:42 -07:00
|
|
|
|
// MARK: - Widget State Push
|
|
|
|
|
|
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
/// Push current playback state to the widget via App Group.
|
|
|
|
|
|
/// Called on song change, play/pause, and artwork load.
|
|
|
|
|
|
private func pushWidgetState() {
|
|
|
|
|
|
guard let song = currentSong else { return }
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
|
|
|
|
|
|
// Notify LyricsManager of the current song (deduplicates internally by song ID)
|
|
|
|
|
|
LyricsManager.shared.songChanged(
|
|
|
|
|
|
songId: song.id,
|
|
|
|
|
|
artist: song.artist ?? "Unknown",
|
|
|
|
|
|
title: song.title,
|
|
|
|
|
|
path: song.path,
|
|
|
|
|
|
duration: duration
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
|
|
|
|
|
let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in
|
|
|
|
|
|
var artData: Data?
|
|
|
|
|
|
if let id = s.coverArt {
|
|
|
|
|
|
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
|
|
|
|
|
// Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px)
|
|
|
|
|
|
artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))?
|
|
|
|
|
|
.jpegData(compressionQuality: 0.5)
|
|
|
|
|
|
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) {
|
|
|
|
|
|
artData = ImageCache.shared.cachedImage(for: url)?
|
|
|
|
|
|
.jpegData(compressionQuality: 0.5)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData)
|
|
|
|
|
|
}
|
2026-04-12 12:57:42 -07:00
|
|
|
|
|
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
|
|
|
|
// Cover art: custom first, then server (memory + disk).
|
2026-04-12 12:57:42 -07:00
|
|
|
|
var coverImage: UIImage?
|
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
|
|
|
|
var artKey = song.coverArt
|
2026-04-12 12:57:42 -07:00
|
|
|
|
if let id = song.coverArt {
|
2026-04-12 16:16:32 -07:00
|
|
|
|
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
|
|
|
|
|
coverImage = custom
|
|
|
|
|
|
artKey = "custom_\(id)"
|
|
|
|
|
|
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) {
|
2026-04-12 17:12:07 -07:00
|
|
|
|
coverImage = ImageCache.shared.cachedImage(for: url)
|
2026-04-12 12:57:42 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
|
|
|
|
// Waveform: downsample offline vis data to 40 peaks, or pseudo-random fallback.
|
|
|
|
|
|
let waveform = sampleWaveform(sampleCount: 40)
|
|
|
|
|
|
|
|
|
|
|
|
// Crossfade trigger time from Smart DJ profile (seconds into track).
|
|
|
|
|
|
let crossfadeTime: TimeInterval? = {
|
|
|
|
|
|
if isUsingCrossfade,
|
|
|
|
|
|
let ss = SmartCrossfadeManager.shared.currentProfile?.silenceStart,
|
|
|
|
|
|
ss > 0 { return ss }
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-04-12 12:57:42 -07:00
|
|
|
|
WidgetBridge.shared.updateNowPlaying(
|
|
|
|
|
|
title: song.title,
|
|
|
|
|
|
artist: song.artist ?? "Unknown",
|
|
|
|
|
|
album: song.album ?? "",
|
|
|
|
|
|
isPlaying: isPlaying,
|
|
|
|
|
|
currentTime: currentTime,
|
|
|
|
|
|
duration: duration,
|
2026-04-12 16:16:32 -07:00
|
|
|
|
coverArtId: artKey,
|
2026-04-12 12:57:42 -07:00
|
|
|
|
coverArtImage: coverImage,
|
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
|
|
|
|
queue: upcoming,
|
|
|
|
|
|
waveformSamples: waveform,
|
|
|
|
|
|
crossfadeAt: crossfadeTime
|
2026-04-12 12:57:42 -07:00
|
|
|
|
)
|
|
|
|
|
|
}
|
Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00
|
|
|
|
|
|
|
|
|
|
/// Downsample the offline vis buffer to `sampleCount` peak amplitudes (0.0–1.0).
|
|
|
|
|
|
/// If no vis data is loaded, returns a seeded pseudo-random shape per song ID.
|
|
|
|
|
|
private func sampleWaveform(sampleCount: Int = 40) -> [Float] {
|
|
|
|
|
|
let buf = offlineVisBuffer
|
|
|
|
|
|
if !buf.isEmpty {
|
|
|
|
|
|
var samples = [Float]()
|
|
|
|
|
|
samples.reserveCapacity(sampleCount)
|
|
|
|
|
|
for i in 0..<sampleCount {
|
|
|
|
|
|
let startFrame = i * buf.frameCount / sampleCount
|
|
|
|
|
|
let endFrame = min((i + 1) * buf.frameCount / sampleCount, buf.frameCount)
|
|
|
|
|
|
var peak: Float = 0
|
|
|
|
|
|
for f in startFrame..<endFrame {
|
|
|
|
|
|
let offset = f * buf.pointsPerFrame
|
|
|
|
|
|
let end = min(offset + buf.pointsPerFrame, buf.data.count)
|
|
|
|
|
|
for j in offset..<end {
|
|
|
|
|
|
peak = max(peak, buf.data[j])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
samples.append(peak)
|
|
|
|
|
|
}
|
|
|
|
|
|
return samples
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback: seeded PRNG for consistent shape per song
|
|
|
|
|
|
guard let id = currentSong?.id else {
|
|
|
|
|
|
return [Float](repeating: 0.3, count: sampleCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
var seed: UInt64 = 5381
|
|
|
|
|
|
for c in id.utf8 { seed = seed &* 33 &+ UInt64(c) }
|
|
|
|
|
|
var samples = [Float]()
|
|
|
|
|
|
samples.reserveCapacity(sampleCount)
|
|
|
|
|
|
for i in 0..<sampleCount {
|
|
|
|
|
|
seed = (seed &* 16807 &+ 7) % 2147483647
|
|
|
|
|
|
let base = Float(seed % 1000) / 1000.0
|
|
|
|
|
|
let envelope = 0.5 + 0.5 * sin(Float.pi * Float(i) / Float(sampleCount))
|
|
|
|
|
|
samples.append(min(0.2 + base * 0.6 * envelope, 1.0))
|
|
|
|
|
|
}
|
|
|
|
|
|
return samples
|
|
|
|
|
|
}
|
2026-04-12 12:57:42 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
|
2026-03-28 13:49:47 -07:00
|
|
|
|
// MARK: - Cleanup
|
|
|
|
|
|
|
|
|
|
|
|
private func stopLevelTimer() {
|
|
|
|
|
|
levelTimer?.invalidate()
|
|
|
|
|
|
levelTimer = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func stopEngineTimer() {
|
|
|
|
|
|
engineTimeTimer?.invalidate()
|
|
|
|
|
|
engineTimeTimer = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func stopEngine() {
|
|
|
|
|
|
scheduleGeneration += 1 // invalidate all pending completions
|
|
|
|
|
|
enginePlayerNode?.stop()
|
|
|
|
|
|
audioEngine?.mainMixerNode.removeTap(onBus: 0)
|
|
|
|
|
|
audioEngine?.stop()
|
|
|
|
|
|
audioEngine = nil
|
|
|
|
|
|
enginePlayerNode = nil
|
|
|
|
|
|
engineFile = nil
|
|
|
|
|
|
stopEngineTimer()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func stopAVPlayer() {
|
|
|
|
|
|
player?.pause()
|
|
|
|
|
|
player?.replaceCurrentItem(with: nil)
|
|
|
|
|
|
if let observer = timeObserver {
|
|
|
|
|
|
player?.removeTimeObserver(observer)
|
|
|
|
|
|
timeObserver = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
playerItem = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Stop everything — called before starting new playback
|
|
|
|
|
|
private func stopAll() {
|
|
|
|
|
|
stopLevelTimer()
|
|
|
|
|
|
nowPlayingSyncTimer?.invalidate()
|
|
|
|
|
|
nowPlayingSyncTimer = nil
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
stopOfflineVisTimer()
|
2026-04-10 17:50:26 -07:00
|
|
|
|
analysisTask?.cancel()
|
|
|
|
|
|
analysisTask = nil
|
2026-03-28 13:49:47 -07:00
|
|
|
|
if isUsingCrossfade {
|
|
|
|
|
|
SmartCrossfadeManager.shared.stop()
|
|
|
|
|
|
isUsingCrossfade = false
|
|
|
|
|
|
}
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Zero the vis buffer so stale frames from the previous song don't render
|
|
|
|
|
|
// while the new song is loading. The new loadOfflineVisualizer() call will
|
|
|
|
|
|
// repopulate it once analysis finishes.
|
|
|
|
|
|
offlineVisBuffer = VisFrameBuffer.empty
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
stopEngine()
|
|
|
|
|
|
stopAVPlayer()
|
|
|
|
|
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// Zero audio levels immediately — prevents the Canvas from painting stale
|
|
|
|
|
|
// FFT/vis data during track transitions (the "lifted bars" bug).
|
|
|
|
|
|
setLevels(Array(repeating: 0, count: _audioLevels.count))
|
2026-03-28 13:49:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stop() {
|
|
|
|
|
|
stopAll()
|
|
|
|
|
|
isPlaying = false
|
|
|
|
|
|
currentSong = nil
|
|
|
|
|
|
setLevels(Array(repeating: 0, count: 30))
|
|
|
|
|
|
internalLevels = Array(repeating: 0, count: 30)
|
|
|
|
|
|
rawFFTLevels = Array(repeating: 0, count: 512)
|
|
|
|
|
|
isUsingRealFFT = false
|
|
|
|
|
|
isUsingOfflineVis = false
|
|
|
|
|
|
#if os(iOS)
|
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.
2026-04-10 16:55:09 -07:00
|
|
|
|
offlineVisBuffer = VisFrameBuffer.empty
|
2026-04-12 12:57:42 -07:00
|
|
|
|
WidgetBridge.shared.clear()
|
2026-03-28 13:49:47 -07:00
|
|
|
|
#endif
|
|
|
|
|
|
if isRadioStream {
|
|
|
|
|
|
isRadioStream = false
|
|
|
|
|
|
radioStreamURL = nil
|
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
|
RadioStreamBuffer.shared.stopBuffering()
|
|
|
|
|
|
isPlayingFromBuffer = false
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
|
|
if let setup = fftSetup {
|
|
|
|
|
|
vDSP_destroy_fftsetup(setup)
|
|
|
|
|
|
}
|
|
|
|
|
|
stop()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|