improvong the settings tab
This commit is contained in:
parent
8667f623ef
commit
989412fda5
5 changed files with 209 additions and 339 deletions
|
|
@ -750,8 +750,17 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
#if os(iOS)
|
||||
if isUsingOfflineVis {
|
||||
startOfflineVisSync()
|
||||
} else if isUsingRealFFT, !isUsingOfflineVis {
|
||||
// Engine (real FFT) path — restart the raw FFT publish timer.
|
||||
// stopLevelTimer() on pause killed this; startLevelSimulation() would
|
||||
// push random simulation data instead of the real FFT tap output.
|
||||
stopLevelTimer()
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.setLevels(self.rawFFTLevels)
|
||||
}
|
||||
} else {
|
||||
// Restart level simulation for streaming / radio / crossfade paths
|
||||
// AVPlayer / streaming / radio — use level simulation
|
||||
startLevelSimulation()
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1311,6 +1320,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
private func startLevelSimulation() {
|
||||
stopLevelTimer()
|
||||
// Force a fresh target generation on the first tick after resume
|
||||
// by invalidating the cached phase. Otherwise currentTime hasn't
|
||||
// advanced yet and the wave smooths to a stale constant value.
|
||||
lastTargetPhase = -1
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self, self.isPlaying else {
|
||||
if self?.internalLevels.contains(where: { $0 > 0.01 }) == true {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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 = ""
|
||||
|
|
@ -97,44 +96,8 @@ struct CompanionSettingsView: View {
|
|||
|
||||
// Smart DJ — available with or without Companion API
|
||||
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(settings.isEnabled
|
||||
? "Uses server-side BPM, silence, and loudness analysis for seamless crossfades."
|
||||
: "On-device analysis (no Companion API needed): silence detection and loudness for downloaded songs. BPM requires the API.")
|
||||
Text("Smart DJ and Crossfade settings have moved to Settings → Smart DJ & Visualizer.")
|
||||
}
|
||||
|
||||
if settings.isEnabled {
|
||||
|
|
@ -148,7 +111,7 @@ struct CompanionSettingsView: View {
|
|||
} 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 {
|
||||
|
|
@ -156,18 +119,8 @@ struct CompanionSettingsView: View {
|
|||
Text("Trigger Navidrome Scan").foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(SmartDJCache.shared.cachedCount)").foregroundColor(.white)
|
||||
}
|
||||
Button(action: { SmartDJCache.shared.clearAll() }) {
|
||||
Text("Clear Profile Cache").foregroundColor(.red)
|
||||
}
|
||||
} header: { Text("Server Actions") }
|
||||
}
|
||||
|
||||
if settings.smartDJEnabled {
|
||||
|
||||
Section {
|
||||
Button(action: { triggerPreAnalyze(force: false) }) {
|
||||
HStack {
|
||||
|
|
@ -175,53 +128,32 @@ struct CompanionSettingsView: View {
|
|||
.foregroundColor(accentPink)
|
||||
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Analyze Missing Songs").foregroundColor(.white)
|
||||
Text(settings.isEnabled
|
||||
? "Server + on-device for songs without a profile"
|
||||
: "On-device analysis for downloaded songs only")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
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-Analyze All Songs").foregroundColor(.white)
|
||||
Text("Clear cache and re-analyze every downloaded track")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
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 settings.isEnabled {
|
||||
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))
|
||||
Text(msg).font(.caption)
|
||||
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
|
||||
}
|
||||
} header: {
|
||||
Text(settings.isEnabled ? "Analysis (Server + On-Device)" : "Analysis (On-Device)")
|
||||
} footer: {
|
||||
Text(settings.isEnabled
|
||||
? "Server analysis runs on the Pi. On-device analysis handles downloaded songs. Both feed the same Smart DJ cache."
|
||||
: "Scans downloaded songs for silence boundaries and approximate loudness. Cached locally for crossfade use.")
|
||||
} header: { Text("Server Analysis (Runs on Pi)") } footer: {
|
||||
Text("These commands run on your Companion API server. Large libraries may take a few minutes.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +162,7 @@ struct CompanionSettingsView: View {
|
|||
}
|
||||
|
||||
// MARK: - Trigger Server Scan
|
||||
|
||||
|
||||
private func triggerScan() {
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -241,81 +173,12 @@ struct CompanionSettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pre-Analyze (server + on-device combined)
|
||||
|
||||
|
||||
// MARK: - Server Visualizer Precompute
|
||||
|
||||
private func triggerPreAnalyze(force: Bool) {
|
||||
analyzeStatus = .analyzing
|
||||
analyzeMessage = "Starting analysis..."
|
||||
|
||||
Task {
|
||||
var serverMsg: String? = nil
|
||||
var onDeviceCount = 0
|
||||
var onDeviceErrors = 0
|
||||
|
||||
// 1. Server-side analysis
|
||||
if CompanionSettings.shared.isEnabled {
|
||||
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)
|
||||
serverMsg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Server analysis started"
|
||||
} catch {
|
||||
serverMsg = "Server unavailable — on-device only"
|
||||
}
|
||||
}
|
||||
|
||||
// 2. On-device analysis for downloaded songs
|
||||
let offlineSongs = await MainActor.run { OfflineManager.shared.downloadedSongs }
|
||||
let total = offlineSongs.count
|
||||
|
||||
for (idx, downloaded) in offlineSongs.enumerated() {
|
||||
let song = downloaded.song
|
||||
guard let path = song.path else { continue }
|
||||
if !force && SmartDJCache.shared.get(path) != nil { continue }
|
||||
guard let localURL = OfflineManager.shared.localURL(for: song.id) else { continue }
|
||||
|
||||
await MainActor.run {
|
||||
analyzeMessage = "Analyzing \(idx + 1)/\(total): \(song.title)"
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: localURL, pointsCount: 10, fps: 10.0, cutoff: 80,
|
||||
extractSmartDJ: true, progress: nil)
|
||||
SmartDJCache.shared.store(SmartDJProfile(
|
||||
bpm: nil, silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS
|
||||
), for: path)
|
||||
onDeviceCount += 1
|
||||
} catch {
|
||||
onDeviceErrors += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Summary
|
||||
await MainActor.run {
|
||||
var parts: [String] = []
|
||||
if let s = serverMsg { parts.append(s) }
|
||||
if onDeviceCount > 0 { parts.append("✓ \(onDeviceCount) on-device") }
|
||||
if onDeviceErrors > 0 { parts.append("\(onDeviceErrors) failed") }
|
||||
if parts.isEmpty { parts.append("✓ Nothing new to analyze") }
|
||||
analyzeStatus = .done
|
||||
analyzeMessage = parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
await MainActor.run {
|
||||
if analyzeStatus == .done { analyzeStatus = .idle; analyzeMessage = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
private func triggerVisPrecompute() {
|
||||
analyzeStatus = .analyzing
|
||||
analyzeMessage = "Generating visualizer frames on server..."
|
||||
|
||||
analyzeMessage = force ? "Requesting full re-compute on server..." : "Requesting precompute on server..."
|
||||
Task {
|
||||
do {
|
||||
guard let base = CompanionSettings.shared.baseURL else { return }
|
||||
|
|
@ -323,26 +186,13 @@ struct CompanionSettingsView: View {
|
|||
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)"
|
||||
}
|
||||
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)"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
await MainActor.run { if analyzeStatus != .analyzing { analyzeStatus = .idle; analyzeMessage = nil } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -505,10 +505,7 @@ struct SettingsView: View {
|
|||
|
||||
@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
|
||||
|
|
@ -638,15 +635,23 @@ struct SettingsView: View {
|
|||
.tint(accentPink)
|
||||
}
|
||||
|
||||
// Visualizer
|
||||
Section("Visualizer") {
|
||||
Button(action: { showVisualizerSettings = true }) {
|
||||
// Visualizer & Smart DJ
|
||||
Section("Audio & Visualizer") {
|
||||
NavigationLink {
|
||||
SmartDJVisualizerSettingsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Visualizer Settings")
|
||||
.foregroundColor(.white)
|
||||
Text("Smart DJ & Visualizer")
|
||||
Spacer()
|
||||
Text(VisualizerSettings.shared.style.rawValue)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Button(action: { showVisualizerSettings = true }) {
|
||||
HStack {
|
||||
Text("Visualizer Appearance")
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
|
|
@ -661,74 +666,18 @@ struct SettingsView: View {
|
|||
cacheSizeText = "0 MB"
|
||||
}) {
|
||||
HStack {
|
||||
Text("Clear Image Cache")
|
||||
.foregroundColor(.white)
|
||||
Text("Clear Image Cache").foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(cacheSizeText)
|
||||
.foregroundColor(.gray)
|
||||
Text(cacheSizeText).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
LibraryCache.shared.clearAll()
|
||||
}) {
|
||||
Button(action: { LibraryCache.shared.clearAll() }) {
|
||||
HStack {
|
||||
Text("Clear Library Cache")
|
||||
.foregroundColor(.white)
|
||||
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.")
|
||||
}
|
||||
} header: { Text("Storage") }
|
||||
|
||||
// About
|
||||
Section {
|
||||
|
|
@ -766,13 +715,6 @@ struct SettingsView: View {
|
|||
}
|
||||
.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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -792,86 +734,184 @@ struct SettingsView: View {
|
|||
}
|
||||
return ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
|
||||
}
|
||||
|
||||
private func analyzeAllMissing(force: Bool) {
|
||||
}
|
||||
|
||||
// MARK: - Smart DJ & Visualizer Settings
|
||||
struct SmartDJVisualizerSettingsView: View {
|
||||
@ObservedObject private var crossfade = SmartCrossfadeManager.shared
|
||||
@ObservedObject private var compSettings = CompanionSettings.shared
|
||||
@State private var visCacheText = "—"
|
||||
@State private var smartDJCacheCount = 0
|
||||
@State private var isAnalyzing = false
|
||||
@State private var analysisProgress = ""
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// Smart DJ
|
||||
Section {
|
||||
Toggle("Smart DJ", isOn: $compSettings.smartDJEnabled).tint(accentPink)
|
||||
if compSettings.smartDJEnabled {
|
||||
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled).tint(accentPink)
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Duration").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s").foregroundColor(.white)
|
||||
}
|
||||
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 0.5).tint(accentPink)
|
||||
HStack {
|
||||
Text("Target LUFS").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.0f", crossfade.targetLUFS)) LUFS").foregroundColor(.white)
|
||||
}
|
||||
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1).tint(accentPink)
|
||||
Toggle("Skip Silence", isOn: $crossfade.skipSilence).tint(accentPink)
|
||||
}
|
||||
}
|
||||
} header: { Text("Smart DJ") } footer: {
|
||||
Text("Smart DJ uses on-device analysis for downloaded songs (silence detection + loudness). When the Companion API is enabled, server-side BPM data is also used.")
|
||||
}
|
||||
|
||||
// Combined Analysis
|
||||
Section {
|
||||
Button(action: { runAnalysis(force: false) }) {
|
||||
HStack {
|
||||
Image(systemName: isAnalyzing ? "waveform" : "waveform.badge.magnifyingglass")
|
||||
.foregroundColor(accentPink)
|
||||
.symbolEffect(.pulse, isActive: isAnalyzing)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Analyze Missing Songs").foregroundColor(.white)
|
||||
Text("Vis frames + Smart DJ profile for un-analyzed songs")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
Spacer()
|
||||
if isAnalyzing {
|
||||
Text(analysisProgress).font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
|
||||
Button(action: { runAnalysis(force: true) }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Re-Analyze All Songs").foregroundColor(.white)
|
||||
Text("Clear all caches and rebuild from scratch")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
} header: { Text("Analysis (On-Device)") } footer: {
|
||||
Text("Each song is read once to produce both its Mitsuha visualizer frames and its Smart DJ profile (silence boundaries + loudness). Results are cached on-device.")
|
||||
}
|
||||
|
||||
// Cache management
|
||||
Section {
|
||||
HStack {
|
||||
Text("Visualizer Cache").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(visCacheText).foregroundColor(.white).font(.caption)
|
||||
}
|
||||
Button(action: {
|
||||
Task {
|
||||
await VisualizerStorageManager.shared.clearCache()
|
||||
await refreshVisCacheText()
|
||||
}
|
||||
}) {
|
||||
Text("Clear Visualizer Cache").foregroundColor(.red)
|
||||
}
|
||||
HStack {
|
||||
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(smartDJCacheCount)").foregroundColor(.white).font(.caption)
|
||||
}
|
||||
Button(action: {
|
||||
SmartDJCache.shared.clearAll()
|
||||
smartDJCacheCount = 0
|
||||
}) {
|
||||
Text("Clear Smart DJ Cache").foregroundColor(.red)
|
||||
}
|
||||
} header: { Text("Cache") }
|
||||
}
|
||||
.navigationTitle("Smart DJ & Visualizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
smartDJCacheCount = SmartDJCache.shared.cachedCount
|
||||
Task { await refreshVisCacheText() }
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshVisCacheText() async {
|
||||
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 runAnalysis(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()
|
||||
SmartDJCache.shared.clearAll()
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
var toAnalyze: [(songId: String, path: String?, url: URL)] = []
|
||||
for dl in downloaded {
|
||||
if let url = OfflineManager.shared.localURL(for: dl.id) {
|
||||
let hasVis = await storage.hasCache(for: dl.id)
|
||||
let hasProfile = dl.song.path.map { SmartDJCache.shared.get($0) != nil } ?? false
|
||||
if !hasVis || !hasProfile || force {
|
||||
toAnalyze.append((songId: dl.id, path: dl.song.path, url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if toAnalyze.isEmpty {
|
||||
await MainActor.run {
|
||||
analysisProgress = "All songs cached"
|
||||
isAnalyzing = false
|
||||
refreshVisCacheText()
|
||||
}
|
||||
await MainActor.run { analysisProgress = "All up to date"; isAnalyzing = false }
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await MainActor.run { analysisProgress = "" }
|
||||
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)"
|
||||
}
|
||||
|
||||
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")
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: item.url, pointsCount: points, fps: fps,
|
||||
cutoff: cutoff, extractSmartDJ: true)
|
||||
try? await storage.saveCache(frames: result.visFrames, for: item.songId)
|
||||
if let path = item.path {
|
||||
SmartDJCache.shared.store(SmartDJProfile(
|
||||
bpm: nil, silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS
|
||||
), for: path)
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.shared.log("Failed: \(item.id) → \(error.localizedDescription)", category: "Error")
|
||||
DebugLogger.shared.log("Analysis failed: \(item.songId) — \(error.localizedDescription)", category: "FFT")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
analysisProgress = "Done (\(toAnalyze.count) songs)"
|
||||
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))"
|
||||
smartDJCacheCount = SmartDJCache.shared.cachedCount
|
||||
}
|
||||
Task { await refreshVisCacheText() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ struct NowPlayingView: View {
|
|||
|
||||
// MARK: - Visualizer Layer
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
@ViewBuilder
|
||||
private func visualizerLayer(geo: GeometryProxy) -> some View {
|
||||
let height = isLandscape
|
||||
|
|
|
|||
|
|
@ -689,39 +689,6 @@ struct VisualizerSettingsView: View {
|
|||
Text("Viscosity controls how quickly the wave reacts. 0.15–0.25 = heavy liquid. 0.5 = responsive. 0.8+ = snappy EQ.\n\nSensitivity multiplies incoming audio. Base multiplier is a second gain stage for FFT.\n\nDynamic Gain automatically normalises the wave amplitude across loud and quiet tracks — keeps the wave full on soft passages without clipping on loud ones.\n\nFPS drops to 24 in Low Power Mode.")
|
||||
}
|
||||
|
||||
// Smart DJ Crossfade
|
||||
Section {
|
||||
let crossfade = SmartCrossfadeManager.shared
|
||||
Toggle("Smart Crossfade", isOn: Binding(
|
||||
get: { crossfade.isEnabled },
|
||||
set: { crossfade.isEnabled = $0 }
|
||||
)).tint(pink)
|
||||
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { crossfade.crossfadeDuration },
|
||||
set: { crossfade.crossfadeDuration = $0 }
|
||||
),
|
||||
in: 1...10,
|
||||
step: 0.5
|
||||
).tint(pink)
|
||||
|
||||
Toggle("Skip Silence", isOn: Binding(
|
||||
get: { crossfade.skipSilence },
|
||||
set: { crossfade.skipSilence = $0 }
|
||||
)).tint(pink)
|
||||
}
|
||||
} header: { Text("CROSSFADE") } footer: {
|
||||
Text("Smart Crossfade uses Companion API profiles to blend songs seamlessly. Duration controls how long the fade lasts. Skip Silence jumps past leading/trailing silence.")
|
||||
}
|
||||
|
||||
// Presets
|
||||
Section {
|
||||
Button(action: applyDeepOcean) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue