Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
Dallas Groot 2026-04-12 19:25:15 -07:00
commit 8607bce56a
15 changed files with 1871 additions and 446 deletions

View file

@ -1785,26 +1785,29 @@ class AudioPlayer: NSObject, ObservableObject {
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
// Try to get cover art from memory caches (zero I/O).
// Size must match fetchAndSetArtwork (600) or the cache key won't match.
// Cover art: custom first, then server (memory + disk).
var coverImage: UIImage?
var artKey = song.coverArt // cache key for WidgetBridge blur dedup
var artKey = song.coverArt
if let id = song.coverArt {
// Custom cover takes priority same logic as AsyncCoverArt
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
coverImage = custom
// Prefix key so the bridge re-blurs when custom art is set/removed.
// Same album ID produces a different key forces blur update.
// Two songs sharing the same coverArt ID with the same custom cover
// produce the same "custom_al-123" key blur is reused, not redone.
artKey = "custom_\(id)"
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) {
// cachedImage checks memory first, then disk acceptable here because
// pushWidgetState only fires on song change / pause / resume, not per-frame.
coverImage = ImageCache.shared.cachedImage(for: url)
}
}
// Waveform: downsample offline vis data to 40 peaks, or pseudo-random fallback.
let waveform = sampleWaveform(sampleCount: 40)
// Crossfade trigger time from Smart DJ profile (seconds into track).
let crossfadeTime: TimeInterval? = {
if isUsingCrossfade,
let ss = SmartCrossfadeManager.shared.currentProfile?.silenceStart,
ss > 0 { return ss }
return nil
}()
WidgetBridge.shared.updateNowPlaying(
title: song.title,
artist: song.artist ?? "Unknown",
@ -1814,9 +1817,51 @@ class AudioPlayer: NSObject, ObservableObject {
duration: duration,
coverArtId: artKey,
coverArtImage: coverImage,
queue: upcoming
queue: upcoming,
waveformSamples: waveform,
crossfadeAt: crossfadeTime
)
}
/// Downsample the offline vis buffer to `sampleCount` peak amplitudes (0.01.0).
/// If no vis data is loaded, returns a seeded pseudo-random shape per song ID.
private func sampleWaveform(sampleCount: Int = 40) -> [Float] {
let buf = offlineVisBuffer
if !buf.isEmpty {
var samples = [Float]()
samples.reserveCapacity(sampleCount)
for i in 0..<sampleCount {
let startFrame = i * buf.frameCount / sampleCount
let endFrame = min((i + 1) * buf.frameCount / sampleCount, buf.frameCount)
var peak: Float = 0
for f in startFrame..<endFrame {
let offset = f * buf.pointsPerFrame
let end = min(offset + buf.pointsPerFrame, buf.data.count)
for j in offset..<end {
peak = max(peak, buf.data[j])
}
}
samples.append(peak)
}
return samples
}
// Fallback: seeded PRNG for consistent shape per song
guard let id = currentSong?.id else {
return [Float](repeating: 0.3, count: sampleCount)
}
var seed: UInt64 = 5381
for c in id.utf8 { seed = seed &* 33 &+ UInt64(c) }
var samples = [Float]()
samples.reserveCapacity(sampleCount)
for i in 0..<sampleCount {
seed = (seed &* 16807 &+ 7) % 2147483647
let base = Float(seed % 1000) / 1000.0
let envelope = 0.5 + 0.5 * sin(Float.pi * Float(i) / Float(sampleCount))
samples.append(min(0.2 + base * 0.6 * envelope, 1.0))
}
return samples
}
#endif
// MARK: - Cleanup

View file

@ -75,4 +75,35 @@ struct PlaybackStateStore {
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)
}
}
}

View file

@ -66,6 +66,11 @@ final class WidgetSharedState {
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)
@ -81,7 +86,11 @@ final class WidgetSharedState {
duration: TimeInterval,
coverArtJPEG: Data?,
blurredArtJPEG: Data?,
queueNext: [WidgetQueueItem]
queueNext: [WidgetQueueItem],
waveformSamples: [Float]? = nil,
accentColorHex: String? = nil,
secondaryColorHex: String? = nil,
crossfadeAt: TimeInterval? = nil
) {
let d = defaults
d?.set(title, forKey: K.title)
@ -99,6 +108,14 @@ final class WidgetSharedState {
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
@ -121,6 +138,21 @@ final class WidgetSharedState {
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
@ -174,7 +206,8 @@ final class WidgetSharedState {
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.queueNext, K.hasData, K.command, K.seekToTime,
K.waveform, K.accentColor, K.secondColor, K.crossfadeAt] {
defaults?.removeObject(forKey: key)
}
}

View file

@ -39,9 +39,15 @@ struct NowPlayingEntry: TimelineEntry {
let coverArtData: Data?
let blurredArtData: Data?
// Up Next (large widget)
// 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
@ -52,20 +58,27 @@ struct NowPlayingEntry: TimelineEntry {
return min(max(currentTime / duration, 0), 1)
}
var currentTimeFormatted: String {
formatTime(currentTime)
}
var currentTimeFormatted: String { formatTime(currentTime) }
var remainingTimeFormatted: String {
let remaining = max(duration - currentTime, 0)
return "-\(formatTime(remaining))"
"-\(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))
let m = total / 60
let s = total % 60
return String(format: "%d:%02d", m, s)
return String(format: "%d:%02d", total / 60, total % 60)
}
// MARK: - Placeholder
@ -81,6 +94,10 @@ struct NowPlayingEntry: TimelineEntry {
coverArtData: nil,
blurredArtData: nil,
queueNext: [],
waveformSamples: [],
accentColorHex: nil,
secondaryColorHex: nil,
crossfadeAt: 0,
hasData: false
)
}
@ -89,9 +106,7 @@ struct NowPlayingEntry: TimelineEntry {
struct NowPlayingProvider: TimelineProvider {
func placeholder(in context: Context) -> NowPlayingEntry {
.placeholder
}
func placeholder(in context: Context) -> NowPlayingEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (NowPlayingEntry) -> Void) {
completion(buildEntry(at: .now))
@ -102,8 +117,6 @@ struct NowPlayingProvider: TimelineProvider {
let now = Date()
if state.isPlaying && state.duration > 0 {
// Generate entries every 30 seconds for the next 5 minutes
// so the progress bar visually advances between reloads.
var entries: [NowPlayingEntry] = []
let elapsed = now.timeIntervalSince(state.lastUpdatedDate)
let baseTime = state.currentTime + elapsed
@ -112,47 +125,28 @@ struct NowPlayingProvider: TimelineProvider {
let offset = Double(i) * 30.0
let entryDate = now.addingTimeInterval(offset)
let projectedTime = min(baseTime + offset, state.duration)
entries.append(NowPlayingEntry(
date: entryDate,
songTitle: state.songTitle,
artist: state.artist,
album: state.album,
isPlaying: true,
currentTime: projectedTime,
duration: state.duration,
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
hasData: state.hasData
))
entries.append(makeEntry(at: entryDate, projectedTime: projectedTime, state: state))
}
// Re-request timeline after the last entry
let refreshDate = now.addingTimeInterval(300)
completion(Timeline(entries: entries, policy: .after(refreshDate)))
completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(300))))
} else {
// Paused or no data single static entry
let entry = buildEntry(at: now)
// Refresh every 15 minutes in case the app updates state
let refreshDate = now.addingTimeInterval(15 * 60)
completion(Timeline(entries: [entry], policy: .after(refreshDate)))
completion(Timeline(entries: [entry], policy: .after(now.addingTimeInterval(15 * 60))))
}
}
// MARK: - Helpers
private func buildEntry(at date: Date) -> NowPlayingEntry {
let state = WidgetSharedState.shared
// Project currentTime forward if playing
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)
}
return NowPlayingEntry(
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,
@ -163,6 +157,10 @@ struct NowPlayingProvider: TimelineProvider {
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
waveformSamples: state.waveformSamples,
accentColorHex: state.accentColorHex,
secondaryColorHex: state.secondaryColorHex,
crossfadeAt: state.crossfadeAt,
hasData: state.hasData
)
}

