Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)
WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands
CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)
COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan
COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)
SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first
WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected
Files: 13 modified, 8 new (including companion-api/main.py)
This commit is contained in:
parent
aa97be4caa
commit
b9844b23cd
15 changed files with 1871 additions and 446 deletions
|
|
@ -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..<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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
348
iOS/Data/BackupManager.swift
Normal file
348
iOS/Data/BackupManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
227
iOS/Data/PendingOperationsQueue.swift
Normal file
227
iOS/Data/PendingOperationsQueue.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -702,6 +702,9 @@ struct SettingsView: View {
|
|||
}
|
||||
} header: { Text("Storage") }
|
||||
|
||||
// Backup & Restore
|
||||
BackupRestoreSection()
|
||||
|
||||
// About
|
||||
Section {
|
||||
Toggle("Debug Console", isOn: $debugLogger.isEnabled)
|
||||
|
|
|
|||
208
iOS/Views/Settings/BackupRestoreView.swift
Normal file
208
iOS/Views/Settings/BackupRestoreView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue