NavidromeApp/iOS/Views/Library/DownloadsSettingsView.swift
Dallas Groot 2bdac607b4 bug fixes
Songs Tab (SearchView.swift)
Default state now loads all songs alphabetically from the library via
getAlbumList2 → per-album song fetch, cached under "all_songs_sorted"
so subsequent opens are instant. The Download All banner shows song
count + already-downloaded count and queues only non-downloaded songs.
Every row uses .contextMenu (the long-press menu) with Play Now, Play
Next, Add to Queue, Download/Remove, Send to Watch, and Add to
Playlist — same pattern as Favourites. Watch and download badges
appear on each row. Searching ≥2 chars runs the server search and
shows artists/albums/songs in sections, then clears back to the full
list when the field is empty.
Keyboard Done Button
A single keyboardDoneButton() View extension in AsyncCoverArt.swift
calls UIApplication.shared.sendAction(resignFirstResponder:...)
globally — no @FocusState needed. Applied to: LoginView (all 4
fields), CompanionSettingsView (host/port), TrackEditorView
(checkField helper covers all tag fields), BatchAlbumEditorSheet
(editField helper), RadioView (name/URL), PlaylistsView (name fields),
MyMusicView (search), SearchView (via @FocusState + toolbar directly).
ShazamKit MTAudioProcessingTap
Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix
— works for HLS, radio, and any AVPlayer stream without touching the
microphone. The prepare callback captures the source format and builds
an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free
function (required by the API) calls
MTAudioProcessingTapGetSourceAudio then dispatches to a serial
analysisQueue — the render thread is never blocked. convertAndMatch
wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and
feeds SHSession.matchStreamingBuffer. Fallback to microphone
(AVAudioEngine) is kept for the local engine path where no
AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if
the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00

968 lines
41 KiB
Swift

