NavidromeApp/Widget/NowPlayingWidget.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

183 lines
6.2 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 WidgetKit
import SwiftUI
//
// NowPlayingWidget.swift
// TARGET: Widget extension only.
//
// Defines the widget bundle, timeline provider, and timeline entry.
// The provider reads from WidgetSharedState (App Group UserDefaults)
// and generates entries with projected playback positions so the
// progress bar advances even between reloads.
//
// MARK: - Widget Bundle
@main
struct NavidromeWidgetBundle: WidgetBundle {
var body: some Widget {
NowPlayingWidget()
}
}
// MARK: - Timeline Entry
struct NowPlayingEntry: TimelineEntry {
let date: Date
// Song metadata
let songTitle: String
let artist: String
let album: String
// Playback state
let isPlaying: Bool
let currentTime: TimeInterval
let duration: TimeInterval
// Images (raw JPEG data decoded in the view)
let coverArtData: Data?
let blurredArtData: Data?
// Up Next queue
let queueNext: [WidgetQueueItem]
// v2: waveform, adaptive colors, crossfade
let waveformSamples: [Float] // 40 peaks 0.01.0
let accentColorHex: String? // dominant color e.g. "E74C3C"
let secondaryColorHex: String? // lighter variant for text
let crossfadeAt: TimeInterval // 0 = no crossfade data
// Whether any data has ever been written
let hasData: Bool
// MARK: - Computed
var progress: Double {
guard duration > 0 else { return 0 }
return min(max(currentTime / duration, 0), 1)
}
var currentTimeFormatted: String { formatTime(currentTime) }
var remainingTimeFormatted: String {
"-\(formatTime(max(duration - currentTime, 0)))"
}
/// Seconds until crossfade triggers. Nil if no crossfade data or already past.
var crossfadeCountdown: TimeInterval? {
guard crossfadeAt > 0, crossfadeAt > currentTime else { return nil }
let remaining = crossfadeAt - currentTime
return remaining < 120 ? remaining : nil // only show if < 2 min
}
var crossfadeCountdownFormatted: String? {
guard let cd = crossfadeCountdown else { return nil }
return "in \(formatTime(cd))"
}
private func formatTime(_ t: TimeInterval) -> String {
let total = Int(max(t, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
// MARK: - Placeholder
static let placeholder = NowPlayingEntry(
date: .now,
songTitle: "Not Playing",
artist: "NavidromePlayer",
album: "",
isPlaying: false,
currentTime: 0,
duration: 1,
coverArtData: nil,
blurredArtData: nil,
queueNext: [],
waveformSamples: [],
accentColorHex: nil,
secondaryColorHex: nil,
crossfadeAt: 0,
hasData: false
)
}
// MARK: - Timeline Provider
struct NowPlayingProvider: TimelineProvider {
func placeholder(in context: Context) -> NowPlayingEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (NowPlayingEntry) -> Void) {
completion(buildEntry(at: .now))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<NowPlayingEntry>) -> Void) {
let state = WidgetSharedState.shared
let now = Date()
if state.isPlaying && state.duration > 0 {
var entries: [NowPlayingEntry] = []
let elapsed = now.timeIntervalSince(state.lastUpdatedDate)
let baseTime = state.currentTime + elapsed
for i in 0..<10 {
let offset = Double(i) * 30.0
let entryDate = now.addingTimeInterval(offset)
let projectedTime = min(baseTime + offset, state.duration)
entries.append(makeEntry(at: entryDate, projectedTime: projectedTime, state: state))
}
completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(300))))
} else {
let entry = buildEntry(at: now)
completion(Timeline(entries: [entry], policy: .after(now.addingTimeInterval(15 * 60))))
}
}
private func buildEntry(at date: Date) -> NowPlayingEntry {
let state = WidgetSharedState.shared
var projectedTime = state.currentTime
if state.isPlaying && state.duration > 0 {
let elapsed = date.timeIntervalSince(state.lastUpdatedDate)
projectedTime = min(state.currentTime + elapsed, state.duration)
}
return makeEntry(at: date, projectedTime: projectedTime, state: state)
}
private func makeEntry(at date: Date, projectedTime: TimeInterval, state: WidgetSharedState) -> NowPlayingEntry {
NowPlayingEntry(
date: date,
songTitle: state.songTitle.isEmpty ? "Not Playing" : state.songTitle,
artist: state.artist.isEmpty ? "NavidromePlayer" : state.artist,
album: state.album,
isPlaying: state.isPlaying,
currentTime: projectedTime,
duration: state.duration,
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
waveformSamples: state.waveformSamples,
accentColorHex: state.accentColorHex,
secondaryColorHex: state.secondaryColorHex,
crossfadeAt: state.crossfadeAt,
hasData: state.hasData
)
}
}
// MARK: - Widget Definition
struct NowPlayingWidget: Widget {
let kind = "NowPlayingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NowPlayingProvider()) { entry in
NowPlayingWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Now Playing")
.description("Control playback and see what's playing.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}