NavidromeApp/iOS/Views/Companion/CompanionSettingsView.swift
Dallas Groot fdd3a098a8 changes
TrackEditorView — added Disc # field, album artist now auto-populates
from the Companion DB on sheet open (fetches /library/song/{id}), live
path preview shows exactly where the file will end up as you type,
success message now says “File moved to new path”.
CompanionSettingsView — “Fix Library Structure” button added under
Server Actions in orange with the warning “Only run once all tags are
correct”. It won’t do damage right now since touching it just
restructures based on whatever tags exist, but the warning makes it
clear it’s a final-step tool.
2026-04-10 13:00:45 -07:00

268 lines
12 KiB
Swift

import SwiftUI
struct CompanionSettingsView: View {
@ObservedObject private var settings = CompanionSettings.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?
@State private var fixLibraryStatus: AnalyzeStatus = .idle
@State private var fixLibraryMessage: 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 available with or without Companion API
Section {
} header: { Text("Smart DJ") } footer: {
Text("Smart DJ and Crossfade settings have moved to Settings → Smart DJ & Visualizer.")
}
if settings.isEnabled {
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.")
}
Section {
Button(action: triggerScan) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath").foregroundColor(accentPink)
Text("Trigger Navidrome Scan").foregroundColor(.white)
}
}
Button(action: triggerFixLibrary) {
HStack {
Image(systemName: "folder.badge.gearshape")
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .orange)
.symbolEffect(.pulse, isActive: fixLibraryStatus == .running)
VStack(alignment: .leading, spacing: 2) {
Text("Fix Library Structure").foregroundColor(.white)
Text("Only run once all tags are correct — moves every file to match its current tags.")
.font(.caption).foregroundColor(.orange.opacity(0.8))
}
}
}
.disabled(fixLibraryStatus == .running)
if let msg = fixLibraryMessage {
Text(msg)
.font(.caption)
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .gray)
}
} header: { Text("Server Actions") }
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-Compute Missing Visualizer Frames").foregroundColor(.white)
Text("Generate Mitsuha FFT frames on the server for un-cached songs")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
Button(action: { triggerPreAnalyze(force: true) }) {
HStack {
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Re-Compute All Visualizer Frames").foregroundColor(.white)
Text("Force regenerate server-side frames for every track")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
if let msg = analyzeMessage {
Text(msg).font(.caption)
.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 few minutes.")
}
}
}
.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: - Fix Library Structure
private func triggerFixLibrary() {
fixLibraryStatus = .running
fixLibraryMessage = "Restructuring library on server..."
Task {
do {
try await CompanionAPIService().triggerBulkFix()
await MainActor.run {
fixLibraryStatus = .done
fixLibraryMessage = "Library restructure started — check server logs for progress."
}
DebugLogger.shared.log("Fix library triggered", category: "Companion")
} catch {
await MainActor.run {
fixLibraryStatus = .failed
fixLibraryMessage = "Failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Server Visualizer Precompute
private func triggerPreAnalyze(force: Bool) {
analyzeStatus = .analyzing
analyzeMessage = force ? "Requesting full re-compute on server..." : "Requesting precompute 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 }
}
}
}
}