import SwiftUI
import Network
// MARK: - Downloads View
struct DownloadsView: View {
@EnvironmentObject var offlineManager: OfflineManager
@EnvironmentObject var audioPlayer: AudioPlayer
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@State private var selectedTab: DownloadTab = .offline
enum DownloadTab: String, CaseIterable {
case offline = "Offline"
case watch = "Watch"
}
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab picker
Picker("", selection: $selectedTab) {
ForEach(DownloadTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.vertical, 10)
switch selectedTab {
case .offline:
offlineTab
case .watch:
watchTab
}
}
.background(Color(white: 0.06))
.navigationTitle("Downloads")
}
}
// MARK: - Offline Tab
private var offlineTab: some View {
List {
// Storage info
Section {
HStack {
Image(systemName: "internaldrive")
.foregroundColor(accentPink)
Text("Storage Used")
Spacer()
Text(offlineManager.formattedSize)
.foregroundColor(.gray)
}
HStack {
Image(systemName: "music.note")
.foregroundColor(accentPink)
Text("Downloaded Songs")
Spacer()
Text("\(offlineManager.downloadedSongs.count)")
.foregroundColor(.gray)
}
}
// Downloaded songs
Section("Offline Songs") {
if offlineManager.downloadedSongs.isEmpty {
VStack(spacing: 12) {
Image(systemName: "arrow.down.circle")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No downloads yet")
.font(.system(size: 14))
.foregroundColor(.gray)
Text("Download songs from albums or playlists to listen offline")
.font(.system(size: 12))
.foregroundColor(.gray.opacity(0.7))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(offlineManager.downloadedSongs) { downloaded in
Button(action: {
audioPlayer.play(
song: downloaded.song,
fromQueue: offlineManager.downloadedSongs.map { $0.song }
)
}) {
HStack(spacing: 12) {
AsyncCoverArt(
coverArtId: downloaded.song.coverArt,
size: 44
)
.frame(width: 44, height: 44)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 2) {
Text(downloaded.song.title)
.font(.system(size: 15))
.foregroundColor(
audioPlayer.currentSong?.id == downloaded.id ? accentPink : .white
)
.lineLimit(1)
Text(downloaded.song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(downloaded.song.durationFormatted)
.font(.system(size: 12))
.foregroundColor(.gray)
Text(ByteCountFormatter.string(fromByteCount: downloaded.fileSize, countStyle: .file))
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.7))
}
}
}
}
.onDelete { offsets in
for idx in offsets {
offlineManager.removeSong(offlineManager.downloadedSongs[idx].id)
}
}
}
}
// Remove all
if !offlineManager.downloadedSongs.isEmpty {
Section {
Button("Remove All Downloads", role: .destructive) {
offlineManager.removeAll()
}
}
}
}
}
// MARK: - Watch Tab
private var watchTab: some View {
List {
if !watchManager.isWatchPaired {
Section {
VStack(spacing: 12) {
Image(systemName: "applewatch.slash")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No Apple Watch Paired")
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
Text("Pair an Apple Watch and install the app to send music")
.font(.system(size: 12))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
} else {
// Watch status
Section {
HStack(spacing: 10) {
Image(systemName: watchManager.isReachable ? "applewatch.radiowaves.left.and.right" : "applewatch")
.foregroundColor(watchManager.isReachable ? .green : .gray)
.font(.system(size: 18))
VStack(alignment: .leading, spacing: 2) {
Text(watchManager.isReachable ? "Watch App Active" : "Watch App Not Running")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Text(watchManager.isReachable
? "Files will transfer immediately"
: "Open the app on your watch to receive files")
.font(.system(size: 11))
.foregroundColor(.gray)
}
Spacer()
if watchManager.isReachable {
Button(action: { watchManager.requestWatchSongList() }) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 13))
.foregroundColor(accentPink)
}
}
}
} header: {
Text("Status")
}
// Pending transfers
if !watchManager.transferringIds.isEmpty {
Section {
ForEach(watchManager.pendingTransferList, id: \.id) { item in
HStack(spacing: 12) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 3)
Circle()
.trim(from: 0, to: item.progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(Int(item.progress * 100))")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(.gray)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.system(size: 14))
.foregroundColor(.white)
.lineLimit(1)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.1)).frame(height: 3)
Capsule().fill(accentPink)
.frame(width: geo.size.width * item.progress, height: 3)
}
}
.frame(height: 3)
}
Spacer()
}
}
.onDelete { indexSet in
let list = watchManager.pendingTransferList
for idx in indexSet {
if idx < list.count {
watchManager.cancelTransfer(songId: list[idx].id)
}
}
}
if watchManager.transferringIds.count > 1 {
Button(role: .destructive, action: { watchManager.cancelAllTransfers() }) {
HStack {
Image(systemName: "xmark.circle")
Text("Cancel All (\(watchManager.transferringIds.count))")
}
.font(.system(size: 13))
}
}
} header: {
Text("Pending Transfers (\(watchManager.transferringIds.count))")
}
}
// Completed
if !watchManager.transferredIds.isEmpty {
Section {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("\(watchManager.transferredIds.count) songs sent")
.font(.system(size: 14))
.foregroundColor(.white)
Spacer()
Button("Clear") { watchManager.transferredIds.removeAll() }
.font(.system(size: 13))
.foregroundColor(accentPink)
}
} header: {
Text("Recently Sent")
}
}
// Songs on watch
Section {
if watchManager.isLoadingWatchSongs {
HStack {
ProgressView().tint(accentPink)
Text("Loading watch library...")
.font(.system(size: 13))
.foregroundColor(.gray)
.padding(.leading, 8)
}
} else if watchManager.watchSongs.isEmpty {
VStack(spacing: 8) {
Image(systemName: "applewatch")
.font(.system(size: 24))
.foregroundColor(.gray)
Text(watchManager.isReachable ? "No songs on Watch" : "Connect watch to view songs")
.font(.system(size: 13))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
} else {
HStack {
Text("\(watchManager.watchSongs.count) songs")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
Spacer()
let totalSize = watchManager.watchSongs.reduce(0) { $0 + $1.fileSize }
Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))
.font(.system(size: 13))
.foregroundColor(.gray)
}
ForEach(watchManager.watchSongs) { song in
HStack(spacing: 12) {
Image(systemName: "music.note")
.font(.system(size: 12))
.foregroundColor(accentPink)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 14))
.foregroundColor(.white)
.lineLimit(1)
Text("\(song.artist) · \(song.album)")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(ByteCountFormatter.string(fromByteCount: song.fileSize, countStyle: .file))
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
.onDelete { indexSet in
for idx in indexSet {
if idx < watchManager.watchSongs.count {
watchManager.requestWatchDeleteSong(watchManager.watchSongs[idx].id)
}
}
}
Button(role: .destructive, action: { watchManager.requestWatchDeleteAll() }) {
HStack {
Image(systemName: "trash")
Text("Remove All from Watch")
}
.font(.system(size: 13))
}
}
} header: {
Text("On Watch")
}
// Send all offline songs to watch
if !offlineManager.downloadedSongs.isEmpty {
Section {
Button(action: {
watchManager.syncOfflineSongsToWatch(songs: offlineManager.downloadedSongs)
}) {
Label("Send All Offline Songs to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
}
}
}
.onAppear {
if watchManager.isReachable && watchManager.watchSongs.isEmpty {
watchManager.requestWatchSongList()
}
}
.onChange(of: watchManager.isReachable) { _, reachable in
if reachable { watchManager.requestWatchSongList() }
}
}
}
// MARK: - Streaming Quality Manager (shared)
class StreamingQuality: ObservableObject {
static let shared = StreamingQuality()
// WiFi
@Published var wifiFormat: String { didSet { UserDefaults.standard.set(wifiFormat, forKey: "wifi_format") } }
@Published var wifiBitRate: String { didSet { UserDefaults.standard.set(wifiBitRate, forKey: "wifi_bitrate") } }
// Cellular
@Published var cellularFormat: String { didSet { UserDefaults.standard.set(cellularFormat, forKey: "cell_format") } }
@Published var cellularBitRate: String { didSet { UserDefaults.standard.set(cellularBitRate, forKey: "cell_bitrate") } }
// Cache / Downloads
@Published var cacheFormat: String { didSet { UserDefaults.standard.set(cacheFormat, forKey: "cache_format") } }
@Published var cacheBitRate: String { didSet { UserDefaults.standard.set(cacheBitRate, forKey: "cache_bitrate") } }
// Scrobbling
@Published var scrobbleEnabled: Bool { didSet { UserDefaults.standard.set(scrobbleEnabled, forKey: "scrobble_enabled") } }
/// Formats supported by Navidrome's stream endpoint
static let formatOptions: [(value: String, label: String)] = [
("raw", "Raw/Original"),
("mp3", "MP3"),
("opus", "Opus"),
("aac", "AAC"),
("ogg", "OGG Vorbis"),
]
/// Bitrate options for transcoded formats
static let bitrateOptions: [(value: String, label: String)] = [
("0", "No Limit (default)"),
("320", "320 kbps"),
("256", "256 kbps"),
("192", "192 kbps"),
("128", "128 kbps"),
("96", "96 kbps"),
("64", "64 kbps"),
]
/// Active format based on current connection
var format: String? {
let fmt = isOnCellular ? cellularFormat : wifiFormat
return fmt == "raw" ? nil : fmt // nil = no format param = original
}
/// Active bitrate based on current connection
var maxBitRate: Int? {
let br = isOnCellular ? cellularBitRate : wifiBitRate
if br == "0" { return nil }
return Int(br)
}
/// Format for downloads/cache
var downloadFormat: String? {
return cacheFormat == "raw" ? nil : cacheFormat
}
var downloadBitRate: Int? {
if cacheBitRate == "0" { return nil }
return Int(cacheBitRate)
}
/// Summary string for current WiFi config
var wifiSummary: String {
if wifiFormat == "raw" { return "Raw/Original" }
let br = wifiBitRate == "0" ? "No limit" : "\(wifiBitRate) kbps"
return "\(wifiFormat.uppercased()) · \(br)"
}
/// Summary string for current Cellular config
var cellularSummary: String {
if cellularFormat == "raw" { return "Raw/Original" }
let br = cellularBitRate == "0" ? "No limit" : "\(cellularBitRate) kbps"
return "\(cellularFormat.uppercased()) · \(br)"
}
/// Summary string for cache config
var cacheSummary: String {
if cacheFormat == "raw" { return "Raw/Original" }
let br = cacheBitRate == "0" ? "No limit" : "\(cacheBitRate) kbps"
return "\(cacheFormat.uppercased()) · \(br)"
}
// Legacy compat
var streamBitRate: String {
get { isOnCellular ? cellularBitRate : wifiBitRate }
set { if isOnCellular { cellularBitRate = newValue } else { wifiBitRate = newValue } }
}
var streamFormat: String {
get { isOnCellular ? cellularFormat : wifiFormat }
set { if isOnCellular { cellularFormat = newValue } else { wifiFormat = newValue } }
}
var downloadOriginal: Bool {
get { cacheFormat == "raw" }
set { cacheFormat = newValue ? "raw" : "mp3" }
}
private var isOnCellular: Bool {
return !isOnWiFi
}
private var isOnWiFi: Bool {
var result = true
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
let semaphore = DispatchSemaphore(value: 0)
monitor.pathUpdateHandler = { path in
result = (path.status == .satisfied)
semaphore.signal()
}
let queue = DispatchQueue(label: "wifi.check")
monitor.start(queue: queue)
_ = semaphore.wait(timeout: .now() + 0.1)
monitor.cancel()
return result
}
private init() {
let d = UserDefaults.standard
wifiFormat = d.string(forKey: "wifi_format") ?? "raw"
wifiBitRate = d.string(forKey: "wifi_bitrate") ?? "0"
cellularFormat = d.string(forKey: "cell_format") ?? "mp3"
cellularBitRate = d.string(forKey: "cell_bitrate") ?? "320"
cacheFormat = d.string(forKey: "cache_format") ?? "raw"
cacheBitRate = d.string(forKey: "cache_bitrate") ?? "0"
scrobbleEnabled = d.object(forKey: "scrobble_enabled") as? Bool ?? true
}
}
// MARK: - Settings View
struct SettingsView: View {
@EnvironmentObject var serverManager: ServerManager
@ObservedObject var quality = StreamingQuality.shared
@ObservedObject var debugLogger = DebugLogger.shared
@State private var showVisualizerSettings = false
@State private var cacheSizeText = "..."
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
let streamOptions = StreamingQuality.formatOptions
let bitrateOptions = StreamingQuality.bitrateOptions
var body: some View {
NavigationStack {
List {
// Active Server
Section("Server") {
if let server = serverManager.activeServer {
VStack(alignment: .leading, spacing: 4) {
Text(server.name)
.font(.system(size: 16, weight: .medium))
Text(server.url)
.font(.system(size: 13))
.foregroundColor(.gray)
Text("Logged in as \(server.username)")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
NavigationLink("Manage Servers") {
ManageServersView()
}
NavigationLink {
CompanionSettingsView()
} label: {
HStack {
Text("Companion API")
Spacer()
if CompanionSettings.shared.isEnabled {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 12))
.foregroundColor(.green)
}
}
}
}
// WiFi Streaming
Section {
Picker("Format (Transcoding)", selection: $quality.wifiFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.wifiFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.wifiBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("WiFi Streaming")
} footer: {
if quality.wifiFormat == "raw" {
Text("Streams your files as-is (FLAC, ALAC, etc). Uses the Subsonic 'download' action — no transcoding.")
} else {
Text("Select a transcoding format for WiFi streaming. Uses the Subsonic 'stream' action with server-side transcoding.")
}
}
// Cellular Streaming
Section {
Picker("Format (Transcoding)", selection: $quality.cellularFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.cellularFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.cellularBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("Cellular Streaming")
} footer: {
Text("Transcoding is recommended on cellular to reduce data usage. Default: MP3 320 kbps.")
}
// Cache / Downloads
Section {
Picker("Format (Transcoding)", selection: $quality.cacheFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.cacheFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.cacheBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("Cache Format (Downloads)")
} footer: {
Text("For 'Raw/Original', uses the Subsonic 'download' action which skips transcoding. Other formats use 'stream' with server transcoding. Changes won't apply to already downloaded songs; clear cache and redownload if needed.")
}
// Watch
Section {
HStack {
Text("Watch Transfer Quality")
Spacer()
Text("MP3 192 kbps")
.foregroundColor(.gray)
}
} header: {
Text("Apple Watch")
} footer: {
Text("Songs sent to Apple Watch are always transcoded to MP3 192 kbps for fast transfer and storage efficiency.")
}
// Scrobbling
Section("Activity") {
Toggle("Scrobble Plays", isOn: $quality.scrobbleEnabled)
.tint(accentPink)
}
// Visualizer & Smart DJ
Section("Audio & Visualizer") {
NavigationLink {
SmartDJVisualizerSettingsView()
} label: {
Text("Crossfade & Visualizer Analyzer")
}
Button(action: { showVisualizerSettings = true }) {
HStack {
Text("Visualizer Appearance")
.foregroundColor(.white)
Spacer()
Text(VisualizerSettings.shared.nowPlaying.style.rawValue)
.foregroundColor(.gray)
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
// Storage
Section {
Button(action: {
ImageCache.shared.clearAll()
cacheSizeText = "0 MB"
}) {
HStack {
Text("Clear Image Cache").foregroundColor(.white)
Spacer()
Text(cacheSizeText).foregroundColor(.gray)
}
}
Button(action: { LibraryCache.shared.clearAll() }) {
HStack {
Text("Clear Library Cache").foregroundColor(.white)
Spacer()
}
}
} header: { Text("Storage") }
// About
Section {
Toggle("Debug Console", isOn: $debugLogger.isEnabled)
.tint(accentPink)
} header: {
Text("Developer")
} footer: {
Text("Shows a debug panel above the tab bar with real-time logs for Watch transfers, audio engine, network, and more. Drag to resize.")
}
Section("About") {
HStack {
Text("Version")
Spacer()
Text("1.0.0").foregroundColor(.gray)
}
HStack {
Text("API Version")
Spacer()
Text("1.16.1").foregroundColor(.gray)
}
}
// Logout
Section {
Button("Disconnect", role: .destructive) {
serverManager.disconnect()
}
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showVisualizerSettings) {
VisualizerSettingsView()
}
.onAppear {
cacheSizeText = computeCacheSize()
}
}
}
private func computeCacheSize() -> String {
let fm = FileManager.default
let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cacheDir = caches.appendingPathComponent("ImageCache", isDirectory: true)
guard let files = try? fm.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else {
return "0 MB"
}
var total: Int64 = 0
for file in files {
if let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize {
total += Int64(size)
}
}
return ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
}
}
// 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("Crossfade & Visualizer Analyzer")
.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.nowPlaying.numberOfPoints
let fps = VisualizerSettings.shared.effectiveFPS
let cutoff = VisualizerSettings.shared.frequencyCutoff
Task {
let storage = VisualizerStorageManager.shared
if force {
await storage.clearCache()
SmartDJCache.shared.clearAll()
}
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 up to date"; isAnalyzing = false }
try? await Task.sleep(for: .seconds(2))
await MainActor.run { analysisProgress = "" }
return
}
for (idx, item) in toAnalyze.enumerated() {
await MainActor.run { analysisProgress = "\(idx + 1)/\(toAnalyze.count)" }
do {
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("Analysis failed: \(item.songId)\(error.localizedDescription)", category: "FFT")
}
}
await MainActor.run {
analysisProgress = "Done — \(toAnalyze.count) songs"
isAnalyzing = false
smartDJCacheCount = SmartDJCache.shared.cachedCount
}
Task { await refreshVisCacheText() }
}
}
}
// MARK: - Manage Servers View
struct ManageServersView: View {
@EnvironmentObject var serverManager: ServerManager
@State private var showAddServer = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
List {
ForEach(serverManager.servers) { server in
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(server.name)
.font(.system(size: 16, weight: .medium))
Text(server.url)
.font(.system(size: 12))
.foregroundColor(.gray)
Text(server.username)
.font(.system(size: 11))
.foregroundColor(.gray.opacity(0.7))
}
Spacer()
if serverManager.activeServer?.id == server.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(accentPink)
}
}
.contentShape(Rectangle())
.onTapGesture {
Task { _ = await serverManager.switchServer(server) }
}
}
.onDelete { offsets in
serverManager.removeServer(at: offsets)
}
Button(action: { showAddServer = true }) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(accentPink)
Text("Add Server")
.foregroundColor(accentPink)
}
}
}
.navigationTitle("Servers")
.sheet(isPresented: $showAddServer) {
AddServerSheet(isPresented: $showAddServer)
}
}
}