Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
378 lines
16 KiB
Swift
378 lines
16 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|