NavidromeApp/iOS/Views/Settings/BackupRestoreView.swift
Dallas Groot b9844b23cd Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache
PERFORMANCE AUDIT
- Removed 16 dead SubsonicClient methods (~117 lines)
- Added NSCache memory tier to LibraryCache, AlbumCoverStore,
ArtistCoverStore, RadioCoverStore
- Replaced weak polynomial hash with FNV-1a 64-bit in ImageCache
- Split PlaybackStateStore into save() (full queue) and savePosition()
(time only)
- Reused single SubsonicClient in OfflineManager instead of
per-download allocation
- Added periodic ImageCache disk trim every 50 writes
- Changed AudioPreFetcher to fuzzy offline match
(isSongAvailableOffline)
- Removed dead code: hasCompanionLibrary, downloadedSongIds, isActive,
CachedImageLoader.task
- Fixed thread safety: inline JSONEncoder/JSONDecoder in LibraryCache
(no shared instances)

WIDGET EXTENSION (new target: NavidromeWidget)
- v2 glassmorphism design: blurred album art background + frosted
glass panel
- Waveform scrubber: 40-bar Canvas with tap-to-seek (20 segments via
SeekToIntent)
- Color-adaptive theming: CIAreaAverage dominant color extraction with
HSB contrast adjustment
- Transport controls: previous/play-pause/next with interactive
AppIntents
- Up Next footer with crossfade countdown from Smart DJ profiles
- Large widget: 3-item queue list with numbered rows
- Small/Medium/Large sizes matching design mockups
- App Group communication via WidgetSharedState (UserDefaults)
- Darwin notification observer for widget→app commands
- Foreground command pickup for suspended app recovery
- Idempotency guards on all widget commands

CROSSFADE & PLAYBACK FIXES
- Fixed dual audio on single-song queue: guard nextSong.id ==
currentSong?.id in prepareNextForCrossfade
- Fixed crossfade play path never calling pushWidgetState (returned
before reaching it)
- Fixed crossfade needsNextTrack callback missing queue persistence +
widget push
- Fixed toggleShuffle queue not persisted after PlaybackStateStore
split
- Added nowPlayingSyncTimer restart on foreground (Lock Screen seek
bar drift)
- Added AVPlayer currentTime/duration sync in resumeVisTimers before
vis timer restart
(fixes waveform distortion after background — confirmed by Apple
Forums + SoundCloud engineering)

COVER ART PIPELINE
- Fixed pushWidgetState cover art size mismatch (300→600 to match
fetchAndSetArtwork)
- Added custom cover art key differentiation ("custom_" prefix forces
re-blur)
- Changed server art lookup from memoryOnlyImage to cachedImage
(memory+disk fallback)
- Added POST /library/cover-art-by-path endpoint (was missing — iOS
fallback hit 404)
- Added navidrome_id fallback on existing cover art endpoint
- Added embed_cover_art_in_file/embed_cover_art_in_directory: mutagen
writes cover art
directly into FLAC/MP3/M4A/OGG metadata tags so Navidrome serves
updated art
- All three upload paths (by-id, by-path, upload-tracks) now embed +
trigger_scan

COMPANION API FIXES
- Fixed _create_task recursion (was calling itself instead of
asyncio.create_task)
- Fixed navidrome_db NameError on /library/conflicts endpoint
- Reduced WebSocket connect/disconnect logging (only first-client and
all-disconnected)

SMART DJ PROFILE PREFETCH
- New endpoint: GET /smart-dj/profiles/export (bulk JSON, gzip
automatic)
- SmartDJCache.loadBulkCache() reads single file on launch (instant)
- SmartDJCache.bulkImport() writes all profiles in one atomic file
- CompanionAPIService.fetchAllProfiles() fetches entire profile set in
one request
- Wired into NavidromePlayerApp.task after server connect
- SmartCrossfadeManager unchanged — already reads from SmartDJCache
first

WEBSOCKET NOISE REDUCTION
- iOS: silent reconnect retries, only log milestones (#1, #5, every
20th)
- iOS: log "reconnected after N attempts" on success, silent initial
connect
- Python: only log first client connect and all-clients-disconnected

Files: 13 modified, 8 new (including companion-api/main.py)
2026-04-12 19:24:22 -07:00

208 lines
7.5 KiB
Swift

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")
}
}