🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs):
- EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport()
- memoryCache dictionary mutated from main thread (loadBulkCache)
and background Task.detached (bulkImport) simultaneously
- Fix: NSLock serializes all memoryCache reads and writes
🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log):
- SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button
- timeObserver registered on old player, self.player swapped to
crossfade's active player by finalizeCrossfade
- Fix: remove observer from OLD player at both swap sites before
assignment + reorder stopAVPlayer (observer before replaceCurrentItem)
🟡 Audit fixes (no crash logs, preventative):
- KVO in radioSeekBack wrapped in DispatchQueue.main.async
- Stale vis Task guarded by songId in all MainActor.run blocks
- CHANGELOG.md with full findings documentation
Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.
Fix 2 — Seek bar:
Replaced DragGesture with onTapGesture for reliable watchOS taps.
Hit target increased from 3pt to 20pt. Both local and remote views.
Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
Deferred audio session configuration from AudioPlayer.init() to first
play. Setting .playback category on init interrupted podcasts and other
audio before the user even tapped a song.
- configureAudioSession() removed from init, added to crossfade path
and playLocalWithEngine (playWithAVPlayer already had it)
- resumeVisTimers: setActive(true) moved below guard isPlaying so
foregrounding the app without active playback leaves other audio alone
- All 4 setActive(true) sites now gated behind actual playback or
system interruption recovery
Widget v2:
- Glassmorphism glass panel, 40-bar waveform Canvas with SeekToIntent
- CIAreaAverage color extraction + secondary color for adaptive theming
- Small: inset glass. Medium/Large: flush edge-to-edge (contentMarginsDisabled)
- Large: 3-item queue with real cover art thumbnails, crossfade countdown
- Waveform sampled from offline vis buffer, seeded PRNG fallback
Live Lyrics:
- LyricsService: direct LRCLIB from iOS, no companion dependency
- Embedded lyrics read via AVAsset.load(.metadata) from downloads
- Karaoke word-by-word gradient fill, auto-scroll, fade edges
- Tap-to-sync timing editor with +/-0.1s offset adjust
- Companion API fallback only for server-side embedded tags
Backup System:
- .nvdbackup export/import via ZIPFoundation
- UTI registered for AirDrop, passwords stripped on export
- PendingOperationsQueue with retry + disk persistence
Crossfade fixes:
- Seek bar: reports from incoming player immediately, not at 50%
- songHandoff at midpoint: art/colors/text/lyrics transition mid-fade
- Scrobble fires before metadata swap (correct outgoing song)
Visualizer fixes:
- stopAll() zeros _audioLevels + clears offlineVisBuffer
- All 5 simulation start sites gated behind realAudioAnalysis check
- Bars stay flat between skips, only rise when real vis data loads
Smart DJ bulk prefetch, appendingPathComponent slash fix
Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes).
Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over.
The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
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)
1. Reactivate audio session
2. Process stale widget commands
3. Sync currentTime/duration from live AVPlayer ← new
4. Reinstall periodic time observer
5. Resume crossfade manager
6. Restart nowPlayingSyncTimer
7. Restart vis timers (offline vis or level simulation)
The vis timer’s very first frame now reads the correct position
instead of the stale one from backgrounding.
Gap 1: Lock Screen seek bar drift — nowPlayingSyncTimer (5s timer that
pushes elapsed time to MPNowPlayingInfoCenter) was created in
playWithAVPlayer but never restarted after a background/foreground
cycle. Now invalidated in suspendVisTimers() and recreated in
resumeVisTimers(). The crossfade path benefits too — it never went
through playWithAVPlayer so the timer was never created at all for
crossfade sessions.
Gap 2: Prefetcher re-downloads after restructure — AudioPreFetcher
used isSongDownloaded(song.id) (exact ID match). After a Companion
restructure changes IDs, songs already downloaded were re-fetched.
Changed to isSongAvailableOffline(song) which falls back to
title/artist/duration matching.
Gap 3: Unbounded image disk cache — trimDiskCache() only ran on app
launch. A long browsing session could push well past the 200MB limit.
Now storeToDisk increments a write counter and triggers trim every 50
writes. The counter lives on ioQueue (serial) so no lock needed.
Gap 4: Custom cover art in widget — WidgetBridge cached blur keyed by
coverArtId. Custom covers don’t change the ID, so the bridge skipped
the update. Now pushWidgetState() passes "custom_\(id)" as the key
when AlbumCoverStore has a custom image. Same album’s songs still
share the key → blur is reused, not redone. When custom is removed,
key reverts to the bare ID → re-blurs with server art.
AudioPlayer.swift — startRadioSimulation() replaces
startLevelSimulation() for radio streams. It generates two overlapping
sine waves with a slow breathing amplitude envelope. The key property:
it’s driven by an internal radioPhase counter that increments at a
fixed rate, completely independent of currentTime. Buffer seeks, HLS
restarts, and stream reconnects have zero visual effect.
MitsuhaVisualizerView.swift — Two fixes:
1. TimelineView(.periodic(from: .distantPast, by: tickInterval)) —
using .distantPast as the stable schedule origin instead of .now.
Previously, any recalculation of tickInterval (triggered by
isRenderingActive flipping during a radio buffer hiccup) would reset
the origin to the current moment, deferring the next tick by a full
interval — the visible stall.
2. songId parameter with .onChange(of: songId) that resets
peakFollower, levelHistoryBuf, and lastTickTime on song change. This
prevents the outgoing song’s simulation spike from “raising” the wave
at the start of the next song.
NowPlayingView.swift and MainTabView.swift — Pass songId:
audioPlayer.currentSong?.id through to MitsuhaVisualizerView and
CompactVisualizerView at all call sites.
The Artist → Album path bypassed this entirely because it navigates via artistId which fetches albums fresh from Navidrome each time.
After installing this and doing a pull-to-refresh, the Albums tab will use real Navidrome IDs again. You may need to clear the app’s cache once if the stale Companion IDs are already persisted — Settings → clear library cache if that option exists, or just force-quit and relaunch after refreshing.
Replace @Published var currentTime/duration with plain vars and drive
progress bars via TimelineView(.periodic) instead of SwiftUI
observation. This stops objectWillChange from firing 20x/second on
AudioPlayer, eliminating continuous body re-evaluation on
NowPlayingView, MiniPlayerBar, and MyMusicView regardless of
visualizer state.
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
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.
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying