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.
268 lines
12 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|