import SwiftUI import Network // MARK: - Downloads View struct DownloadsView: View { @EnvironmentObject var offlineManager: OfflineManager @EnvironmentObject var audioPlayer: AudioPlayer @ObservedObject private var watchManager = WatchConnectivityManager.shared @State private var selectedTab: DownloadTab = .offline enum DownloadTab: String, CaseIterable { case offline = "Offline" case watch = "Watch" } private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { NavigationStack { VStack(spacing: 0) { // Tab picker Picker("", selection: $selectedTab) { ForEach(DownloadTab.allCases, id: \.self) { tab in Text(tab.rawValue).tag(tab) } } .pickerStyle(.segmented) .padding(.horizontal, 16) .padding(.vertical, 10) switch selectedTab { case .offline: offlineTab case .watch: watchTab } } .background(Color(white: 0.06)) .navigationTitle("Downloads") } } // MARK: - Offline Tab private var offlineTab: some View { List { // Storage info Section { HStack { Image(systemName: "internaldrive") .foregroundColor(accentPink) Text("Storage Used") Spacer() Text(offlineManager.formattedSize) .foregroundColor(.gray) } HStack { Image(systemName: "music.note") .foregroundColor(accentPink) Text("Downloaded Songs") Spacer() Text("\(offlineManager.downloadedSongs.count)") .foregroundColor(.gray) } } // Downloaded songs Section("Offline Songs") { if offlineManager.downloadedSongs.isEmpty { VStack(spacing: 12) { Image(systemName: "arrow.down.circle") .font(.system(size: 36)) .foregroundColor(.gray) Text("No downloads yet") .font(.system(size: 14)) .foregroundColor(.gray) Text("Download songs from albums or playlists to listen offline") .font(.system(size: 12)) .foregroundColor(.gray.opacity(0.7)) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 20) } else { ForEach(offlineManager.downloadedSongs) { downloaded in Button(action: { audioPlayer.play( song: downloaded.song, fromQueue: offlineManager.downloadedSongs.map { $0.song } ) }) { HStack(spacing: 12) { AsyncCoverArt( coverArtId: downloaded.song.coverArt, size: 44 ) .frame(width: 44, height: 44) .cornerRadius(3) VStack(alignment: .leading, spacing: 2) { Text(downloaded.song.title) .font(.system(size: 15)) .foregroundColor( audioPlayer.currentSong?.id == downloaded.id ? accentPink : .white ) .lineLimit(1) Text(downloaded.song.artist ?? "") .font(.system(size: 12)) .foregroundColor(.gray) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(downloaded.song.durationFormatted) .font(.system(size: 12)) .foregroundColor(.gray) Text(ByteCountFormatter.string(fromByteCount: downloaded.fileSize, countStyle: .file)) .font(.system(size: 10)) .foregroundColor(.gray.opacity(0.7)) } } } } .onDelete { offsets in for idx in offsets { offlineManager.removeSong(offlineManager.downloadedSongs[idx].id) } } } } // Remove all if !offlineManager.downloadedSongs.isEmpty { Section { Button("Remove All Downloads", role: .destructive) { offlineManager.removeAll() } } } } } // MARK: - Watch Tab private var watchTab: some View { List { if !watchManager.isWatchPaired { Section { VStack(spacing: 12) { Image(systemName: "applewatch.slash") .font(.system(size: 36)) .foregroundColor(.gray) Text("No Apple Watch Paired") .font(.system(size: 15, weight: .medium)) .foregroundColor(.white) Text("Pair an Apple Watch and install the app to send music") .font(.system(size: 12)) .foregroundColor(.gray) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 20) } } else { // Watch status Section { HStack(spacing: 10) { Image(systemName: watchManager.isReachable ? "applewatch.radiowaves.left.and.right" : "applewatch") .foregroundColor(watchManager.isReachable ? .green : .gray) .font(.system(size: 18)) VStack(alignment: .leading, spacing: 2) { Text(watchManager.isReachable ? "Watch App Active" : "Watch App Not Running") .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) Text(watchManager.isReachable ? "Files will transfer immediately" : "Open the app on your watch to receive files") .font(.system(size: 11)) .foregroundColor(.gray) } Spacer() if watchManager.isReachable { Button(action: { watchManager.requestWatchSongList() }) { Image(systemName: "arrow.clockwise") .font(.system(size: 13)) .foregroundColor(accentPink) } } } } header: { Text("Status") } // Pending transfers if !watchManager.transferringIds.isEmpty { Section { ForEach(watchManager.pendingTransferList, id: \.id) { item in HStack(spacing: 12) { ZStack { Circle() .stroke(Color.white.opacity(0.1), lineWidth: 3) Circle() .trim(from: 0, to: item.progress) .stroke(accentPink, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) Text("\(Int(item.progress * 100))") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(.gray) } .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.system(size: 14)) .foregroundColor(.white) .lineLimit(1) GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.1)).frame(height: 3) Capsule().fill(accentPink) .frame(width: geo.size.width * item.progress, height: 3) } } .frame(height: 3) } Spacer() } } .onDelete { indexSet in let list = watchManager.pendingTransferList for idx in indexSet { if idx < list.count { watchManager.cancelTransfer(songId: list[idx].id) } } } if watchManager.transferringIds.count > 1 { Button(role: .destructive, action: { watchManager.cancelAllTransfers() }) { HStack { Image(systemName: "xmark.circle") Text("Cancel All (\(watchManager.transferringIds.count))") } .font(.system(size: 13)) } } } header: { Text("Pending Transfers (\(watchManager.transferringIds.count))") } } // Completed if !watchManager.transferredIds.isEmpty { Section { HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) Text("\(watchManager.transferredIds.count) songs sent") .font(.system(size: 14)) .foregroundColor(.white) Spacer() Button("Clear") { watchManager.transferredIds.removeAll() } .font(.system(size: 13)) .foregroundColor(accentPink) } } header: { Text("Recently Sent") } } // Songs on watch Section { if watchManager.isLoadingWatchSongs { HStack { ProgressView().tint(accentPink) Text("Loading watch library...") .font(.system(size: 13)) .foregroundColor(.gray) .padding(.leading, 8) } } else if watchManager.watchSongs.isEmpty { VStack(spacing: 8) { Image(systemName: "applewatch") .font(.system(size: 24)) .foregroundColor(.gray) Text(watchManager.isReachable ? "No songs on Watch" : "Connect watch to view songs") .font(.system(size: 13)) .foregroundColor(.gray) } .frame(maxWidth: .infinity) .padding(.vertical, 12) } else { HStack { Text("\(watchManager.watchSongs.count) songs") .font(.system(size: 13, weight: .medium)) .foregroundColor(.white) Spacer() let totalSize = watchManager.watchSongs.reduce(0) { $0 + $1.fileSize } Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)) .font(.system(size: 13)) .foregroundColor(.gray) } ForEach(watchManager.watchSongs) { song in HStack(spacing: 12) { Image(systemName: "music.note") .font(.system(size: 12)) .foregroundColor(accentPink) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(song.title) .font(.system(size: 14)) .foregroundColor(.white) .lineLimit(1) Text("\(song.artist) · \(song.album)") .font(.system(size: 11)) .foregroundColor(.gray) .lineLimit(1) } Spacer() Text(ByteCountFormatter.string(fromByteCount: song.fileSize, countStyle: .file)) .font(.system(size: 11)) .foregroundColor(.gray) } } .onDelete { indexSet in for idx in indexSet { if idx < watchManager.watchSongs.count { watchManager.requestWatchDeleteSong(watchManager.watchSongs[idx].id) } } } Button(role: .destructive, action: { watchManager.requestWatchDeleteAll() }) { HStack { Image(systemName: "trash") Text("Remove All from Watch") } .font(.system(size: 13)) } } } header: { Text("On Watch") } // Send all offline songs to watch if !offlineManager.downloadedSongs.isEmpty { Section { Button(action: { watchManager.syncOfflineSongsToWatch(songs: offlineManager.downloadedSongs) }) { Label("Send All Offline Songs to Watch", systemImage: "applewatch.and.arrow.forward") } } } } } .onAppear { if watchManager.isReachable && watchManager.watchSongs.isEmpty { watchManager.requestWatchSongList() } } .onChange(of: watchManager.isReachable) { _, reachable in if reachable { watchManager.requestWatchSongList() } } } } // MARK: - Streaming Quality Manager (shared) class StreamingQuality: ObservableObject { static let shared = StreamingQuality() // WiFi @Published var wifiFormat: String { didSet { UserDefaults.standard.set(wifiFormat, forKey: "wifi_format") } } @Published var wifiBitRate: String { didSet { UserDefaults.standard.set(wifiBitRate, forKey: "wifi_bitrate") } } // Cellular @Published var cellularFormat: String { didSet { UserDefaults.standard.set(cellularFormat, forKey: "cell_format") } } @Published var cellularBitRate: String { didSet { UserDefaults.standard.set(cellularBitRate, forKey: "cell_bitrate") } } // Cache / Downloads @Published var cacheFormat: String { didSet { UserDefaults.standard.set(cacheFormat, forKey: "cache_format") } } @Published var cacheBitRate: String { didSet { UserDefaults.standard.set(cacheBitRate, forKey: "cache_bitrate") } } // Scrobbling @Published var scrobbleEnabled: Bool { didSet { UserDefaults.standard.set(scrobbleEnabled, forKey: "scrobble_enabled") } } /// Formats supported by Navidrome's stream endpoint static let formatOptions: [(value: String, label: String)] = [ ("raw", "Raw/Original"), ("mp3", "MP3"), ("opus", "Opus"), ("aac", "AAC"), ("ogg", "OGG Vorbis"), ] /// Bitrate options for transcoded formats static let bitrateOptions: [(value: String, label: String)] = [ ("0", "No Limit (default)"), ("320", "320 kbps"), ("256", "256 kbps"), ("192", "192 kbps"), ("128", "128 kbps"), ("96", "96 kbps"), ("64", "64 kbps"), ] /// Active format based on current connection var format: String? { let fmt = isOnCellular ? cellularFormat : wifiFormat return fmt == "raw" ? nil : fmt // nil = no format param = original } /// Active bitrate based on current connection var maxBitRate: Int? { let br = isOnCellular ? cellularBitRate : wifiBitRate if br == "0" { return nil } return Int(br) } /// Format for downloads/cache var downloadFormat: String? { return cacheFormat == "raw" ? nil : cacheFormat } var downloadBitRate: Int? { if cacheBitRate == "0" { return nil } return Int(cacheBitRate) } /// Summary string for current WiFi config var wifiSummary: String { if wifiFormat == "raw" { return "Raw/Original" } let br = wifiBitRate == "0" ? "No limit" : "\(wifiBitRate) kbps" return "\(wifiFormat.uppercased()) · \(br)" } /// Summary string for current Cellular config var cellularSummary: String { if cellularFormat == "raw" { return "Raw/Original" } let br = cellularBitRate == "0" ? "No limit" : "\(cellularBitRate) kbps" return "\(cellularFormat.uppercased()) · \(br)" } /// Summary string for cache config var cacheSummary: String { if cacheFormat == "raw" { return "Raw/Original" } let br = cacheBitRate == "0" ? "No limit" : "\(cacheBitRate) kbps" return "\(cacheFormat.uppercased()) · \(br)" } // Legacy compat var streamBitRate: String { get { isOnCellular ? cellularBitRate : wifiBitRate } set { if isOnCellular { cellularBitRate = newValue } else { wifiBitRate = newValue } } } var streamFormat: String { get { isOnCellular ? cellularFormat : wifiFormat } set { if isOnCellular { cellularFormat = newValue } else { wifiFormat = newValue } } } var downloadOriginal: Bool { get { cacheFormat == "raw" } set { cacheFormat = newValue ? "raw" : "mp3" } } private var isOnCellular: Bool { return !isOnWiFi } private var isOnWiFi: Bool { var result = true let monitor = NWPathMonitor(requiredInterfaceType: .wifi) let semaphore = DispatchSemaphore(value: 0) monitor.pathUpdateHandler = { path in result = (path.status == .satisfied) semaphore.signal() } let queue = DispatchQueue(label: "wifi.check") monitor.start(queue: queue) _ = semaphore.wait(timeout: .now() + 0.1) monitor.cancel() return result } private init() { let d = UserDefaults.standard wifiFormat = d.string(forKey: "wifi_format") ?? "raw" wifiBitRate = d.string(forKey: "wifi_bitrate") ?? "0" cellularFormat = d.string(forKey: "cell_format") ?? "mp3" cellularBitRate = d.string(forKey: "cell_bitrate") ?? "320" cacheFormat = d.string(forKey: "cache_format") ?? "raw" cacheBitRate = d.string(forKey: "cache_bitrate") ?? "0" scrobbleEnabled = d.object(forKey: "scrobble_enabled") as? Bool ?? true } } // MARK: - Settings View struct SettingsView: View { @EnvironmentObject var serverManager: ServerManager @ObservedObject var quality = StreamingQuality.shared @ObservedObject var debugLogger = DebugLogger.shared @State private var showVisualizerSettings = false @State private var cacheSizeText = "..." @State private var conflictErrorCount = 0 @State private var conflictTotalCount = 0 private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) let streamOptions = StreamingQuality.formatOptions let bitrateOptions = StreamingQuality.bitrateOptions var body: some View { NavigationStack { List { // Active Server Section("Server") { if let server = serverManager.activeServer { VStack(alignment: .leading, spacing: 4) { Text(server.name) .font(.system(size: 16, weight: .medium)) Text(server.url) .font(.system(size: 13)) .foregroundColor(.gray) Text("Logged in as \(server.username)") .font(.system(size: 12)) .foregroundColor(.gray) } } NavigationLink("Manage Servers") { ManageServersView() } NavigationLink { CompanionSettingsView() } label: { HStack { Text("Companion API") Spacer() if CompanionSettings.shared.isEnabled { Image(systemName: "checkmark.circle.fill") .font(.system(size: 12)) .foregroundColor(.green) } } } if CompanionSettings.shared.isEnabled { NavigationLink { LibraryConflictsView() } label: { HStack { Text("Issues & Conflicts") Spacer() if conflictErrorCount > 0 { Text("\(conflictErrorCount)") .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) .padding(.horizontal, 7) .padding(.vertical, 2) .background(Color(red: 1, green: 0.176, blue: 0.333)) .clipShape(Capsule()) } else if conflictTotalCount > 0 { Text("\(conflictTotalCount)") .font(.system(size: 12)) .foregroundColor(.gray) } } } } } // WiFi Streaming Section { Picker("Format (Transcoding)", selection: $quality.wifiFormat) { ForEach(streamOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } if quality.wifiFormat != "raw" { Picker("Bitrate Limit", selection: $quality.wifiBitRate) { ForEach(bitrateOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } } } header: { Text("WiFi Streaming") } footer: { if quality.wifiFormat == "raw" { Text("Streams your files as-is (FLAC, ALAC, etc). Uses the Subsonic 'download' action — no transcoding.") } else { Text("Select a transcoding format for WiFi streaming. Uses the Subsonic 'stream' action with server-side transcoding.") } } // Cellular Streaming Section { Picker("Format (Transcoding)", selection: $quality.cellularFormat) { ForEach(streamOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } if quality.cellularFormat != "raw" { Picker("Bitrate Limit", selection: $quality.cellularBitRate) { ForEach(bitrateOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } } } header: { Text("Cellular Streaming") } footer: { Text("Transcoding is recommended on cellular to reduce data usage. Default: MP3 320 kbps.") } // Cache / Downloads Section { Picker("Format (Transcoding)", selection: $quality.cacheFormat) { ForEach(streamOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } if quality.cacheFormat != "raw" { Picker("Bitrate Limit", selection: $quality.cacheBitRate) { ForEach(bitrateOptions, id: \.value) { opt in Text(opt.label).tag(opt.value) } } } } header: { Text("Cache Format (Downloads)") } footer: { Text("For 'Raw/Original', uses the Subsonic 'download' action which skips transcoding. Other formats use 'stream' with server transcoding. Changes won't apply to already downloaded songs; clear cache and redownload if needed.") } // Watch Section { HStack { Text("Watch Transfer Quality") Spacer() Text("MP3 192 kbps") .foregroundColor(.gray) } } header: { Text("Apple Watch") } footer: { Text("Songs sent to Apple Watch are always transcoded to MP3 192 kbps for fast transfer and storage efficiency.") } // Scrobbling Section("Activity") { Toggle("Scrobble Plays", isOn: $quality.scrobbleEnabled) .tint(accentPink) } // Visualizer & Smart DJ Section("Audio & Visualizer") { NavigationLink { SmartDJVisualizerSettingsView() } label: { Text("Crossfade & Visualizer Analyzer") } Button(action: { showVisualizerSettings = true }) { HStack { Text("Visualizer Appearance") .foregroundColor(.white) Spacer() Text(VisualizerSettings.shared.nowPlaying.style.rawValue) .foregroundColor(.gray) Image(systemName: "chevron.right") .font(.system(size: 12)) .foregroundColor(.gray) } } } // Storage Section { Button(action: { ImageCache.shared.clearAll() cacheSizeText = "0 MB" }) { HStack { Text("Clear Image Cache").foregroundColor(.white) Spacer() Text(cacheSizeText).foregroundColor(.gray) } } Button(action: { LibraryCache.shared.clearAll() }) { HStack { Text("Clear Library Cache").foregroundColor(.white) Spacer() } } } header: { Text("Storage") } // About Section { Toggle("Debug Console", isOn: $debugLogger.isEnabled) .tint(accentPink) } header: { Text("Developer") } footer: { Text("Shows a debug panel above the tab bar with real-time logs for Watch transfers, audio engine, network, and more. Drag to resize.") } Section("About") { HStack { Text("Version") Spacer() Text("1.0.0").foregroundColor(.gray) } HStack { Text("API Version") Spacer() Text("1.16.1").foregroundColor(.gray) } } // Logout Section { Button("Disconnect", role: .destructive) { serverManager.disconnect() } } } .navigationTitle("Settings") .sheet(isPresented: $showVisualizerSettings) { VisualizerSettingsView() } .onAppear { cacheSizeText = computeCacheSize() conflictErrorCount = ConflictManager.shared.badgeCount conflictTotalCount = ConflictManager.shared.totalCount } .onReceive(NotificationCenter.default.publisher(for: .companionConflictsUpdated)) { _ in conflictErrorCount = ConflictManager.shared.badgeCount conflictTotalCount = ConflictManager.shared.totalCount } } } private func computeCacheSize() -> String { let fm = FileManager.default let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask).first! let cacheDir = caches.appendingPathComponent("ImageCache", isDirectory: true) guard let files = try? fm.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else { return "0 MB" } var total: Int64 = 0 for file in files { if let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize { total += Int64(size) } } return ByteCountFormatter.string(fromByteCount: total, countStyle: .file) } } // MARK: - Smart DJ & Visualizer Settings struct SmartDJVisualizerSettingsView: View { @ObservedObject private var crossfade = SmartCrossfadeManager.shared @ObservedObject private var compSettings = CompanionSettings.shared @State private var visCacheText = "—" @State private var smartDJCacheCount = 0 @State private var isAnalyzing = false @State private var analysisProgress = "" private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { List { // Smart DJ Section { Toggle("Smart DJ", isOn: $compSettings.smartDJEnabled).tint(accentPink) if compSettings.smartDJEnabled { Toggle("Smart Crossfade", isOn: $crossfade.isEnabled).tint(accentPink) if crossfade.isEnabled { HStack { Text("Duration").foregroundColor(.gray) Spacer() Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s").foregroundColor(.white) } Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 0.5).tint(accentPink) HStack { Text("Target LUFS").foregroundColor(.gray) Spacer() Text("\(String(format: "%.0f", crossfade.targetLUFS)) LUFS").foregroundColor(.white) } Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1).tint(accentPink) Toggle("Skip Silence", isOn: $crossfade.skipSilence).tint(accentPink) } } } header: { Text("Smart DJ") } footer: { Text("Smart DJ uses on-device analysis for downloaded songs (silence detection + loudness). When the Companion API is enabled, server-side BPM data is also used.") } // Combined Analysis Section { Button(action: { runAnalysis(force: false) }) { HStack { Image(systemName: isAnalyzing ? "waveform" : "waveform.badge.magnifyingglass") .foregroundColor(accentPink) .symbolEffect(.pulse, isActive: isAnalyzing) VStack(alignment: .leading, spacing: 2) { Text("Pre-Analyze Missing Songs").foregroundColor(.white) Text("Vis frames + Smart DJ profile for un-analyzed songs") .font(.caption).foregroundColor(.gray) } Spacer() if isAnalyzing { Text(analysisProgress).font(.caption).foregroundColor(.gray) } } } .disabled(isAnalyzing) Button(action: { runAnalysis(force: true) }) { HStack { Image(systemName: "arrow.clockwise").foregroundColor(.orange) VStack(alignment: .leading, spacing: 2) { Text("Re-Analyze All Songs").foregroundColor(.white) Text("Clear all caches and rebuild from scratch") .font(.caption).foregroundColor(.gray) } } } .disabled(isAnalyzing) } header: { Text("Analysis (On-Device)") } footer: { Text("Each song is read once to produce both its Mitsuha visualizer frames and its Smart DJ profile (silence boundaries + loudness). Results are cached on-device.") } // Cache management Section { HStack { Text("Visualizer Cache").foregroundColor(.gray) Spacer() Text(visCacheText).foregroundColor(.white).font(.caption) } Button(action: { Task { await VisualizerStorageManager.shared.clearCache() await refreshVisCacheText() } }) { Text("Clear Visualizer Cache").foregroundColor(.red) } HStack { Text("Smart DJ Profiles Cached").foregroundColor(.gray) Spacer() Text("\(smartDJCacheCount)").foregroundColor(.white).font(.caption) } Button(action: { SmartDJCache.shared.clearAll() smartDJCacheCount = 0 }) { Text("Clear Smart DJ Cache").foregroundColor(.red) } } header: { Text("Cache") } } .navigationTitle("Crossfade & Visualizer Analyzer") .navigationBarTitleDisplayMode(.inline) .onAppear { smartDJCacheCount = SmartDJCache.shared.cachedCount Task { await refreshVisCacheText() } } } private func refreshVisCacheText() async { let size = await VisualizerStorageManager.shared.cacheSize() let count = await VisualizerStorageManager.shared.cachedTrackCount() await MainActor.run { visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))" } } private func runAnalysis(force: Bool) { guard !isAnalyzing else { return } isAnalyzing = true analysisProgress = "Scanning..." let downloaded = OfflineManager.shared.downloadedSongs let points = VisualizerSettings.shared.nowPlaying.numberOfPoints let fps = VisualizerSettings.shared.effectiveFPS let cutoff = VisualizerSettings.shared.frequencyCutoff Task { let storage = VisualizerStorageManager.shared if force { await storage.clearCache() SmartDJCache.shared.clearAll() } var toAnalyze: [(songId: String, path: String?, url: URL)] = [] for dl in downloaded { if let url = OfflineManager.shared.localURL(for: dl.id) { let hasVis = await storage.hasCache(for: dl.id) let hasProfile = dl.song.path.map { SmartDJCache.shared.get($0) != nil } ?? false if !hasVis || !hasProfile || force { toAnalyze.append((songId: dl.id, path: dl.song.path, url: url)) } } } if toAnalyze.isEmpty { await MainActor.run { analysisProgress = "All up to date"; isAnalyzing = false } try? await Task.sleep(for: .seconds(2)) await MainActor.run { analysisProgress = "" } return } for (idx, item) in toAnalyze.enumerated() { await MainActor.run { analysisProgress = "\(idx + 1)/\(toAnalyze.count)" } do { let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ( url: item.url, pointsCount: points, fps: fps, cutoff: cutoff, extractSmartDJ: true) try? await storage.saveCache(frames: result.visFrames, for: item.songId) if let path = item.path { SmartDJCache.shared.store(SmartDJProfile( bpm: nil, silenceStart: result.silenceStart, silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS ), for: path) } } catch { DebugLogger.shared.log("Analysis failed: \(item.songId) — \(error.localizedDescription)", category: "FFT") } } await MainActor.run { analysisProgress = "Done — \(toAnalyze.count) songs" isAnalyzing = false smartDJCacheCount = SmartDJCache.shared.cachedCount } Task { await refreshVisCacheText() } } } } // MARK: - Manage Servers View struct ManageServersView: View { @EnvironmentObject var serverManager: ServerManager @State private var showAddServer = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { List { ForEach(serverManager.servers) { server in HStack { VStack(alignment: .leading, spacing: 3) { Text(server.name) .font(.system(size: 16, weight: .medium)) Text(server.url) .font(.system(size: 12)) .foregroundColor(.gray) Text(server.username) .font(.system(size: 11)) .foregroundColor(.gray.opacity(0.7)) } Spacer() if serverManager.activeServer?.id == server.id { Image(systemName: "checkmark.circle.fill") .foregroundColor(accentPink) } } .contentShape(Rectangle()) .onTapGesture { Task { _ = await serverManager.switchServer(server) } } } .onDelete { offsets in serverManager.removeServer(at: offsets) } Button(action: { showAddServer = true }) { HStack { Image(systemName: "plus.circle.fill") .foregroundColor(accentPink) Text("Add Server") .foregroundColor(accentPink) } } } .navigationTitle("Servers") .sheet(isPresented: $showAddServer) { AddServerSheet(isPresented: $showAddServer) } } } // ============================================================================= // MARK: - Library Conflicts UI // Appended here so no new file is needed — avoids Xcode project registration issues. // ============================================================================= extension LibraryConflict { var severityColor: Color { severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow } var severityIcon: String { severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" } var typeIcon: String { switch type { case "duplicate_album": return "rectangle.on.rectangle.fill" case "missing_files": return "doc.badge.questionmark" case "picard_legacy_tags": return "tag.slash.fill" case "orphaned_tracks": return "link.badge.plus" case "duplicate_track": return "music.note.list" case "stale_companion_paths": return "externaldrive.badge.xmark" default: return "exclamationmark.circle" } } } struct LibraryConflictsView: View { @StateObject private var manager = ConflictManager.shared @State private var selectedTab = 0 // 0 = Active, 1 = Ignored @AppStorage("ignoredConflictIDs") private var ignoredIDsRaw = "" private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private var ignoredIDs: Set { Set(ignoredIDsRaw.split(separator: "|||").map(String.init)) } private func ignore(_ conflict: LibraryConflict) { var ids = ignoredIDs ids.insert(conflict.id) ignoredIDsRaw = ids.joined(separator: "|||") } private func restore(_ conflict: LibraryConflict) { var ids = ignoredIDs ids.remove(conflict.id) ignoredIDsRaw = ids.joined(separator: "|||") } private var activeIssues: [LibraryConflict] { guard let all = manager.conflicts?.issues else { return [] } return all .filter { !ignoredIDs.contains($0.id) } .sorted { ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) } } private var ignoredIssues: [LibraryConflict] { guard let all = manager.conflicts?.issues else { return [] } return all.filter { ignoredIDs.contains($0.id) } } var body: some View { List { // Tab picker Section { Picker("", selection: $selectedTab) { Text("Active (\(activeIssues.count))").tag(0) Text("Ignored (\(ignoredIssues.count))").tag(1) } .pickerStyle(.segmented) .padding(.vertical, 4) } if manager.isLoading { Section { HStack { ProgressView().tint(accentPink) Text("Scanning library for issues…") .font(.system(size: 14)) .foregroundColor(.gray) .padding(.leading, 8) } .padding(.vertical, 4) } } else if let error = manager.lastError { Section { Text(error) .font(.system(size: 13)) .foregroundColor(.red) } } else if selectedTab == 0 { // ── Active tab ────────────────────────────────────────────── if activeIssues.isEmpty { Section { HStack(spacing: 12) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 28)) .foregroundColor(.green) VStack(alignment: .leading, spacing: 2) { Text("No active issues") .font(.system(size: 15, weight: .medium)) Text("Your library looks clean.") .font(.system(size: 13)) .foregroundColor(.gray) } } .padding(.vertical, 6) } } else { let errors = activeIssues.filter { $0.severity == "error" }.count let warnings = activeIssues.filter { $0.severity == "warning" }.count Section { HStack(spacing: 16) { summaryBadge(count: errors, label: "Errors", color: accentPink) summaryBadge(count: warnings, label: "Warnings", color: .yellow) Spacer() } .padding(.vertical, 4) } header: { Text("Summary") } ForEach(activeIssues) { conflict in conflictRow(conflict) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { withAnimation { ignore(conflict) } } label: { Label("Ignore", systemImage: "eye.slash") } .tint(.gray) } } } } else { // ── Ignored tab ───────────────────────────────────────────── if ignoredIssues.isEmpty { Section { Text("No ignored issues.") .font(.system(size: 14)) .foregroundColor(.gray) } } else { Section { Text("Swipe left to restore an issue to the Active list.") .font(.system(size: 12)) .foregroundColor(.gray) } ForEach(ignoredIssues) { conflict in conflictRow(conflict, dimmed: true) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { withAnimation { restore(conflict) } } label: { Label("Restore", systemImage: "arrow.uturn.left") } .tint(accentPink) } } } } } .navigationTitle("Issues & Conflicts") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { Task { await manager.refresh() } }) { if manager.isLoading { ProgressView().tint(accentPink).scaleEffect(0.8) } else { Image(systemName: "arrow.clockwise").foregroundColor(accentPink) } } .disabled(manager.isLoading) } } .task { if manager.conflicts == nil { await manager.refresh() } } } // MARK: - Conflict Row @ViewBuilder private func conflictRow(_ conflict: LibraryConflict, dimmed: Bool = false) -> some View { Section { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: conflict.typeIcon) .font(.system(size: 16)) .foregroundColor(dimmed ? .gray : conflict.severityColor) Text(conflict.title) .font(.system(size: 14, weight: .semibold)) .foregroundColor(dimmed ? .gray : .white) Spacer() Image(systemName: conflict.severityIcon) .font(.system(size: 12)) .foregroundColor(dimmed ? .gray : conflict.severityColor) } Text(conflict.detail) .font(.system(size: 12)) .foregroundColor(.gray) .fixedSize(horizontal: false, vertical: true) if !conflict.affected_paths.isEmpty { VStack(alignment: .leading, spacing: 2) { ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in Text(path) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.white.opacity(0.45)) .lineLimit(1) } if conflict.affected_paths.count > 3 { Text("+ \(conflict.affected_paths.count - 3) more") .font(.system(size: 10)) .foregroundColor(.gray.opacity(0.6)) } } .padding(6) .background(Color.white.opacity(0.05)) .cornerRadius(6) } if !dimmed, let action = conflict.fix_action { HStack { Spacer() Button(action: { Task { await manager.fix(conflict) } }) { if manager.isFixing == conflict.id { HStack(spacing: 6) { ProgressView().tint(.white).scaleEffect(0.75) Text("Fixing…").font(.system(size: 13)) } .padding(.horizontal, 16).padding(.vertical, 7) .background(Color.gray.opacity(0.3)) .cornerRadius(8) } else { Text(fixButtonLabel(action)) .font(.system(size: 13, weight: .medium)) .padding(.horizontal, 16).padding(.vertical, 7) .background(conflict.severityColor.opacity(0.25)) .foregroundColor(conflict.severityColor) .cornerRadius(8) } } .buttonStyle(.plain) .disabled(manager.isFixing != nil) } } } .padding(.vertical, 4) } } // MARK: - Helpers private func summaryBadge(count: Int, label: String, color: Color) -> some View { VStack(spacing: 2) { Text("\(count)") .font(.system(size: 24, weight: .bold)) .foregroundColor(count > 0 ? color : .gray) Text(label) .font(.system(size: 11)) .foregroundColor(.gray) } } private func fixButtonLabel(_ action: String) -> String { switch action { case "fix_duplicate_album": return "Merge Duplicate" case "fix_missing_files": return "Remove from DB" case "fix_picard_tags": return "Fix Tags" case "fix_stale_paths": return "Rescan Library" case "fix_orphaned_tracks": return "Trigger Rescan" default: return "Fix" } } }