View file

@ -6,68 +6,265 @@ import AppIntents
// NowPlayingWidgetViews.swift
// TARGET: Widget extension only.
//
// SwiftUI views for small, medium, and large widgets.
// Features:
// Blurred album art background with light/dark scrim
// Interactive play/pause, next, previous via AppIntents
// Progress bar with left/right tap zones for ±15s seek
// "Up Next" queue in large widget
// Graceful idle state when nothing is playing
// v2 redesign: glassmorphism, waveform scrubber, color-adaptive theming,
// crossfade countdown, Up Next queue (large).
//
// MARK: - Accent Color
// MARK: - Color Helpers
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
extension Color {
/// Create a Color from a hex string like "E74C3C".
init(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h = String(h.dropFirst()) }
guard h.count == 6, let val = UInt64(h, radix: 16) else {
self = .pink
return
}
self.init(
red: Double((val >> 16) & 0xFF) / 255.0,
green: Double((val >> 8) & 0xFF) / 255.0,
blue: Double( val & 0xFF) / 255.0
)
}
}
// MARK: - Size Router
// MARK: - Adaptive Colors from Entry
struct WidgetColors {
let accent: Color
let secondary: Color
let textPrimary: Color
let textSecondary: Color
let textTertiary: Color
let barUnplayed: Color
init(entry: NowPlayingEntry) {
if let hex = entry.accentColorHex {
accent = Color(hex: hex)
} else {
accent = Color(red: 0.91, green: 0.30, blue: 0.24) // default pink-red
}
if let hex = entry.secondaryColorHex {
secondary = Color(hex: hex)
} else {
secondary = .white.opacity(0.7)
}
textPrimary = .white
textSecondary = secondary.opacity(0.9)
textTertiary = .white.opacity(0.4)
barUnplayed = .white.opacity(0.18)
}
}
// MARK: - Main View (dispatches by family)
struct NowPlayingWidgetView: View {
let entry: NowPlayingEntry
@Environment(\.widgetFamily) var family
@Environment(\.colorScheme) var colorScheme
var body: some View {
let colors = WidgetColors(entry: entry)
ZStack {
// Blurred album art background
// Layer 0: Blurred album art background
backgroundLayer
// Dark/light scrim for text readability
scrimOverlay
// Content
// Dark scrim for contrast
Color.black.opacity(0.28)
// Layer 1: Glass panel + content
switch family {
case .systemSmall: SmallWidgetContent(entry: entry)
case .systemMedium: MediumWidgetContent(entry: entry)
case .systemLarge: LargeWidgetContent(entry: entry)
default: MediumWidgetContent(entry: entry)
case .systemSmall:
SmallContent(entry: entry, colors: colors)
case .systemMedium:
MediumContent(entry: entry, colors: colors)
case .systemLarge:
LargeContent(entry: entry, colors: colors)
default:
MediumContent(entry: entry, colors: colors)
}
}
}
// MARK: - Background
@ViewBuilder
private var backgroundLayer: some View {
if let data = entry.blurredArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
if let data = entry.blurredArtData, let img = UIImage(data: data) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
// Fallback gradient when no art is available
LinearGradient(
colors: colorScheme == .dark
? [Color(white: 0.12), Color(white: 0.08)]
: [Color(white: 0.92), Color(white: 0.85)],
colors: [Color(hex: entry.accentColorHex ?? "3A1A1A"), .black],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
private var scrimOverlay: some View {
Rectangle().fill(
colorScheme == .dark
? Color.black.opacity(0.55)
: Color.white.opacity(0.50)
)
//
// MARK: - Glass Panel Container
//
struct GlassPanel<Content: View>: View {
let colors: WidgetColors
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(colors.accent.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(.white.opacity(0.12), lineWidth: 0.5)
)
)
.padding(8)
}
}
//
// MARK: - Waveform Bar (Canvas + SeekToIntent overlay)
//
struct WaveformBar: View {
let samples: [Float]
let progress: Double
let accentColor: Color
let unplayedColor: Color
let barHeight: CGFloat
private let barCount = 40
private let seekSegments = 20
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let barWidth: CGFloat = max((w - CGFloat(barCount - 1) * 1.5) / CGFloat(barCount), 1.5)
let playedCount = Int(Double(barCount) * progress)
ZStack {
// Waveform canvas
Canvas { ctx, size in
let effectiveSamples = samples.isEmpty
? (0..<barCount).map { _ in Float(0.3) }
: samples
let totalBars = min(effectiveSamples.count, barCount)
let spacing: CGFloat = 1.5
let bw = max((size.width - CGFloat(totalBars - 1) * spacing) / CGFloat(totalBars), 1.5)
for i in 0..<totalBars {
let amp = CGFloat(effectiveSamples[i])
let h = max(amp * (barHeight - 2), 2)
let x = CGFloat(i) * (bw + spacing)
let y = barHeight - h
let rect = CGRect(x: x, y: y, width: bw, height: h)
let rr = Path(roundedRect: rect, cornerRadius: 1)
let color: Color = i < playedCount ? accentColor : unplayedColor
ctx.fill(rr, with: .color(color))
}
}
.frame(height: barHeight)
// Seek tap zones 20 segments over the waveform
HStack(spacing: 0) {
ForEach(0..<seekSegments, id: \.self) { i in
let fraction = (Double(i) + 0.5) / Double(seekSegments)
Button(intent: SeekToIntent(fraction: fraction)) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.frame(height: max(barHeight, 28))
}
}
.frame(height: max(barHeight, 28))
}
}
//
// MARK: - Transport Controls
//
struct TransportControls: View {
let isPlaying: Bool
let iconSize: CGFloat
let playSize: CGFloat
var body: some View {
HStack(spacing: iconSize * 1.5) {
// Previous
Button(intent: PreviousTrackIntent()) {
Image(systemName: "backward.fill")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(.plain)
// Play / Pause
Button(intent: PlayPauseIntent()) {
ZStack {
Circle()
.fill(.white.opacity(0.15))
.frame(width: playSize, height: playSize)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: playSize * 0.4, weight: .bold))
.foregroundStyle(.white)
.offset(x: isPlaying ? 0 : 1) // optical center for play triangle
}
}
.buttonStyle(.plain)
// Next
Button(intent: NextTrackIntent()) {
Image(systemName: "forward.fill")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(.plain)
}
}
}
//
// MARK: - Cover Art View
//
struct CoverArtView: View {
let data: Data?
let size: CGFloat
var body: some View {
Group {
if let data = data, let img = UIImage(data: data) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ZStack {
Color.white.opacity(0.08)
Image(systemName: "music.note")
.font(.system(size: size * 0.35, weight: .light))
.foregroundStyle(.white.opacity(0.3))
}
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.15, style: .continuous))
.shadow(color: .black.opacity(0.35), radius: 6, y: 3)
}
}
@ -75,74 +272,57 @@ struct NowPlayingWidgetView: View {
// MARK: - SMALL WIDGET
//
struct SmallWidgetContent: View {
struct SmallContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
let colors: WidgetColors
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Album art + song info
HStack(spacing: 10) {
coverArt(size: 48)
GlassPanel(colors: colors) {
VStack(spacing: 6) {
// Top: art + text
HStack(spacing: 10) {
CoverArtView(data: entry.coverArtData, size: 48)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(2)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
// Waveform
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 18
)
// Progress bar
ProgressBarView(entry: entry, height: 3, showTimes: false)
// Play/pause button centered
HStack {
Spacer()
Button(intent: PlayPauseIntent()) {
Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(primaryColor)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
.buttonStyle(.plain)
Spacer()
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.35))
.foregroundStyle(Color(white: 0.5))
)
// Controls
TransportControls(isPlaying: entry.isPlaying, iconSize: 14, playSize: 28)
}
}
}
}
@ -151,99 +331,109 @@ struct SmallWidgetContent: View {
// MARK: - MEDIUM WIDGET
//
struct MediumWidgetContent: View {
struct MediumContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
let colors: WidgetColors
var body: some View {
HStack(spacing: 14) {
// Album art
coverArt(size: 90)
GlassPanel(colors: colors) {
VStack(spacing: 6) {
// Top row: art + song info
HStack(spacing: 14) {
CoverArtView(data: entry.coverArtData, size: 64)
// Right side: info + progress + controls
VStack(alignment: .leading, spacing: 0) {
// Song info
Text(entry.songTitle)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
.padding(.bottom, 2)
Text(entry.artist)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
Spacer(minLength: 0)
Text(entry.album)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(colors.textTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.bottom, 8)
// Waveform + times
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 18
)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
// Transport controls
HStack(spacing: 0) {
Spacer(minLength: 0)
transportButton(
icon: "backward.fill",
size: 18,
intent: PreviousTrackIntent()
)
Spacer(minLength: 0)
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 26,
intent: PlayPauseIntent()
)
Spacer(minLength: 0)
transportButton(
icon: "forward.fill",
size: 18,
intent: NextTrackIntent()
)
Spacer(minLength: 0)
}
TransportControls(isPlaying: entry.isPlaying, iconSize: 16, playSize: 30)
// Up Next footer with crossfade countdown
upNextFooter
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
} else {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.3))
.foregroundStyle(Color(white: 0.5))
)
}
}
private var upNextFooter: some View {
let nextTrack = entry.queueNext.first
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 44, height: 36)
.contentShape(Rectangle())
if nextTrack != nil || entry.crossfadeCountdownFormatted != nil {
HStack(spacing: 6) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
if let track = nextTrack {
Text("Up Next: \(track.title) · \(track.artist)")
.font(.system(size: 10, weight: .regular))
.foregroundStyle(.white.opacity(0.4))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 0)
if entry.isPlaying, let cd = entry.crossfadeCountdownFormatted {
Text(cd)
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(colors.accent.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(.white.opacity(0.06))
)
} else if !entry.isPlaying {
Text("paused")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(colors.accent.opacity(0.7))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(.white.opacity(0.06))
)
}
}
}
.buttonStyle(.plain)
}
}
@ -251,208 +441,161 @@ struct MediumWidgetContent: View {
// MARK: - LARGE WIDGET
//
struct LargeWidgetContent: View {
struct LargeContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
private var tertiaryColor: Color { colorScheme == .dark ? Color(white: 0.45) : Color(white: 0.55) }
let colors: WidgetColors
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 4)
GlassPanel(colors: colors) {
VStack(spacing: 8) {
// Header: art + info + waveform
HStack(spacing: 16) {
CoverArtView(data: entry.coverArtData, size: 80)
// Centered album art
coverArt(size: 140)
.padding(.bottom, 12)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
// Song info
Text(entry.songTitle)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
.padding(.bottom, 1)
Text(entry.artist)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
Text(entry.album)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(colors.textTertiary)
.lineLimit(1)
if !entry.album.isEmpty {
Text(entry.album)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(tertiaryColor)
.lineLimit(1)
.padding(.top, 1)
}
Spacer(minLength: 4)
Spacer(minLength: 8)
// Waveform inside the header
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 22
)
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.horizontal, 8)
.padding(.bottom, 10)
// Transport controls
HStack(spacing: 0) {
Spacer()
transportButton(icon: "backward.fill", size: 20, intent: PreviousTrackIntent())
Spacer()
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 30,
intent: PlayPauseIntent()
)
Spacer()
transportButton(icon: "forward.fill", size: 20, intent: NextTrackIntent())
Spacer()
}
.padding(.bottom, 10)
// Up Next divider + queue
if !entry.queueNext.isEmpty {
Divider()
.background(tertiaryColor.opacity(0.3))
.padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 4) {
Text("UP NEXT")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(tertiaryColor)
.tracking(1.2)
.padding(.top, 6)
.padding(.leading, 4)
ForEach(Array(entry.queueNext.prefix(3).enumerated()), id: \.offset) { idx, item in
HStack(spacing: 6) {
Text("\(idx + 2).")
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(accentPink)
.frame(width: 18, alignment: .trailing)
Text(item.title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(primaryColor)
.lineLimit(1)
Text("\(item.artist)")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
Spacer(minLength: 0)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Transport controls
TransportControls(isPlaying: entry.isPlaying, iconSize: 18, playSize: 34)
// Divider
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 0.5)
.padding(.horizontal, 2)
// Queue section
VStack(alignment: .leading, spacing: 0) {
Text("UP NEXT")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.3))
.tracking(0.8)
.padding(.bottom, 6)
.padding(.leading, 2)
if entry.queueNext.isEmpty {
Text("No upcoming tracks")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.25))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
ForEach(Array(entry.queueNext.prefix(3).enumerated()), id: \.offset) { idx, item in
queueRow(index: idx + 1, item: item)
}
.padding(.leading, 4)
}
}
}
.frame(maxHeight: .infinity, alignment: .top)
Spacer(minLength: 4)
// Crossfade footer
crossfadeFooter
}
}
.padding(14)
}
private func queueRow(index: Int, item: WidgetQueueItem) -> some View {
HStack(spacing: 10) {
Text("\(index)")
.font(.system(size: 11, weight: .medium).monospacedDigit())
.foregroundStyle(.white.opacity(0.3))
.frame(width: 14, alignment: .center)
// Mini art placeholder (no per-track art data in widget)
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(colors.accent.opacity(0.2))
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 11, weight: .light))
.foregroundStyle(.white.opacity(0.2))
)
VStack(alignment: .leading, spacing: 1) {
Text(item.title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.lineLimit(1)
Text(item.artist)
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.white.opacity(0.03))
)
.padding(.bottom, 2)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.4), radius: 12, x: 0, y: 6)
} else {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.25))
.foregroundStyle(Color(white: 0.5))
)
}
}
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 50, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
//
// MARK: - PROGRESS BAR (shared component)
//
struct ProgressBarView: View {
let entry: NowPlayingEntry
let height: CGFloat
let showTimes: Bool
@Environment(\.colorScheme) var colorScheme
private var trackColor: Color {
colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.7)
}
private var timeColor: Color {
colorScheme == .dark ? Color(white: 0.6) : Color(white: 0.4)
}
var body: some View {
VStack(spacing: 4) {
// Progress track with segmented tap-to-seek
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track
Capsule()
.fill(trackColor)
.frame(height: height)
// Filled progress
Capsule()
.fill(accentPink)
.frame(width: max(geo.size.width * entry.progress, height), height: height)
// Seek tap zones 10 segments, each seeks to that fraction
HStack(spacing: 0) {
ForEach(0..<10, id: \.self) { i in
let fraction = (Double(i) + 0.5) / 10.0
Button(intent: SeekToIntent(fraction: fraction)) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.frame(height: max(height, 28)) // generous tap target
}
private var crossfadeFooter: some View {
if entry.isPlaying, let cd = entry.crossfadeCountdownFormatted {
HStack(spacing: 4) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
Text("Crossfade \(cd)")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(colors.accent.opacity(0.7))
.lineLimit(1)
}
.frame(height: max(height, 28))
// Time labels
if showTimes {
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 2)
} else if !entry.isPlaying {
HStack(spacing: 4) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
Text("Paused")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(colors.accent.opacity(0.7))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 2)
}
}
}
@ -460,40 +603,21 @@ struct ProgressBarView: View {
// MARK: - Previews
#if DEBUG
#Preview("Small — Dark", as: .systemSmall) {
#Preview("Small", as: .systemSmall) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
NowPlayingEntry.placeholder
}
#Preview("Medium — Dark", as: .systemMedium) {
#Preview("Medium", as: .systemMedium) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
NowPlayingEntry.placeholder
}
#Preview("Large — Dark", as: .systemLarge) {
#Preview("Large", as: .systemLarge) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [
WidgetQueueItem(title: "Blah Blah Blah", artist: "Armin van Buuren"),
WidgetQueueItem(title: "In And Out of Love", artist: "Armin van Buuren"),
],
hasData: true
)
NowPlayingEntry.placeholder
}
#endif

