NavidromeApp/iOS/Data/WidgetBridge.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

300 lines
12 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
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
import WidgetKit
//
// WidgetBridge.swift
// TARGET: Main app only (NOT the widget extension).
//
// Responsibilities:
// 1. Pre-blurs album art for the widget background (CIGaussianBlur)
// 2. Pushes playback state to App Group on every change
// 3. Observes Darwin notifications for widget app commands
// 4. Reloads widget timelines when state changes
//
// Integration: call WidgetBridge.shared.start() once from app launch,
// then call updateNowPlaying(...) from AudioPlayer on every state change.
//
final class WidgetBridge {
static let shared = WidgetBridge()
private let state = WidgetSharedState.shared
private let blurContext = CIContext(options: [.useSoftwareRenderer: false])
private var isObserving = false
/// Cached blurred art to avoid re-blurring on every time-observer tick.
/// Reset when the cover art ID changes.
private var cachedBlurCoverArtId: String?
private var cachedBlurData: Data?
private var cachedArtData: Data?
private var cachedAccentHex: String?
private var cachedSecondaryHex: String?
private init() {}
// MARK: - Lifecycle
/// Call once from app launch (e.g. NavidromePlayerApp.init or AppDelegate).
/// Sets up the Darwin notification observer for widget commands.
func start() {
guard !isObserving else { return }
isObserving = true
let center = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
center,
observer,
{ _, observer, _, _, _ in
guard let observer else { return }
let bridge = Unmanaged<WidgetBridge>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async { bridge.handleWidgetCommand() }
},
kWidgetCommandNotification,
nil,
.deliverImmediately
)
}
// MARK: - Push State to Widget
/// Full state push call on song change, play/pause, queue edit.
/// Blurs the album art if the cover changed; skips blur if same art.
func updateNowPlaying(
title: String,
artist: String,
album: String,
isPlaying: Bool,
currentTime: TimeInterval,
duration: TimeInterval,
coverArtId: String?,
coverArtImage: UIImage?,
queue: [(title: String, artist: String)],
waveformSamples: [Float]? = nil,
crossfadeAt: TimeInterval? = nil
) {
// Only re-process if the cover art actually changed
if let id = coverArtId, id != cachedBlurCoverArtId, let image = coverArtImage {
cachedBlurCoverArtId = id
cachedArtData = image.jpegData(compressionQuality: 0.7)
let blurred = blurImage(image, radius: 60)
cachedBlurData = blurred?.jpegData(compressionQuality: 0.5)
// Extract dominant color for adaptive theming
let (accent, secondary) = extractColors(from: image)
cachedAccentHex = accent
cachedSecondaryHex = secondary
}
let queueItems = queue.prefix(3).map { WidgetQueueItem(title: $0.title, artist: $0.artist) }
state.pushState(
title: title,
artist: artist,
album: album,
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration,
coverArtJPEG: cachedArtData,
blurredArtJPEG: cachedBlurData,
queueNext: queueItems,
waveformSamples: waveformSamples,
accentColorHex: cachedAccentHex,
secondaryColorHex: cachedSecondaryHex,
crossfadeAt: crossfadeAt
)
WidgetCenter.shared.reloadAllTimelines()
}
/// Lightweight position-only push call from the time observer (~every 5s).
/// Does NOT reload timelines (the timeline entries already project forward).
func updatePosition(currentTime: TimeInterval, isPlaying: Bool) {
state.pushPosition(currentTime: currentTime, isPlaying: isPlaying)
}
/// Clear widget state (e.g. on logout or server change).
func clear() {
cachedBlurCoverArtId = nil
cachedBlurData = nil
cachedArtData = nil
cachedAccentHex = nil
cachedSecondaryHex = nil
state.clearAll()
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Handle Widget Commands
/// Called by the Darwin notification observer when the app process is running.
private func handleWidgetCommand() {
processAnyPendingCommand()
}
/// Check App Group UserDefaults for any pending widget command and execute it.
/// Called from two places:
/// 1. Darwin notification observer (app is running in background with audio)
/// 2. resumeVisTimers / foreground entry (app was suspended, Darwin missed)
func processAnyPendingCommand() {
guard let cmd = state.dequeueCommand() else { return }
let player = AudioPlayer.shared
switch cmd {
case .play:
// Idempotency: don't resume if already playing (avoids audio glitches)
guard !player.isPlaying else { break }
// If no song is loaded (app was killed), restore from saved state first
if player.currentSong == nil {
if let saved = PlaybackStateStore.shared.load() {
player.play(song: saved.currentSong, fromQueue: saved.queue, at: saved.index)
}
} else {
player.resume()
}
case .pause:
// Idempotency: don't pause if already paused
guard player.isPlaying else { break }
player.pause()
case .next:
player.next()
case .previous:
player.previous()
case .seekForward15:
guard player.duration > 0 else { break }
player.seek(to: min(player.currentTime + 15, player.duration))
case .seekBackward15:
player.seek(to: max(player.currentTime - 15, 0))
case .seekTo:
guard player.duration > 0 else { break }
let target = state.seekToTime
player.seek(to: min(max(target, 0), player.duration))
}
DebugLogger.shared.log("Widget command: \(cmd.rawValue)", category: "Widget")
}
// MARK: - Color Extraction
/// Extract dominant color from cover art using CIAreaAverage, then compute
/// a secondary color for text/accents. Returns (accentHex, secondaryHex).
private func extractColors(from image: UIImage) -> (String, String) {
let accentHex = dominantColorHex(from: image)
let secondaryHex = computeSecondaryHex(from: accentHex)
return (accentHex, secondaryHex)
}
/// Uses CIAreaAverage to find the single dominant color in the image.
/// Returns a hex string like "E74C3C".
private func dominantColorHex(from image: UIImage) -> String {
guard let cgImage = image.cgImage else { return "E74C3C" }
let ciImage = CIImage(cgImage: cgImage)
let extent = ciImage.extent
guard let filter = CIFilter(name: "CIAreaAverage",
parameters: [kCIInputImageKey: ciImage,
kCIInputExtentKey: CIVector(cgRect: extent)]),
let output = filter.outputImage else { return "E74C3C" }
// Read the single 1×1 pixel result
var pixel = [UInt8](repeating: 0, count: 4)
blurContext.render(output,
toBitmap: &pixel,
rowBytes: 4,
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .RGBA8,
colorSpace: CGColorSpaceCreateDeviceRGB())
var r = CGFloat(pixel[0]) / 255.0
var g = CGFloat(pixel[1]) / 255.0
var b = CGFloat(pixel[2]) / 255.0
// Boost saturation averaged colors tend to be muddy.
// Convert to HSB, clamp brightness, increase saturation.
var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0
UIColor(red: r, green: g, blue: b, alpha: 1).getHue(&h, saturation: &s, brightness: &br, alpha: &a)
// Ensure usable contrast: not too dark, not too light
br = max(0.35, min(0.80, br))
s = max(0.30, min(0.90, s * 1.3))
let boosted = UIColor(hue: h, saturation: s, brightness: br, alpha: 1)
boosted.getRed(&r, green: &g, blue: &b, alpha: &a)
let ri = Int(r * 255), gi = Int(g * 255), bi = Int(b * 255)
return String(format: "%02X%02X%02X", ri, gi, bi)
}
/// Compute a lighter/desaturated secondary color from the accent hex.
/// Used for artist text, time labels, and glass tint.
private func computeSecondaryHex(from hex: String) -> String {
let (r, g, b) = hexToRGB(hex)
var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0
UIColor(red: r, green: g, blue: b, alpha: 1).getHue(&h, saturation: &s, brightness: &br, alpha: &a)
// Lighter, less saturated version for text
let secondary = UIColor(hue: h, saturation: s * 0.5, brightness: min(br + 0.25, 0.95), alpha: 1)
var sr: CGFloat = 0, sg: CGFloat = 0, sb: CGFloat = 0
secondary.getRed(&sr, green: &sg, blue: &sb, alpha: &a)
return String(format: "%02X%02X%02X", Int(sr * 255), Int(sg * 255), Int(sb * 255))
}
private func hexToRGB(_ hex: String) -> (CGFloat, CGFloat, CGFloat) {
var h = hex
if h.hasPrefix("#") { h = String(h.dropFirst()) }
guard h.count == 6, let val = UInt64(h, radix: 16) else {
return (0.9, 0.3, 0.24) // fallback pink
}
return (CGFloat((val >> 16) & 0xFF) / 255.0,
CGFloat((val >> 8) & 0xFF) / 255.0,
CGFloat( val & 0xFF) / 255.0)
}
// MARK: - Gaussian Blur
/// Applies a heavy gaussian blur to the image for the widget background.
/// Uses CoreImage on the GPU fast even for large images.
private func blurImage(_ image: UIImage, radius: CGFloat) -> UIImage? {
// Downscale to ~200px wide first no need for full-res blur
let maxDim: CGFloat = 200
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let targetSize = CGSize(
width: image.size.width * scale,
height: image.size.height * scale
)
UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: targetSize))
let downscaled = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let cgImage = (downscaled ?? image).cgImage else { return nil }
let ciImage = CIImage(cgImage: cgImage)
// Clamp edges to prevent dark border artifacts from the blur kernel
let clamped = ciImage.clampedToExtent()
guard let blurFilter = CIFilter(name: "CIGaussianBlur") else { return nil }
blurFilter.setValue(clamped, forKey: kCIInputImageKey)
blurFilter.setValue(radius, forKey: kCIInputRadiusKey)
guard let output = blurFilter.outputImage else { return nil }
// Crop back to original extent (blur expands the image)
let cropped = output.cropped(to: ciImage.extent)
guard let cgResult = blurContext.createCGImage(cropped, from: ciImage.extent) else { return nil }
return UIImage(cgImage: cgResult)
}
}