NavidromeApp/Shared/Storage/WidgetSharedState.swift
Dallas Groot b9844b23cd 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

225 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
//
// App Group: "group.com.navidromeplayer.shared"
// 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
}
/// 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
}
/// 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 {
static let suiteName = "group.com.navidromeplayer.shared"
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
// 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
}
// 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?,
queueNext: [WidgetQueueItem],
waveformSamples: [Float]? = nil,
accentColorHex: String? = nil,
secondaryColorHex: String? = nil,
crossfadeAt: TimeInterval? = nil
) {
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)
}
// 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) }
}
/// 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) }
/// 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 }
/// 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
}
/// 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,
K.queueNext, K.hasData, K.command, K.seekToTime,
K.waveform, K.accentColor, K.secondColor, K.crossfadeAt] {
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
)
}