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)
109 lines
4.2 KiB
Swift
109 lines
4.2 KiB
Swift
import Foundation
|
|
|
|
/// Persists the current playback queue and position to UserDefaults so the app
|
|
/// can restore them after being terminated by iOS (memory pressure, system kill).
|
|
///
|
|
/// Restoration is intentionally conservative:
|
|
/// - Only offline/downloaded songs are auto-resumed (streams need live URLs)
|
|
/// - Playback restarts paused at the saved position — the user taps Play
|
|
/// - If the saved songs no longer exist in the library the state is discarded
|
|
struct PlaybackStateStore {
|
|
|
|
static let shared = PlaybackStateStore()
|
|
|
|
private let queueKey = "playback_saved_queue"
|
|
private let indexKey = "playback_saved_index"
|
|
private let timeKey = "playback_saved_time"
|
|
private let songIdKey = "playback_saved_song_id"
|
|
private let encoder = JSONEncoder()
|
|
|
|
// MARK: - Save
|
|
|
|
/// Save the full queue structure — call when queue content or index changes
|
|
/// (play, next, reorder, add/remove). Heavy: encodes entire [Song] array.
|
|
func save(queue: [Song], index: Int, currentTime: TimeInterval, currentSongId: String?) {
|
|
guard !queue.isEmpty else { return }
|
|
|
|
if let data = try? encoder.encode(queue) {
|
|
UserDefaults.standard.set(data, forKey: queueKey)
|
|
}
|
|
UserDefaults.standard.set(index, forKey: indexKey)
|
|
UserDefaults.standard.set(currentTime, forKey: timeKey)
|
|
UserDefaults.standard.set(currentSongId, forKey: songIdKey)
|
|
}
|
|
|
|
/// Save only the playback position — call from the periodic time observer.
|
|
/// Lightweight: writes a single Double, no JSON encoding.
|
|
func savePosition(_ currentTime: TimeInterval) {
|
|
UserDefaults.standard.set(currentTime, forKey: timeKey)
|
|
}
|
|
|
|
// MARK: - Load
|
|
|
|
struct RestoredState {
|
|
let queue: [Song]
|
|
let index: Int
|
|
let currentTime: TimeInterval
|
|
let currentSong: Song
|
|
}
|
|
|
|
/// Returns saved state if valid, nil if nothing was saved or data is stale.
|
|
func load() -> RestoredState? {
|
|
guard let data = UserDefaults.standard.data(forKey: queueKey),
|
|
let queue = try? JSONDecoder().decode([Song].self, from: data),
|
|
!queue.isEmpty else { return nil }
|
|
|
|
let index = UserDefaults.standard.integer(forKey: indexKey)
|
|
let time = UserDefaults.standard.double(forKey: timeKey)
|
|
|
|
guard queue.indices.contains(index) else { return nil }
|
|
|
|
return RestoredState(
|
|
queue: queue,
|
|
index: index,
|
|
currentTime: time,
|
|
currentSong: queue[index]
|
|
)
|
|
}
|
|
|
|
// MARK: - Clear
|
|
|
|
/// Call after the user explicitly stops playback or clears the queue.
|
|
func clear() {
|
|
UserDefaults.standard.removeObject(forKey: queueKey)
|
|
UserDefaults.standard.removeObject(forKey: indexKey)
|
|
UserDefaults.standard.removeObject(forKey: timeKey)
|
|
UserDefaults.standard.removeObject(forKey: songIdKey)
|
|
}
|
|
|
|
// MARK: - Backup Export/Import
|
|
|
|
/// Export playback state as JSON data for the backup system.
|
|
func exportData() -> Data? {
|
|
guard let queueData = UserDefaults.standard.data(forKey: queueKey) else { return nil }
|
|
let dict: [String: Any] = [
|
|
"queue": queueData.base64EncodedString(),
|
|
"index": UserDefaults.standard.integer(forKey: indexKey),
|
|
"time": UserDefaults.standard.double(forKey: timeKey),
|
|
"songId": UserDefaults.standard.string(forKey: songIdKey) ?? ""
|
|
]
|
|
return try? JSONSerialization.data(withJSONObject: dict)
|
|
}
|
|
|
|
/// Import playback state from backup data.
|
|
func importData(_ data: Data) {
|
|
guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
if let b64 = dict["queue"] as? String, let queueData = Data(base64Encoded: b64) {
|
|
UserDefaults.standard.set(queueData, forKey: queueKey)
|
|
}
|
|
if let idx = dict["index"] as? Int {
|
|
UserDefaults.standard.set(idx, forKey: indexKey)
|
|
}
|
|
if let time = dict["time"] as? Double {
|
|
UserDefaults.standard.set(time, forKey: timeKey)
|
|
}
|
|
if let songId = dict["songId"] as? String {
|
|
UserDefaults.standard.set(songId, forKey: songIdKey)
|
|
}
|
|
}
|
|
}
|