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 visCacheText = "..." @State private var isAnalyzing = false @State private var analysisProgress = "" 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) } } } } // 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 Section("Visualizer") { Button(action: { showVisualizerSettings = true }) { HStack { Text("Visualizer Settings") .foregroundColor(.white) Spacer() Text(VisualizerSettings.shared.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() } } Button(action: { Task { await VisualizerStorageManager.shared.clearCache() await MainActor.run { visCacheText = "Cleared" } } }) { HStack { Text("Clear Visualizer Cache") .foregroundColor(.white) Spacer() Text(visCacheText) .foregroundColor(.gray) } } // Pre-analyze missing songs Button(action: { analyzeAllMissing(force: false) }) { HStack { Text("Pre-Analyze Missing Songs") .foregroundColor(isAnalyzing ? .gray : .white) Spacer() if isAnalyzing { HStack(spacing: 6) { ProgressView().tint(accentPink) Text(analysisProgress) .font(.system(size: 12)) .foregroundColor(.gray) } } else { Image(systemName: "waveform.badge.magnifyingglass") .foregroundColor(accentPink) } } } .disabled(isAnalyzing) // Force re-analyze all songs Button(action: { analyzeAllMissing(force: true) }) { HStack { Text("Re-Analyze All Songs") .foregroundColor(isAnalyzing ? .gray : .orange) Spacer() Image(systemName: "arrow.clockwise") .foregroundColor(isAnalyzing ? .gray : .orange) } } .disabled(isAnalyzing) } header: { Text("Storage") } footer: { Text("Pre-Analyze scans downloaded songs without visualizer data. Re-Analyze clears all caches and rebuilds from scratch.") } // 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() Task { 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 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) } private func analyzeAllMissing(force: Bool) { guard !isAnalyzing else { return } isAnalyzing = true analysisProgress = "Scanning..." let downloaded = OfflineManager.shared.downloadedSongs let points = VisualizerSettings.shared.numberOfPoints let fps = VisualizerSettings.shared.effectiveFPS let cutoff = VisualizerSettings.shared.frequencyCutoff Task { let storage = VisualizerStorageManager.shared if force { await storage.clearCache() } // Find songs that need analysis var toAnalyze: [(id: String, url: URL)] = [] for song in downloaded { if let url = OfflineManager.shared.localURL(for: song.id) { let hasCache = await storage.hasCache(for: song.id) if !hasCache || force { toAnalyze.append((id: song.id, url: url)) } } } if toAnalyze.isEmpty { await MainActor.run { analysisProgress = "All songs cached" isAnalyzing = false refreshVisCacheText() } return } await MainActor.run { analysisProgress = "0/\(toAnalyze.count)" } DebugLogger.shared.log("Starting batch analysis: \(toAnalyze.count) songs (force: \(force))", category: "FFT") for (idx, item) in toAnalyze.enumerated() { await MainActor.run { analysisProgress = "\(idx + 1)/\(toAnalyze.count)" } do { let frames = try await OfflineAudioAnalyzer.shared.analyze( url: item.url, pointsCount: points, fps: fps, cutoff: cutoff ) try? await storage.saveCache(frames: frames, for: item.id) DebugLogger.shared.log("Analyzed: \(item.id) → \(frames.count) frames", category: "FFT") } catch { DebugLogger.shared.log("Failed: \(item.id) → \(error.localizedDescription)", category: "Error") } } await MainActor.run { analysisProgress = "Done (\(toAnalyze.count) songs)" isAnalyzing = false refreshVisCacheText() } DebugLogger.shared.log("Batch analysis complete: \(toAnalyze.count) songs", category: "FFT") } } private func refreshVisCacheText() { Task { let size = await VisualizerStorageManager.shared.cacheSize() let count = await VisualizerStorageManager.shared.cachedTrackCount() await MainActor.run { visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))" } } } } // 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) } } }