NavidromeApp/Shared/Storage/WidgetSharedState.swift

227 lines
9.7 KiB
Swift
Raw Normal View History

2026-04-12 12:57:42 -07:00
import Foundation
//
// WidgetSharedState.swift
// TARGET: Compile into BOTH the main app AND the widget extension.
// Lives in Shared/Storage/ alongside LibraryCache, PlaybackStateStore, etc.
//
// Reads/writes playback state via App Group UserDefaults so the widget
// can display current song info and the app can receive widget commands.
//
2026-04-12 13:39:13 -07:00
// App Group: "group.com.navidromeplayer.shared"
2026-04-12 12:57:42 -07:00
// Add this App Group capability to BOTH targets in Signing & Capabilities.
//
/// Item in the "Up Next" queue displayed by the large widget.
struct WidgetQueueItem: Codable, Equatable {
let title: String
let artist: String
let coverArtData: Data? // small JPEG thumbnail (~40×40 ~2KB each)
2026-04-12 12:57:42 -07:00
}
/// Commands the widget can send to the main app.
enum WidgetCommand: String, Codable {
case play
case pause
case next
case previous
case seekForward15
case seekBackward15
case seekTo // reads target from w_seekToTime
2026-04-12 12:57:42 -07:00
}
/// Darwin notification name used for widget app wake-up signal.
/// Darwin notifications are inter-process (no payload the command
/// is written to App Group UserDefaults before posting).
let kWidgetCommandNotification = "ca.dallasgroot.NavidromePlayer.widgetCommand" as CFString
/// Shared playback state bridge between the main app and the widget extension.
///
/// The main app calls `pushState(...)` whenever playback state changes.
/// The widget's TimelineProvider calls `pullState()` to build entries.
/// Widget AppIntents call `enqueueCommand(...)` then post a Darwin notification.
final class WidgetSharedState {
2026-04-12 13:39:13 -07:00
static let suiteName = "group.com.navidromeplayer.shared"
2026-04-12 12:57:42 -07:00
static let shared = WidgetSharedState()
private let defaults: UserDefaults?
private init() {
defaults = UserDefaults(suiteName: Self.suiteName)
}
// MARK: - Keys
private enum K {
static let title = "w_title"
static let artist = "w_artist"
static let album = "w_album"
static let isPlaying = "w_isPlaying"
static let currentTime = "w_currentTime"
static let duration = "w_duration"
static let coverArt = "w_coverArt" // JPEG data, ~80 KB
static let blurredArt = "w_blurredArt" // pre-blurred JPEG, ~30 KB
static let lastUpdated = "w_lastUpdated" // TimeInterval since 1970
static let queueNext = "w_queueNext" // JSON-encoded [WidgetQueueItem]
static let hasData = "w_hasData"
static let command = "w_pendingCmd"
static let seekToTime = "w_seekToTime" // target time for seekTo command
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
// v2: waveform + adaptive colors + crossfade
static let waveform = "w_waveform" // JSON [Float], 40 samples 0.01.0
static let accentColor = "w_accentColor" // hex string e.g. "E74C3C"
static let secondColor = "w_secondaryColor" // hex string for text/accents
static let crossfadeAt = "w_crossfadeAt" // seconds when crossfade triggers
2026-04-12 12:57:42 -07:00
}
// MARK: - Write (main app shared defaults)
/// Push the full playback state. Call from AudioPlayer on every meaningful change
/// (song change, play/pause, seek, queue edit). Lightweight just UserDefaults writes.
func pushState(
title: String,
artist: String,
album: String,
isPlaying: Bool,
currentTime: TimeInterval,
duration: TimeInterval,
coverArtJPEG: Data?,
blurredArtJPEG: Data?,
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
queueNext: [WidgetQueueItem],
waveformSamples: [Float]? = nil,
accentColorHex: String? = nil,
secondaryColorHex: String? = nil,
crossfadeAt: TimeInterval? = nil
2026-04-12 12:57:42 -07:00
) {
let d = defaults
d?.set(title, forKey: K.title)
d?.set(artist, forKey: K.artist)
d?.set(album, forKey: K.album)
d?.set(isPlaying, forKey: K.isPlaying)
d?.set(currentTime, forKey: K.currentTime)
d?.set(duration, forKey: K.duration)
d?.set(Date().timeIntervalSince1970, forKey: K.lastUpdated)
d?.set(true, forKey: K.hasData)
if let art = coverArtJPEG { d?.set(art, forKey: K.coverArt) }
if let blur = blurredArtJPEG { d?.set(blur, forKey: K.blurredArt) }
if let encoded = try? JSONEncoder().encode(queueNext) {
d?.set(encoded, forKey: K.queueNext)
}
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
// v2 fields only write if provided (nil = keep previous value)
if let w = waveformSamples, let data = try? JSONEncoder().encode(w) {
d?.set(data, forKey: K.waveform)
}
if let c = accentColorHex { d?.set(c, forKey: K.accentColor) }
if let c = secondaryColorHex { d?.set(c, forKey: K.secondColor) }
if let t = crossfadeAt { d?.set(t, forKey: K.crossfadeAt) }
2026-04-12 12:57:42 -07:00
}
/// Lightweight position-only update call from the periodic time observer
/// so the widget progress bar stays roughly accurate without pushing full state.
func pushPosition(currentTime: TimeInterval, isPlaying: Bool) {
defaults?.set(currentTime, forKey: K.currentTime)
defaults?.set(isPlaying, forKey: K.isPlaying)
defaults?.set(Date().timeIntervalSince1970, forKey: K.lastUpdated)
}
// MARK: - Read (widget shared defaults)
var songTitle: String { defaults?.string(forKey: K.title) ?? "" }
var artist: String { defaults?.string(forKey: K.artist) ?? "" }
var album: String { defaults?.string(forKey: K.album) ?? "" }
var isPlaying: Bool { defaults?.bool(forKey: K.isPlaying) ?? false }
var currentTime: TimeInterval { defaults?.double(forKey: K.currentTime) ?? 0 }
var duration: TimeInterval { defaults?.double(forKey: K.duration) ?? 0 }
var hasData: Bool { defaults?.bool(forKey: K.hasData) ?? false }
var coverArtData: Data? { defaults?.data(forKey: K.coverArt) }
var blurredArtData: Data? { defaults?.data(forKey: K.blurredArt) }
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
/// 40 amplitude samples (0.01.0) representing the song's waveform shape.
var waveformSamples: [Float] {
guard let data = defaults?.data(forKey: K.waveform),
let samples = try? JSONDecoder().decode([Float].self, from: data)
else { return [] }
return samples
}
/// Dominant color extracted from cover art, as a hex string like "E74C3C".
var accentColorHex: String? { defaults?.string(forKey: K.accentColor) }
/// Computed secondary color for text/accents.
var secondaryColorHex: String? { defaults?.string(forKey: K.secondColor) }
/// The playback time (seconds) at which the crossfade will trigger.
var crossfadeAt: TimeInterval { defaults?.double(forKey: K.crossfadeAt) ?? 0 }
2026-04-12 12:57:42 -07:00
/// Seconds since the state was last written by the main app.
var lastUpdatedDate: Date {
let ts = defaults?.double(forKey: K.lastUpdated) ?? 0
return Date(timeIntervalSince1970: ts)
}
var queueNext: [WidgetQueueItem] {
guard let data = defaults?.data(forKey: K.queueNext),
let items = try? JSONDecoder().decode([WidgetQueueItem].self, from: data)
else { return [] }
return items
}
// MARK: - Commands (widget app)
/// Write a command for the main app to pick up via Darwin notification observer.
func enqueueCommand(_ cmd: WidgetCommand) {
defaults?.set(cmd.rawValue, forKey: K.command)
}
/// Write a seekTo target time alongside the seekTo command.
func enqueueSeekTo(time: TimeInterval) {
defaults?.set(time, forKey: K.seekToTime)
enqueueCommand(.seekTo)
}
/// The target time for the most recent seekTo command.
var seekToTime: TimeInterval {
defaults?.double(forKey: K.seekToTime) ?? 0
}
2026-04-12 12:57:42 -07:00
/// Read and clear the pending command. Called by the app's Darwin observer.
func dequeueCommand() -> WidgetCommand? {
guard let raw = defaults?.string(forKey: K.command),
let cmd = WidgetCommand(rawValue: raw) else { return nil }
defaults?.removeObject(forKey: K.command)
return cmd
}
// MARK: - Optimistic Toggle (for widget-side instant UI update)
/// Toggle isPlaying optimistically so the widget re-renders immediately
/// before the app processes the command.
func togglePlayingOptimistic() {
let current = isPlaying
defaults?.set(!current, forKey: K.isPlaying)
}
// MARK: - Clear
func clearAll() {
for key in [K.title, K.artist, K.album, K.isPlaying, K.currentTime,
K.duration, K.coverArt, K.blurredArt, K.lastUpdated,
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
K.queueNext, K.hasData, K.command, K.seekToTime,
K.waveform, K.accentColor, K.secondColor, K.crossfadeAt] {
2026-04-12 12:57:42 -07:00
defaults?.removeObject(forKey: key)
}
}
}
// MARK: - Darwin Notification Helpers
/// Post a Darwin notification (inter-process, no payload).
func postDarwinNotification(_ name: CFString) {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(name),
nil, nil, true
)
}