From b9844b23cd93a3442e05788422b80c3d58a6c230 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 19:24:22 -0700 Subject: [PATCH] Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Shared/Audio/AudioPlayer.swift | 67 +- Shared/Storage/PlaybackStateStore.swift | 31 + Shared/Storage/WidgetSharedState.swift | 37 +- Widget/NowPlayingWidget.swift | 78 +- Widget/NowPlayingWidgetViews.swift | 874 ++++++++++-------- companion-api/main.py | 199 +++- iOS/App/NavidromePlayerApp.swift | 18 + iOS/Data/BackupManager.swift | 348 +++++++ iOS/Data/PendingOperationsQueue.swift | 227 +++++ iOS/Data/WidgetBridge.swift | 101 +- iOS/Resources/Info.plist | 40 + iOS/Views/Companion/CompanionAPIService.swift | 82 +- iOS/Views/Library/DownloadsSettingsView.swift | 3 + iOS/Views/Settings/BackupRestoreView.swift | 208 +++++ project.yml | 4 + 15 files changed, 1871 insertions(+), 446 deletions(-) create mode 100644 iOS/Data/BackupManager.swift create mode 100644 iOS/Data/PendingOperationsQueue.swift create mode 100644 iOS/Views/Settings/BackupRestoreView.swift diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 018e49c..89e19af 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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.0–1.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.. 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) + } + } } diff --git a/Shared/Storage/WidgetSharedState.swift b/Shared/Storage/WidgetSharedState.swift index b60eb1b..2b53994 100644 --- a/Shared/Storage/WidgetSharedState.swift +++ b/Shared/Storage/WidgetSharedState.swift @@ -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.0–1.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.0–1.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) } } diff --git a/Widget/NowPlayingWidget.swift b/Widget/NowPlayingWidget.swift index 91a9985..d071676 100644 --- a/Widget/NowPlayingWidget.swift +++ b/Widget/NowPlayingWidget.swift @@ -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.0–1.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 ) } diff --git a/Widget/NowPlayingWidgetViews.swift b/Widget/NowPlayingWidgetViews.swift index 9af03ad..c8ca4ff 100644 --- a/Widget/NowPlayingWidgetViews.swift +++ b/Widget/NowPlayingWidgetViews.swift @@ -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: 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.. 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(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(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 diff --git a/companion-api/main.py b/companion-api/main.py index 1a47a75..a5c123f 100644 --- a/companion-api/main.py +++ b/companion-api/main.py @@ -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 diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index ccf6d2e..52b7a33 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -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). diff --git a/iOS/Data/BackupManager.swift b/iOS/Data/BackupManager.swift new file mode 100644 index 0000000..4188230 --- /dev/null +++ b/iOS/Data/BackupManager.swift @@ -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(_ 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) + } +} diff --git a/iOS/Data/PendingOperationsQueue.swift b/iOS/Data/PendingOperationsQueue.swift new file mode 100644 index 0000000..10c1172 --- /dev/null +++ b/iOS/Data/PendingOperationsQueue.swift @@ -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() + } +} diff --git a/iOS/Data/WidgetBridge.swift b/iOS/Data/WidgetBridge.swift index 4f5a517..7fcc253 100644 --- a/iOS/Data/WidgetBridge.swift +++ b/iOS/Data/WidgetBridge.swift @@ -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. diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index a98f577..ca38d74 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -76,5 +76,45 @@ Choose a cover image for your radio stations. NSMicrophoneUsageDescription Identify songs playing nearby using Shazam. + + + CFBundleDocumentTypes + + + CFBundleTypeName + NavidromePlayer Backup + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + ca.dallasgroot.navidromeplayer.backup + + + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + com.pkware.zip-archive + + UTTypeDescription + NavidromePlayer Backup + UTTypeIdentifier + ca.dallasgroot.navidromeplayer.backup + UTTypeTagSpecification + + public.filename-extension + + nvdbackup + + public.mime-type + application/x-nvdbackup + + + diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index 21af4ca..e9ff113 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -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() } diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index 650f047..4553569 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -702,6 +702,9 @@ struct SettingsView: View { } } header: { Text("Storage") } + // Backup & Restore + BackupRestoreSection() + // About Section { Toggle("Debug Console", isOn: $debugLogger.isEnabled) diff --git a/iOS/Views/Settings/BackupRestoreView.swift b/iOS/Views/Settings/BackupRestoreView.swift new file mode 100644 index 0000000..1e8b886 --- /dev/null +++ b/iOS/Views/Settings/BackupRestoreView.swift @@ -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") + } +} diff --git a/project.yml b/project.yml index 7e6356c..2438875 100644 --- a/project.yml +++ b/project.yml @@ -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