View file

@ -10,6 +10,7 @@ Endpoints (existing - unchanged):
POST /upload-tracks
GET /smart-dj/profile
GET /smart-dj/bulk-profiles
GET /smart-dj/profiles/export
POST /bulk-fix
GET /visualizer/frames
POST /visualizer/precompute
@ -252,6 +253,130 @@ def find_cover_art(song_path: str) -> Optional[str]:
return None
# ── Cover art embedding ─────────────────────────────────────────────────────
def embed_cover_art_in_file(audio_path: str, image_data: bytes, mime: str = "image/jpeg") -> bool:
"""
Embed cover art into a single audio file's metadata tags using mutagen.
Handles FLAC, MP3 (ID3), M4A/AAC (MP4), OGG/Opus (VorbisComment), and AIFF.
Returns True on success, False on failure.
"""
try:
audio = MutagenFile(audio_path)
if audio is None:
print(f" [embed-art] Unsupported format: {audio_path}", flush=True)
return False
ext = os.path.splitext(audio_path)[1].lower()
# ── FLAC ──
from mutagen.flac import FLAC
if isinstance(audio, FLAC):
from mutagen.flac import Picture
# Remove existing pictures
audio.clear_pictures()
pic = Picture()
pic.type = 3 # Cover (front)
pic.mime = mime
pic.desc = "Cover"
pic.data = image_data
audio.add_picture(pic)
audio.save()
print(f" [embed-art] FLAC embedded: {os.path.basename(audio_path)} ({len(image_data)} bytes)", flush=True)
return True
# ── MP3 / AIFF (ID3 tags) ──
from mutagen.id3 import ID3, APIC, ID3NoHeaderError
if hasattr(audio, 'tags') and audio.tags is not None:
# Check if it's an ID3-based format
is_id3 = any(key.startswith('APIC') or key.startswith('TIT2') or key.startswith('TPE1')
for key in audio.tags.keys()) or ext in ('.mp3', '.aiff', '.aif')
if is_id3:
# Remove existing APIC frames
to_remove = [k for k in audio.tags.keys() if k.startswith('APIC')]
for k in to_remove:
del audio.tags[k]
audio.tags.add(APIC(
encoding=3, # UTF-8
mime=mime,
type=3, # Cover (front)
desc='Cover',
data=image_data
))
audio.save()
print(f" [embed-art] ID3 embedded: {os.path.basename(audio_path)} ({len(image_data)} bytes)", flush=True)
return True
# ── M4A / AAC (MP4) ──
try:
from mutagen.mp4 import MP4, MP4Cover
if isinstance(audio, MP4):
fmt = MP4Cover.FORMAT_JPEG if mime == "image/jpeg" else MP4Cover.FORMAT_PNG
audio.tags['covr'] = [MP4Cover(image_data, imageformat=fmt)]
audio.save()
print(f" [embed-art] MP4 embedded: {os.path.basename(audio_path)} ({len(image_data)} bytes)", flush=True)
return True
except ImportError:
pass
# ── OGG / Opus (VorbisComment with METADATA_BLOCK_PICTURE) ──
if hasattr(audio, 'tags') and hasattr(audio.tags, 'get'):
from mutagen.flac import Picture
import base64
if ext in ('.ogg', '.opus', '.oga'):
pic = Picture()
pic.type = 3
pic.mime = mime
pic.desc = "Cover"
pic.data = image_data
encoded = base64.b64encode(pic.write()).decode('ascii')
audio["metadata_block_picture"] = [encoded]
audio.save()
print(f" [embed-art] Vorbis embedded: {os.path.basename(audio_path)} ({len(image_data)} bytes)", flush=True)
return True
print(f" [embed-art] No handler for format: {os.path.basename(audio_path)} (type={type(audio).__name__})", flush=True)
return False
except Exception as e:
print(f" [embed-art] FAILED {os.path.basename(audio_path)}: {e}", flush=True)
return False
AUDIO_EXTENSIONS = {'.flac', '.mp3', '.m4a', '.aac', '.ogg', '.opus', '.oga', '.aiff', '.aif', '.wav', '.wma'}
def embed_cover_art_in_directory(directory: str, image_data: bytes, mime: str = "image/jpeg") -> dict:
"""
Embed cover art into ALL audio files in a directory.
Returns {"succeeded": int, "failed": int, "skipped": int}.
"""
results = {"succeeded": 0, "failed": 0, "skipped": 0}
print(f" [embed-art] Embedding into all audio files in: {directory}", flush=True)
print(f" [embed-art] Image payload: {len(image_data)} bytes, MIME: {mime}", flush=True)
try:
files = sorted(os.listdir(directory))
except OSError as e:
print(f" [embed-art] Cannot list directory: {e}", flush=True)
return results
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full = os.path.join(directory, fname)
if not os.path.isfile(full):
continue
ok = embed_cover_art_in_file(full, image_data, mime)
if ok:
results["succeeded"] += 1
else:
results["failed"] += 1
print(f" [embed-art] Done: {results['succeeded']} embedded, {results['failed']} failed", flush=True)
return results
# ── Tag reader ──────────────────────────────────────────────────────────────
def read_tags(full_path: str) -> dict:
@ -530,12 +655,16 @@ class PushManager:
async def connect(self, ws: WebSocket):
await ws.accept()
self.connections.append(ws)
print(f"Client connected ({len(self.connections)} total)")
# Only log when first client connects (not every reconnect cycle)
if len(self.connections) == 1:
print(f"Push: client connected ({len(self.connections)} total)", flush=True)
def disconnect(self, ws: WebSocket):
if ws in self.connections:
self.connections.remove(ws)
print(f"Client disconnected ({len(self.connections)} total)")
# Only log when all clients disconnected (not every reconnect cycle)
if len(self.connections) == 0:
print("Push: all clients disconnected", flush=True)
async def broadcast(self, event: str, data: dict):
msg = json.dumps({"event": event, "data": data})
@ -1718,12 +1847,17 @@ async def upload_tracks(
if cover_art and album_dir:
cover_dest = os.path.join(album_dir, "cover.jpg")
try:
cover_data = await cover_art.read()
with open(cover_dest, "wb") as buf:
shutil.copyfileobj(cover_art.file, buf)
buf.write(cover_data)
with get_db() as c:
c.execute("UPDATE songs SET cover_art_path = ? WHERE full_path LIKE ?",
(cover_dest, os.path.join(album_dir, "%")))
print(f" Cover art saved: {cover_dest}", flush=True)
# Also embed into each audio file's tags
embed_result = await asyncio.to_thread(
embed_cover_art_in_directory, album_dir, cover_data
)
print(f" Cover art saved: {cover_dest}, embedded in {embed_result['succeeded']} files", flush=True)
except Exception as e:
print(f" Cover art save failed: {e}", flush=True)
@ -1797,6 +1931,40 @@ async def bulk_profiles(paths: str = Query(...)):
return results
@app.get("/smart-dj/profiles/export")
async def export_all_profiles():
"""
Export ALL Smart DJ profiles as a single JSON blob.
iOS client fetches this once on launch to populate the local cache,
eliminating per-song API calls for crossfade/volume data.
URLSession sends Accept-Encoding: gzip by default; FastAPI compresses
the response automatically ~200KB raw ~30KB over the wire.
"""
def _read_all():
with get_db() as c:
rows = c.execute(
"SELECT file_path, bpm, silence_start, silence_end, loudness_lufs "
"FROM dj_profiles"
).fetchall()
profiles = {}
for row in rows:
try:
rel = os.path.relpath(row[0], MUSIC_DIR)
except ValueError:
continue # skip paths on different drives (Windows edge case)
profiles[rel] = {
"bpm": row[1],
"silence_start": row[2],
"silence_end": row[3],
"loudness_lufs": row[4]
}
return profiles
profiles = await asyncio.to_thread(_read_all)
print(f" [profiles/export] Exported {len(profiles)} profiles", flush=True)
return {"count": len(profiles), "profiles": profiles}
@app.get("/visualizer/frames")
async def vis_frames(relative_path: str):
fp = resolve_path(relative_path)
@ -2056,9 +2224,16 @@ async def upload_cover_art(song_id: str, file: UploadFile = File(...)):
cached = os.path.join(COVER_ART_DIR, f"{sid}.jpg")
if os.path.isfile(cached):
os.remove(cached)
print(f" [cover-art] Success — updated {len(sids)} songs, cleared cached art", flush=True)
# Embed the image into every audio file's metadata tags so Navidrome
# picks up the new art via getCoverArt (reads embedded, not cover.jpg)
embed_result = await asyncio.to_thread(
embed_cover_art_in_directory, song_dir, file_data
)
print(f" [cover-art] Success — updated {len(sids)} songs in DB, "
f"embedded in {embed_result['succeeded']} files, cleared cached art", flush=True)
await trigger_scan()
await push.broadcast("cover_art_updated", {"song_id": song_id})
return {"status": "saved", "path": cover_dest}
return {"status": "saved", "path": cover_dest, "embedded": embed_result}
except Exception as e:
print(f" [cover-art] FAILED: {e}", flush=True)
raise HTTPException(500, str(e))
@ -2116,9 +2291,17 @@ async def upload_cover_art_by_path(
os.remove(cached)
cleared += 1
print(f" [cover-art-by-path] Success — {updated} songs updated, {cleared} cached cleared", flush=True)
# Embed the image into every audio file's metadata tags so Navidrome
# picks up the new art via getCoverArt (reads embedded, not cover.jpg)
embed_result = await asyncio.to_thread(
embed_cover_art_in_directory, song_dir, file_data
)
print(f" [cover-art-by-path] Success — {updated} songs in DB, "
f"{embed_result['succeeded']} embedded, {cleared} cached cleared", flush=True)
await trigger_scan()
await push.broadcast("cover_art_updated", {"path": relative_path})
return {"status": "saved", "path": cover_dest, "songs_updated": updated}
return {"status": "saved", "path": cover_dest, "songs_updated": updated, "embedded": embed_result}
except Exception as e:
print(f" [cover-art-by-path] FAILED: {e}", flush=True)
import traceback

View file

@ -160,6 +160,24 @@ struct RootView: View {
// Flush any pending optimistic actions (star/unstar that failed offline)
OptimisticActionQueue.shared.flush()
// Bulk-fetch all Smart DJ profiles in one request (~30KB gzipped).
// Populates SmartDJCache so crossfade/volume data is instant for every song.
if CompanionSettings.shared.smartDJEnabled {
SmartDJCache.shared.loadBulkCache() // disk memory (instant)
Task.detached(priority: .utility) {
do {
let api = try CompanionAPIService()
let profiles = try await api.fetchAllProfiles()
SmartDJCache.shared.bulkImport(profiles)
} catch {
DebugLogger.shared.log(
"DJ profile bulk fetch failed: \(error.localizedDescription)",
category: "SmartDJ"
)
}
}
}
// Restore queue from last session if the app was killed by iOS.
// Only restore if no song is already playing (e.g. from a widget tap).

View file

@ -0,0 +1,348 @@
import Foundation
import UIKit
import ZIPFoundation
//
// BackupManager.swift
// Export and import .nvdbackup files (zip archives) containing all app
// state except server passwords and downloaded audio files.
//
// Export: collects UserDefaults, server configs (passwords stripped),
// DJ profiles, custom covers, pending ops zip share sheet.
// Import: unzips, validates manifest, restores everything, marks
// servers as needing re-authentication.
//
struct BackupManifest: Codable {
let version: Int // backup format version
let appVersion: String
let exportDate: Date
let deviceName: String
let deviceModel: String
static let currentVersion = 1
}
class BackupManager {
static let shared = BackupManager()
private init() {}
enum BackupError: LocalizedError {
case noData
case invalidManifest
case versionMismatch(Int)
case zipCreationFailed
case missingFile(String)
var errorDescription: String? {
switch self {
case .noData: return "No backup data found"
case .invalidManifest: return "Invalid backup file — missing manifest"
case .versionMismatch(let v): return "Unsupported backup version: \(v)"
case .zipCreationFailed: return "Failed to create backup archive"
case .missingFile(let f): return "Missing file in backup: \(f)"
}
}
}
// MARK: - UserDefaults keys to backup
private let settingsKeys: [String] = [
"cache_bitrate", "cache_format",
"cell_bitrate", "cell_format",
"wifi_bitrate", "wifi_format",
"scrobble_enabled",
"debug_console_enabled", "debug_track_view_bodies",
"smart_crossfade_enabled", "smart_crossfade_duration",
"smart_skip_silence", "smart_target_lufs",
]
private let companionKeys: [String] = [
"companion_enabled", "companion_host", "companion_port",
"companion_smart_dj",
]
//
// MARK: - EXPORT
//
/// Build a .nvdbackup file and return its URL for the share sheet.
func exportBackup() throws -> URL {
let fm = FileManager.default
let tempDir = fm.temporaryDirectory.appendingPathComponent("nvdbackup_\(UUID().uuidString)")
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: tempDir) }
// 1. Manifest
let manifest = BackupManifest(
version: BackupManifest.currentVersion,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
exportDate: Date(),
deviceName: UIDevice.current.name,
deviceModel: UIDevice.current.model
)
try writeJSON(manifest, to: tempDir.appendingPathComponent("manifest.json"))
// 2. Server configs (passwords stripped already blanked in UserDefaults)
if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers") {
try serverData.write(to: tempDir.appendingPathComponent("servers.json"))
}
if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server") {
try activeData.write(to: tempDir.appendingPathComponent("active_server.json"))
}
// 3. App settings
var settings: [String: Any] = [:]
for key in settingsKeys {
if let val = UserDefaults.standard.object(forKey: key) {
settings[key] = val
}
}
let settingsData = try JSONSerialization.data(withJSONObject: settings, options: .prettyPrinted)
try settingsData.write(to: tempDir.appendingPathComponent("settings.json"))
// 4. Companion settings
var companion: [String: Any] = [:]
for key in companionKeys {
if let val = UserDefaults.standard.object(forKey: key) {
companion[key] = val
}
}
let companionData = try JSONSerialization.data(withJSONObject: companion, options: .prettyPrinted)
try companionData.write(to: tempDir.appendingPathComponent("companion_settings.json"))
// 5. Visualizer settings
let visSettings = VisualizerSettings.shared
let visDict: [String: Any] = [
"realAudioAnalysis": visSettings.realAudioAnalysis,
"barCount": visSettings.barCount,
"gain": visSettings.gain,
"colorScheme": visSettings.colorScheme,
]
let visData = try JSONSerialization.data(withJSONObject: visDict, options: .prettyPrinted)
try visData.write(to: tempDir.appendingPathComponent("visualizer_settings.json"))
// 6. Playback state
if let state = PlaybackStateStore.shared.exportData() {
try state.write(to: tempDir.appendingPathComponent("playback_state.json"))
}
// 7. Smart DJ profiles (bulk cache)
let djCacheURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
.appendingPathComponent("dj_profiles_bulk.json")
if fm.fileExists(atPath: djCacheURL.path) {
try fm.copyItem(at: djCacheURL, to: tempDir.appendingPathComponent("dj_profiles_bulk.json"))
}
// 8. Offline catalog (metadata only, not actual audio files)
if let catalogData = UserDefaults.standard.data(forKey: "offline_catalog") {
try catalogData.write(to: tempDir.appendingPathComponent("offline_catalog.json"))
}
// 9. Pending operations
if let opsData = PendingOperationsQueue.shared.exportData() {
try opsData.write(to: tempDir.appendingPathComponent("pending_operations.json"))
}
// 10. Custom covers
let coversDir = tempDir.appendingPathComponent("covers")
try fm.createDirectory(at: coversDir, withIntermediateDirectories: true)
let albumCoversDir = coversDir.appendingPathComponent("albums")
try fm.createDirectory(at: albumCoversDir, withIntermediateDirectories: true)
copyCovers(from: albumCoverDirectory(), to: albumCoversDir)
let artistCoversDir = coversDir.appendingPathComponent("artists")
try fm.createDirectory(at: artistCoversDir, withIntermediateDirectories: true)
copyCovers(from: artistCoverDirectory(), to: artistCoversDir)
// Package into zip
let backupURL = fm.temporaryDirectory.appendingPathComponent(
"NavidromeBackup_\(dateString()).nvdbackup"
)
try? fm.removeItem(at: backupURL) // remove stale
try fm.zipItem(at: tempDir, to: backupURL, shouldKeepParent: false)
DebugLogger.shared.log(
"Backup exported: \(backupURL.lastPathComponent) (\(fileSize(backupURL)))",
category: "Backup"
)
return backupURL
}
//
// MARK: - IMPORT
//
/// Import a .nvdbackup file. Returns the manifest for UI display.
/// Servers are restored with passwords cleared user must re-authenticate.
@discardableResult
func importBackup(from url: URL) throws -> BackupManifest {
let fm = FileManager.default
let tempDir = fm.temporaryDirectory.appendingPathComponent("nvdimport_\(UUID().uuidString)")
try fm.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: tempDir) }
// Access security-scoped resource (AirDrop / Files)
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
// Unzip
try fm.unzipItem(at: url, to: tempDir)
// 1. Validate manifest
let manifestURL = tempDir.appendingPathComponent("manifest.json")
guard fm.fileExists(atPath: manifestURL.path) else {
throw BackupError.invalidManifest
}
let manifestData = try Data(contentsOf: manifestURL)
let manifest = try JSONDecoder().decode(BackupManifest.self, from: manifestData)
guard manifest.version <= BackupManifest.currentVersion else {
throw BackupError.versionMismatch(manifest.version)
}
// 2. Restore servers (passwords blank user re-enters)
let serversURL = tempDir.appendingPathComponent("servers.json")
if fm.fileExists(atPath: serversURL.path) {
let data = try Data(contentsOf: serversURL)
UserDefaults.standard.set(data, forKey: "navidrome_servers")
}
let activeURL = tempDir.appendingPathComponent("active_server.json")
if fm.fileExists(atPath: activeURL.path) {
let data = try Data(contentsOf: activeURL)
UserDefaults.standard.set(data, forKey: "navidrome_active_server")
}
// 3. Restore app settings
let settingsURL = tempDir.appendingPathComponent("settings.json")
if fm.fileExists(atPath: settingsURL.path),
let data = try? Data(contentsOf: settingsURL),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for (key, value) in dict {
UserDefaults.standard.set(value, forKey: key)
}
}
// 4. Restore companion settings
let companionURL = tempDir.appendingPathComponent("companion_settings.json")
if fm.fileExists(atPath: companionURL.path),
let data = try? Data(contentsOf: companionURL),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
for (key, value) in dict {
UserDefaults.standard.set(value, forKey: key)
}
}
// 5. Restore visualizer settings
let visURL = tempDir.appendingPathComponent("visualizer_settings.json")
if fm.fileExists(atPath: visURL.path),
let data = try? Data(contentsOf: visURL),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let vis = VisualizerSettings.shared
if let v = dict["realAudioAnalysis"] as? Bool { vis.realAudioAnalysis = v }
if let v = dict["barCount"] as? Int { vis.barCount = v }
if let v = dict["gain"] as? Double { vis.gain = Float(v) }
if let v = dict["colorScheme"] as? String { vis.colorScheme = v }
}
// 6. Restore playback state
let playbackURL = tempDir.appendingPathComponent("playback_state.json")
if fm.fileExists(atPath: playbackURL.path) {
let data = try Data(contentsOf: playbackURL)
PlaybackStateStore.shared.importData(data)
}
// 7. Restore DJ profiles
let djURL = tempDir.appendingPathComponent("dj_profiles_bulk.json")
if fm.fileExists(atPath: djURL.path),
let data = try? Data(contentsOf: djURL),
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data) {
SmartDJCache.shared.bulkImport(profiles)
}
// 8. Restore offline catalog (metadata only)
let catalogURL = tempDir.appendingPathComponent("offline_catalog.json")
if fm.fileExists(atPath: catalogURL.path) {
let data = try Data(contentsOf: catalogURL)
UserDefaults.standard.set(data, forKey: "offline_catalog")
}
// 9. Restore pending operations
let opsURL = tempDir.appendingPathComponent("pending_operations.json")
if fm.fileExists(atPath: opsURL.path) {
let data = try Data(contentsOf: opsURL)
PendingOperationsQueue.shared.importData(data)
}
// 10. Restore custom covers
let albumCoversSource = tempDir.appendingPathComponent("covers/albums")
if fm.fileExists(atPath: albumCoversSource.path) {
copyCovers(from: albumCoversSource, to: albumCoverDirectory())
}
let artistCoversSource = tempDir.appendingPathComponent("covers/artists")
if fm.fileExists(atPath: artistCoversSource.path) {
copyCovers(from: artistCoversSource, to: artistCoverDirectory())
}
DebugLogger.shared.log(
"Backup imported: v\(manifest.version) from \(manifest.deviceName) (\(manifest.exportDate))",
category: "Backup"
)
return manifest
}
//
// MARK: - Helpers
//
private func writeJSON<T: Encodable>(_ value: T, to url: URL) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(value)
try data.write(to: url, options: .atomic)
}
private func albumCoverDirectory() -> URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("AlbumCovers", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func artistCoverDirectory() -> URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("ArtistCovers", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func copyCovers(from source: URL, to dest: URL) {
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) else { return }
for file in files {
let target = dest.appendingPathComponent(file.lastPathComponent)
try? fm.removeItem(at: target)
try? fm.copyItem(at: file, to: target)
}
}
private func dateString() -> String {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
return df.string(from: Date())
}
private func fileSize(_ url: URL) -> String {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int else { return "?" }
if size < 1024 { return "\(size) B" }
if size < 1024 * 1024 { return "\(size / 1024) KB" }
return String(format: "%.1f MB", Double(size) / 1024.0 / 1024.0)
}
}

