import SwiftUI struct CompanionSettingsView: View { @ObservedObject private var settings = CompanionSettings.shared @ObservedObject private var crossfade = SmartCrossfadeManager.shared @State private var testStatus: TestStatus = .idle @State private var hostInput: String = "" @State private var portInput: String = "" @State private var analyzeStatus: AnalyzeStatus = .idle @State private var analyzeMessage: String? private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) enum TestStatus: Equatable { case idle, testing, success, failed(String) } enum AnalyzeStatus { case idle, analyzing, done, failed } init() { _hostInput = State(initialValue: CompanionSettings.shared.host) _portInput = State(initialValue: "\(CompanionSettings.shared.port)") } var body: some View { List { // Connection Section { Toggle("Enable Companion API", isOn: $settings.isEnabled) .tint(accentPink) if settings.isEnabled { HStack { Text("Host") .foregroundColor(.gray) .frame(width: 50, alignment: .leading) TextField("192.168.1.100", text: $hostInput) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.numbersAndPunctuation) .multilineTextAlignment(.trailing) .onChange(of: hostInput) { _, val in settings.host = val } } HStack { Text("Port") .foregroundColor(.gray) .frame(width: 50, alignment: .leading) TextField("8000", text: $portInput) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) .onChange(of: portInput) { _, val in settings.port = Int(val) ?? 8000 } } Button(action: testConnection) { HStack { switch testStatus { case .idle: Image(systemName: "bolt.horizontal") .foregroundColor(accentPink) Text("Test Connection") .foregroundColor(accentPink) case .testing: ProgressView() .scaleEffect(0.8) Text("Testing...") .foregroundColor(.gray) case .success: Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) Text("Connected") .foregroundColor(.green) case .failed(let msg): Image(systemName: "xmark.circle.fill") .foregroundColor(.red) Text(msg) .foregroundColor(.red) .lineLimit(2) } } .font(.system(size: 14)) } .disabled(testStatus == .testing) } } header: { Text("Companion API") } footer: { Text("The Companion API provides advanced features: file uploads, ID3 tag editing, and Smart DJ audio analysis.") } // Smart DJ if settings.isEnabled { Section { Toggle("Smart DJ", isOn: $settings.smartDJEnabled) .tint(accentPink) if settings.smartDJEnabled { Toggle("Smart Crossfade", isOn: $crossfade.isEnabled) .tint(accentPink) if crossfade.isEnabled { HStack { Text("Crossfade") .foregroundColor(.gray) Spacer() Text("\(String(format: "%.0f", crossfade.crossfadeDuration))s") .foregroundColor(.white) .frame(width: 30) Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 1) .tint(accentPink) .frame(width: 140) } HStack { Text("Target LUFS") .foregroundColor(.gray) Spacer() Text("\(String(format: "%.0f", crossfade.targetLUFS))") .foregroundColor(.white) .frame(width: 30) Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1) .tint(accentPink) .frame(width: 140) } Toggle("Skip Silence", isOn: $crossfade.skipSilence) .tint(accentPink) } } } header: { Text("Smart DJ") } footer: { Text("Smart DJ analyzes tracks for BPM, silence boundaries, and loudness. Crossfade uses this data for seamless transitions and volume normalization.") } // Upload Section { NavigationLink(destination: BatchUploadView()) { HStack { Image(systemName: "icloud.and.arrow.up") .foregroundColor(accentPink) Text("Upload Music") } } } header: { Text("Upload") } footer: { Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.") } // Server Actions Section { Button(action: triggerScan) { HStack { Image(systemName: "arrow.triangle.2.circlepath") .foregroundColor(accentPink) Text("Trigger Navidrome Scan") .foregroundColor(.white) } } HStack { Text("Smart DJ Profiles Cached") .foregroundColor(.gray) Spacer() Text("\(SmartDJCache.shared.cachedCount)") .foregroundColor(.white) } .font(.system(size: 14)) Button(role: .destructive, action: { SmartDJCache.shared.clearAll() }) { Text("Clear Local DJ Cache") } } header: { Text("Server Actions") } // Analysis (runs on the Pi) Section { Button(action: { triggerPreAnalyze(force: false) }) { HStack { Image(systemName: analyzeStatus == .analyzing ? "waveform" : "waveform.badge.magnifyingglass") .foregroundColor(accentPink) .symbolEffect(.pulse, isActive: analyzeStatus == .analyzing) VStack(alignment: .leading, spacing: 2) { Text("Pre-Analyze Missing Songs") .foregroundColor(.white) Text("Analyze only songs without a Smart DJ profile") .font(.system(size: 11)) .foregroundColor(.gray) } } } .disabled(analyzeStatus == .analyzing) Button(action: { triggerPreAnalyze(force: true) }) { HStack { Image(systemName: "arrow.clockwise") .foregroundColor(.orange) VStack(alignment: .leading, spacing: 2) { Text("Re-Analyze All Songs") .foregroundColor(.white) Text("Force re-analyze every track (slow on large libraries)") .font(.system(size: 11)) .foregroundColor(.gray) } } } .disabled(analyzeStatus == .analyzing) Button(action: triggerVisPrecompute) { HStack { Image(systemName: "waveform.path") .foregroundColor(accentPink) VStack(alignment: .leading, spacing: 2) { Text("Pre-Compute Visualizer Frames") .foregroundColor(.white) Text("Generate Mitsuha FFT frames on the server") .font(.system(size: 11)) .foregroundColor(.gray) } } } .disabled(analyzeStatus == .analyzing) if let msg = analyzeMessage { Text(msg) .font(.system(size: 12)) .foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray) } } header: { Text("Server Analysis (runs on Pi)") } footer: { Text("These commands run on your Companion API server. Large libraries may take a while.") } } } .navigationTitle("Companion API") .navigationBarTitleDisplayMode(.inline) } // MARK: - Trigger Server Scan private func triggerScan() { Task { do { try await CompanionAPIService().triggerBulkFix() DebugLogger.shared.log("Triggered Navidrome scan", category: "Companion") } catch { DebugLogger.shared.log("Scan failed: \(error.localizedDescription)", category: "Companion") } } } // MARK: - Pre-Analyze private func triggerPreAnalyze(force: Bool) { analyzeStatus = .analyzing analyzeMessage = force ? "Re-analyzing all songs on server..." : "Analyzing missing songs on server..." Task { do { // Trigger server-side precompute via the visualizer/precompute endpoint if force { // For force re-analyze, we hit the precompute endpoint // which processes all un-cached files guard let base = CompanionSettings.shared.baseURL else { return } var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute")) req.httpMethod = "POST" req.timeoutInterval = 10 let (data, _) = try await URLSession.shared.data(for: req) let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started" await MainActor.run { analyzeStatus = .done analyzeMessage = "✓ \(msg)" } } else { // Pre-analyze missing: same endpoint, server skips already-analyzed guard let base = CompanionSettings.shared.baseURL else { return } var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute")) req.httpMethod = "POST" req.timeoutInterval = 10 let (data, _) = try await URLSession.shared.data(for: req) let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started" await MainActor.run { analyzeStatus = .done analyzeMessage = "✓ \(msg)" } } } catch { await MainActor.run { analyzeStatus = .failed analyzeMessage = "✗ \(error.localizedDescription)" } } // Reset after 5s try? await Task.sleep(for: .seconds(5)) await MainActor.run { if analyzeStatus != .analyzing { analyzeStatus = .idle analyzeMessage = nil } } } } private func triggerVisPrecompute() { analyzeStatus = .analyzing analyzeMessage = "Generating visualizer frames on server..." Task { do { guard let base = CompanionSettings.shared.baseURL else { return } var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute")) req.httpMethod = "POST" req.timeoutInterval = 10 let (data, _) = try await URLSession.shared.data(for: req) let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started" await MainActor.run { analyzeStatus = .done analyzeMessage = "✓ \(msg)" } } catch { await MainActor.run { analyzeStatus = .failed analyzeMessage = "✗ \(error.localizedDescription)" } } try? await Task.sleep(for: .seconds(5)) await MainActor.run { if analyzeStatus != .analyzing { analyzeStatus = .idle analyzeMessage = nil } } } } // MARK: - Test Connection private func testConnection() { testStatus = .testing Task { do { let api = CompanionAPIService() let ok = try await api.healthCheck() await MainActor.run { testStatus = ok ? .success : .failed("Unhealthy") } } catch let error as URLError { await MainActor.run { testStatus = .failed(error.code == .timedOut ? "Timeout" : error.localizedDescription) } } catch { await MainActor.run { testStatus = .failed(error.localizedDescription) } } try? await Task.sleep(for: .seconds(3)) await MainActor.run { if case .success = testStatus { testStatus = .idle } if case .failed = testStatus { testStatus = .idle } } } } }