View file

@ -0,0 +1,227 @@
import Foundation
//
// PendingOperationsQueue.swift
// Persistent queue for companion API operations that failed due to
// network issues. Retries automatically when the WebSocket reconnects.
// Stored as JSON in Documents so it survives app restarts.
//
struct PendingOperation: Codable, Identifiable {
let id: UUID
let type: OperationType
let payload: [String: String]
let createdAt: Date
var retryCount: Int
let maxRetries: Int
enum OperationType: String, Codable {
case metadataEdit // PATCH /batch-edit-metadata
case coverArtUpload // POST /library/cover-art-by-path
case coverArtDelete // DELETE /library/cover-art/{id}
case tagCleanup // POST /tag-cleanup
}
init(type: OperationType, payload: [String: String], maxRetries: Int = 5) {
self.id = UUID()
self.type = type
self.payload = payload
self.createdAt = Date()
self.retryCount = 0
self.maxRetries = maxRetries
}
var isExpired: Bool { retryCount >= maxRetries }
var displayDescription: String {
switch type {
case .metadataEdit:
return "Edit: \(payload["album"] ?? payload["artist"] ?? "metadata")"
case .coverArtUpload:
return "Cover art: \(payload["path"] ?? "upload")"
case .coverArtDelete:
return "Remove cover: \(payload["songId"] ?? "")"
case .tagCleanup:
return "Tag cleanup: \(payload["path"] ?? "")"
}
}
}
class PendingOperationsQueue: ObservableObject {
static let shared = PendingOperationsQueue()
@Published private(set) var operations: [PendingOperation] = []
private let fileURL: URL
private var isProcessing = false
var count: Int { operations.count }
var isEmpty: Bool { operations.isEmpty }
private init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
fileURL = docs.appendingPathComponent("pending_operations.json")
loadFromDisk()
}
// MARK: - Enqueue
func enqueue(_ op: PendingOperation) {
operations.append(op)
saveToDisk()
DebugLogger.shared.log(
"Pending op queued: \(op.type.rawValue) (\(operations.count) total)",
category: "PendingOps"
)
}
/// Convenience: enqueue a metadata edit that failed.
func enqueueMetadataEdit(paths: [String], tags: [String: String]) {
var payload = tags
payload["_paths"] = paths.joined(separator: "\n")
enqueue(PendingOperation(type: .metadataEdit, payload: payload))
}
/// Convenience: enqueue a cover art upload that failed.
func enqueueCoverArtUpload(relativePath: String) {
enqueue(PendingOperation(
type: .coverArtUpload,
payload: ["path": relativePath]
))
}
// MARK: - Process
/// Retry all pending operations. Call when WebSocket reconnects
/// or when the app returns to foreground with a connection.
func processAll() {
guard !isProcessing, !operations.isEmpty else { return }
isProcessing = true
DebugLogger.shared.log(
"Processing \(operations.count) pending operations",
category: "PendingOps"
)
Task {
var remaining: [PendingOperation] = []
for var op in operations {
if op.isExpired {
DebugLogger.shared.log(
"Dropping expired op: \(op.displayDescription) (\(op.retryCount) retries)",
category: "PendingOps"
)
continue
}
let success = await retryOperation(op)
if !success {
op.retryCount += 1
remaining.append(op)
}
}
await MainActor.run {
self.operations = remaining
self.saveToDisk()
self.isProcessing = false
if remaining.isEmpty {
DebugLogger.shared.log("All pending ops completed", category: "PendingOps")
} else {
DebugLogger.shared.log(
"\(remaining.count) ops still pending after retry",
category: "PendingOps"
)
}
}
}
}
private func retryOperation(_ op: PendingOperation) async -> Bool {
guard let api = try? CompanionAPIService() else { return false }
switch op.type {
case .metadataEdit:
let paths = (op.payload["_paths"] ?? "").split(separator: "\n").map(String.init)
guard !paths.isEmpty else { return true } // nothing to do = success
var tags: [String: String] = op.payload
tags.removeValue(forKey: "_paths")
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: tags["title"],
artist: tags["artist"],
album: tags["album"],
albumArtist: tags["album_artist"],
genre: tags["genre"],
year: tags["year"].flatMap { Int($0) }
)
do {
_ = try await api.batchEditMetadata(request)
return true
} catch {
return false
}
case .coverArtUpload:
// Cover art data isn't persisted in the queue (too large).
// Mark as expired user will need to re-apply.
return false
case .coverArtDelete:
guard let songId = op.payload["songId"] else { return true }
do {
try await api.deleteCoverArt(songId: songId)
return true
} catch {
return false
}
case .tagCleanup:
// Tag cleanup is idempotent safe to retry
return false // Not implemented yet
}
}
// MARK: - Remove
func remove(_ op: PendingOperation) {
operations.removeAll { $0.id == op.id }
saveToDisk()
}
func clearAll() {
operations.removeAll()
saveToDisk()
}
// MARK: - Persistence
private func saveToDisk() {
if let data = try? JSONEncoder().encode(operations) {
try? data.write(to: fileURL, options: .atomic)
}
}
func loadFromDisk() {
guard let data = try? Data(contentsOf: fileURL),
let ops = try? JSONDecoder().decode([PendingOperation].self, from: data)
else { return }
operations = ops
}
// MARK: - Export/Import (for backup system)
func exportData() -> Data? {
try? JSONEncoder().encode(operations)
}
func importData(_ data: Data) {
guard let ops = try? JSONDecoder().decode([PendingOperation].self, from: data) else { return }
operations = ops
saveToDisk()
}
}

View file

@ -31,6 +31,8 @@ final class WidgetBridge {
private var cachedBlurCoverArtId: String?
private var cachedBlurData: Data?
private var cachedArtData: Data?
private var cachedAccentHex: String?
private var cachedSecondaryHex: String?
private init() {}
@ -72,15 +74,21 @@ final class WidgetBridge {
duration: TimeInterval,
coverArtId: String?,
coverArtImage: UIImage?,
queue: [(title: String, artist: String)]
queue: [(title: String, artist: String)],
waveformSamples: [Float]? = nil,
crossfadeAt: TimeInterval? = nil
) {
// Only re-blur if the cover art actually changed
// 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)
// Blur on a background queue JPEG encode is ~2ms, blur is ~5ms
let blurred = blurImage(image, radius: 40)
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) }
@ -94,7 +102,11 @@ final class WidgetBridge {
duration: duration,
coverArtJPEG: cachedArtData,
blurredArtJPEG: cachedBlurData,
queueNext: queueItems
queueNext: queueItems,
waveformSamples: waveformSamples,
accentColorHex: cachedAccentHex,
secondaryColorHex: cachedSecondaryHex,
crossfadeAt: crossfadeAt
)
WidgetCenter.shared.reloadAllTimelines()
@ -111,6 +123,8 @@ final class WidgetBridge {
cachedBlurCoverArtId = nil
cachedBlurData = nil
cachedArtData = nil
cachedAccentHex = nil
cachedSecondaryHex = nil
state.clearAll()
WidgetCenter.shared.reloadAllTimelines()
}
@ -170,6 +184,83 @@ final class WidgetBridge {
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.

View file

@ -76,5 +76,45 @@
<string>Choose a cover image for your radio stations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing nearby using Shazam.</string>
<!-- Register .nvdbackup file type so AirDrop opens the app automatically -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>NavidromePlayer Backup</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>ca.dallasgroot.navidromeplayer.backup</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>com.pkware.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>NavidromePlayer Backup</string>
<key>UTTypeIdentifier</key>
<string>ca.dallasgroot.navidromeplayer.backup</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>nvdbackup</string>
</array>
<key>public.mime-type</key>
<string>application/x-nvdbackup</string>
</dict>
</dict>
</array>
</dict>
</plist>

View file

@ -63,11 +63,13 @@ class SmartDJCache {
private let fileManager = FileManager.default
private let cacheDir: URL
private let bulkCacheURL: URL
private var memoryCache: [String: SmartDJProfile] = [:]
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true)
bulkCacheURL = caches.appendingPathComponent("dj_profiles_bulk.json")
try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
@ -95,16 +97,49 @@ class SmartDJCache {
}
}
// MARK: - Bulk Cache (single-file import/export for all profiles)
/// Load the bulk cache file into memory. Call on app launch one disk read,
/// populates memoryCache so every subsequent get() is a zero-I/O memory hit.
func loadBulkCache() {
guard let data = try? Data(contentsOf: bulkCacheURL),
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data)
else { return }
// Merge: individual stores (from cache misses) take priority over bulk
for (path, profile) in profiles where memoryCache[path] == nil {
memoryCache[path] = profile
}
DebugLogger.shared.log(
"DJ bulk cache loaded: \(profiles.count) profiles from disk",
category: "SmartDJ"
)
}
/// Import all profiles from the server export. Updates memory immediately,
/// writes a single bulk JSON file to disk for next launch.
func bulkImport(_ profiles: [String: SmartDJProfile]) {
for (path, profile) in profiles {
memoryCache[path] = profile
}
// Write single bulk file one atomic write, no per-profile I/O
if let data = try? JSONEncoder().encode(profiles) {
try? data.write(to: bulkCacheURL, options: .atomic)
}
DebugLogger.shared.log(
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
category: "SmartDJ"
)
}
func clearAll() {
memoryCache.removeAll()
try? fileManager.removeItem(at: bulkCacheURL)
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil))?.count ?? 0
}
var cachedCount: Int { memoryCache.count }
}
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)
@ -223,6 +258,28 @@ actor CompanionAPIService {
return profile
}
/// Fetch ALL Smart DJ profiles in one request. Returns a dict keyed by relative path.
/// The server compresses with gzip automatically (~200KB ~30KB).
/// Call once on app launch to populate SmartDJCache.bulkImport().
func fetchAllProfiles() async throws -> [String: SmartDJProfile] {
let base = try baseURL()
let url = base.appendingPathComponent("smart-dj/profiles/export")
let (data, response) = try await session.data(from: url)
try validateResponse(response)
struct ExportResponse: Codable {
let count: Int
let profiles: [String: SmartDJProfile]
}
let export = try JSONDecoder().decode(ExportResponse.self, from: data)
DebugLogger.shared.log(
"Fetched \(export.count) DJ profiles (\(data.count) bytes)",
category: "SmartDJ"
)
return export.profiles
}
/// Prefetch profiles for a batch of songs (e.g. queue, album)
func prefetchProfiles(for songs: [Song]) async {
await withTaskGroup(of: Void.self) { group in
@ -463,17 +520,26 @@ class CompanionPushClient: ObservableObject {
guard CompanionSettings.shared.isEnabled,
CompanionSettings.shared.baseURL != nil else { return }
let wasReconnecting = reconnectAttempts > 0
disconnect()
let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")!
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
if wasReconnecting {
DebugLogger.shared.log(
"Push: reconnected after \(reconnectAttempts) attempts",
category: "Companion"
)
}
// Reset backoff on fresh connect
reconnectDelay = 5
reconnectAttempts = 0
DebugLogger.shared.log("Push: connecting to \(wsURL)", category: "Companion")
listen()
startPing()
}
@ -586,7 +652,13 @@ class CompanionPushClient: ObservableObject {
let delay = reconnectDelay
// Exponential backoff: 5 10 20 40 80 120 120...
reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay)
DebugLogger.shared.log("Push: reconnect #\(reconnectAttempts) in \(Int(delay))s", category: "Companion")
// Silent retries only log milestones to avoid console spam
if reconnectAttempts == 1 || reconnectAttempts == 5 || reconnectAttempts % 20 == 0 {
DebugLogger.shared.log(
"Push: reconnecting (attempt \(reconnectAttempts), \(Int(delay))s backoff)",
category: "Companion"
)
}
reconnectTask = Task {
try? await Task.sleep(for: .seconds(delay))
await MainActor.run { self.connect() }

View file

@ -702,6 +702,9 @@ struct SettingsView: View {
}
} header: { Text("Storage") }
// Backup & Restore
BackupRestoreSection()
// About
Section {
Toggle("Debug Console", isOn: $debugLogger.isEnabled)

View file

@ -0,0 +1,208 @@
import SwiftUI
import ConfettiSwiftUI
//
// BackupRestoreView.swift
// Settings section: Export Backup (share sheet), Import Backup (file picker),
// pending operations count, and confetti on successful import.
//
struct BackupRestoreSection: View {
@ObservedObject private var pendingOps = PendingOperationsQueue.shared
@State private var showExportSheet = false
@State private var showImportPicker = false
@State private var showImportSuccess = false
@State private var showError = false
@State private var errorMessage = ""
@State private var importedManifest: BackupManifest?
@State private var exportURL: URL?
@State private var confettiTrigger = 0
@State private var isExporting = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
Section("Backup & Restore") {
// Export
Button {
exportBackup()
} label: {
HStack {
Label("Export Backup", systemImage: "square.and.arrow.up")
Spacer()
if isExporting {
ProgressView()
.scaleEffect(0.8)
}
}
}
.disabled(isExporting)
// Import
Button {
showImportPicker = true
} label: {
Label("Import Backup", systemImage: "square.and.arrow.down")
}
// Pending operations
if !pendingOps.isEmpty {
NavigationLink {
PendingOperationsListView()
} label: {
HStack {
Label("Pending Operations", systemImage: "clock.arrow.circlepath")
Spacer()
Text("\(pendingOps.count)")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(accentPink.cornerRadius(10))
}
}
}
}
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.fileImporter(
isPresented: $showImportPicker,
allowedContentTypes: [.init(filenameExtension: "nvdbackup")!],
allowsMultipleSelection: false
) { result in
handleImport(result)
}
.alert("Import Successful", isPresented: $showImportSuccess) {
Button("OK") {
confettiTrigger += 1
}
} message: {
if let m = importedManifest {
Text("Restored from \(m.deviceName) (\(m.deviceModel))\n\(formattedDate(m.exportDate))\n\nPlease re-enter your server password.")
}
}
.alert("Backup Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage)
}
.confettiCannon(trigger: $confettiTrigger, num: 50, radius: 400)
}
private func exportBackup() {
isExporting = true
Task {
do {
let url = try BackupManager.shared.exportBackup()
await MainActor.run {
exportURL = url
isExporting = false
showExportSheet = true
}
} catch {
await MainActor.run {
isExporting = false
errorMessage = error.localizedDescription
showError = true
}
}
}
}
private func handleImport(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
do {
let manifest = try BackupManager.shared.importBackup(from: url)
importedManifest = manifest
showImportSuccess = true
} catch {
errorMessage = error.localizedDescription
showError = true
}
case .failure(let error):
errorMessage = error.localizedDescription
showError = true
}
}
private func formattedDate(_ date: Date) -> String {
let df = DateFormatter()
df.dateStyle = .medium
df.timeStyle = .short
return df.string(from: date)
}
}
// MARK: - Share Sheet (UIKit wrapper)
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
// MARK: - Pending Operations List
struct PendingOperationsListView: View {
@ObservedObject private var queue = PendingOperationsQueue.shared
var body: some View {
List {
if queue.isEmpty {
Text("No pending operations")
.foregroundStyle(.secondary)
} else {
ForEach(queue.operations) { op in
VStack(alignment: .leading, spacing: 4) {
Text(op.displayDescription)
.font(.system(size: 14, weight: .medium))
HStack {
Text(op.type.rawValue)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer()
Text("Retry \(op.retryCount)/\(op.maxRetries)")
.font(.system(size: 11, weight: .medium).monospacedDigit())
.foregroundStyle(op.retryCount >= 3 ? .red : .secondary)
}
Text(op.createdAt, style: .relative)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
}
.onDelete { offsets in
for offset in offsets {
queue.remove(queue.operations[offset])
}
}
Section {
Button("Retry All Now") {
queue.processAll()
}
.disabled(queue.isEmpty)
Button("Clear All", role: .destructive) {
queue.clearAll()
}
.disabled(queue.isEmpty)
}
}
}
.navigationTitle("Pending Operations")
}
}

View file

@ -12,6 +12,9 @@ packages:
ZIPFoundation:
url: https://github.com/weichsel/ZIPFoundation
from: "0.9.19"
ConfettiSwiftUI:
url: https://github.com/simibac/ConfettiSwiftUI
from: "3.0.0"
settings:
base:
@ -49,6 +52,7 @@ targets:
- target: NavidromeWidget
embed: true
- package: ZIPFoundation
- package: ConfettiSwiftUI
# ─────────────────────────────────────
# watchOS App