NavidromeApp/iOS/Views/Library/DownloadsSettingsView.swift
Dallas Groot 3385b88270 Audio Tap Infrastructure:
- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer
- Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output)
- Zero per-frame heap allocation in FFT path
- Shared tap serves both FFT visualizer and Shazam simultaneously

Fixes (blockers for tap to work):
- radioGoLive/radioSeekBack now update self.playerItem (was orphaned)
- Tap reinstalled on every AVPlayerItem swap (seek, live, station change)
- Tap removed on background, reinstalled on foreground
- Tap removed on radio→music transition

Shazam rework:
- Uses shared AudioTapProcessor instead of creating its own tap
- Fixes tap conflict where Shazam overwrote FFT audioMix
- 500ms wait for tapPrepare callback (sourceFormat timing race)
- Fixed pre-existing bug: stopAll() audio session never restored after mic fallback

Debug capture:
- Capture Audio Tap button in Visualizer Settings
- Records 5s of raw tap PCM as playable WAV file
- Uses actual stream sample rate (not hardcoded 44100)
- Share sheet via Notification pattern (survives view dismiss)
- Spinner auto-resets on appear if capture interrupted by background

Also includes from main branch:
- Edit History UI, batch undo, companion API 7-bug fix
- Recently Played tab, Discover section, Play Queue sync, Share links"
2026-04-14 17:15:34 -07:00

1312 lines
56 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 = "..."
@State private var conflictErrorCount = 0
@State private var conflictTotalCount = 0
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)
}
}
}
if CompanionSettings.shared.isEnabled {
NavigationLink {
LibraryConflictsView()
} label: {
HStack {
Text("Issues & Conflicts")
Spacer()
if conflictErrorCount > 0 {
Text("\(conflictErrorCount)")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Color(red: 1, green: 0.176, blue: 0.333))
.clipShape(Capsule())
} else if conflictTotalCount > 0 {
Text("\(conflictTotalCount)")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
}
}
// 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") }
// Backup & Restore
BackupRestoreSection()
// 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)
}
NavigationLink {
LicensesView()
} label: {
HStack {
Image(systemName: "doc.text")
.foregroundStyle(accentPink)
Text("Licenses & Acknowledgments")
}
}
}
// Logout
Section {
Button("Disconnect", role: .destructive) {
serverManager.disconnect()
}
}
// Extra space so the last items aren't hidden behind the mini player
Section {} footer: {
Spacer().frame(height: 60)
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showVisualizerSettings) {
VisualizerSettingsView()
}
.onAppear {
cacheSizeText = computeCacheSize()
conflictErrorCount = ConflictManager.shared.badgeCount
conflictTotalCount = ConflictManager.shared.totalCount
}
.onReceive(NotificationCenter.default.publisher(for: .companionConflictsUpdated)) { _ in
conflictErrorCount = ConflictManager.shared.badgeCount
conflictTotalCount = ConflictManager.shared.totalCount
}
}
}
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)
}
}
}
// =============================================================================
// MARK: - Library Conflicts UI
// Appended here so no new file is needed avoids Xcode project registration issues.
// =============================================================================
extension LibraryConflict {
var severityColor: Color {
severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow
}
var severityIcon: String {
severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill"
}
var typeIcon: String {
switch type {
case "duplicate_album": return "rectangle.on.rectangle.fill"
case "missing_files": return "doc.badge.questionmark"
case "picard_legacy_tags": return "tag.slash.fill"
case "orphaned_tracks": return "link.badge.plus"
case "duplicate_track": return "music.note.list"
case "stale_companion_paths": return "externaldrive.badge.xmark"
case "album_reassigned": return "arrow.triangle.2.circlepath.circle.fill"
default: return "exclamationmark.circle"
}
}
}
struct LibraryConflictsView: View {
@StateObject private var manager = ConflictManager.shared
@State private var selectedTab = 0 // 0 = Active, 1 = Ignored
@AppStorage("ignoredConflictIDs") private var ignoredIDsRaw = ""
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var ignoredIDs: Set<String> {
Set(ignoredIDsRaw.split(separator: "|||").map(String.init))
}
private func ignore(_ conflict: LibraryConflict) {
var ids = ignoredIDs
ids.insert(conflict.id)
ignoredIDsRaw = ids.joined(separator: "|||")
}
private func restore(_ conflict: LibraryConflict) {
var ids = ignoredIDs
ids.remove(conflict.id)
ignoredIDsRaw = ids.joined(separator: "|||")
}
private var activeIssues: [LibraryConflict] {
guard let all = manager.conflicts?.issues else { return [] }
return all
.filter { !ignoredIDs.contains($0.id) }
.sorted { ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) }
}
private var ignoredIssues: [LibraryConflict] {
guard let all = manager.conflicts?.issues else { return [] }
return all.filter { ignoredIDs.contains($0.id) }
}
var body: some View {
List {
// Edit History link always visible at top
Section {
NavigationLink {
EditHistoryView()
} label: {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 16, weight: .medium))
.foregroundColor(accentPink)
.frame(width: 28)
Text("Edit History")
.font(.system(size: 15, weight: .medium))
Spacer()
Text("View & revert")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
// Tab picker
Section {
Picker("", selection: $selectedTab) {
Text("Active (\(activeIssues.count))").tag(0)
Text("Ignored (\(ignoredIssues.count))").tag(1)
}
.pickerStyle(.segmented)
.padding(.vertical, 4)
}
if manager.isLoading {
Section {
HStack {
ProgressView().tint(accentPink)
Text("Scanning library for issues…")
.font(.system(size: 14))
.foregroundColor(.gray)
.padding(.leading, 8)
}
.padding(.vertical, 4)
}
} else if let error = manager.lastError {
Section {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
}
} else if selectedTab == 0 {
// Active tab
if activeIssues.isEmpty {
Section {
HStack(spacing: 12) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 28))
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("No active issues")
.font(.system(size: 15, weight: .medium))
Text("Your library looks clean.")
.font(.system(size: 13))
.foregroundColor(.gray)
}
}
.padding(.vertical, 6)
}
} else {
let errors = activeIssues.filter { $0.severity == "error" }.count
let warnings = activeIssues.filter { $0.severity == "warning" }.count
Section {
HStack(spacing: 16) {
summaryBadge(count: errors, label: "Errors", color: accentPink)
summaryBadge(count: warnings, label: "Warnings", color: .yellow)
Spacer()
}
.padding(.vertical, 4)
} header: { Text("Summary") }
ForEach(activeIssues) { conflict in
conflictRow(conflict)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
withAnimation { ignore(conflict) }
} label: {
Label("Ignore", systemImage: "eye.slash")
}
.tint(.gray)
}
}
}
} else {
// Ignored tab
if ignoredIssues.isEmpty {
Section {
Text("No ignored issues.")
.font(.system(size: 14))
.foregroundColor(.gray)
}
} else {
Section {
Text("Swipe left to restore an issue to the Active list.")
.font(.system(size: 12))
.foregroundColor(.gray)
}
ForEach(ignoredIssues) { conflict in
conflictRow(conflict, dimmed: true)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
withAnimation { restore(conflict) }
} label: {
Label("Restore", systemImage: "arrow.uturn.left")
}
.tint(accentPink)
}
}
}
}
}
.navigationTitle("Issues & Conflicts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await manager.refresh() } }) {
if manager.isLoading {
ProgressView().tint(accentPink).scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise").foregroundColor(accentPink)
}
}
.disabled(manager.isLoading)
}
}
.task {
if manager.conflicts == nil { await manager.refresh() }
}
}
// MARK: - Conflict Row
@ViewBuilder
private func conflictRow(_ conflict: LibraryConflict, dimmed: Bool = false) -> some View {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: conflict.typeIcon)
.font(.system(size: 16))
.foregroundColor(dimmed ? .gray : conflict.severityColor)
Text(conflict.title)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(dimmed ? .gray : .white)
Spacer()
Image(systemName: conflict.severityIcon)
.font(.system(size: 12))
.foregroundColor(dimmed ? .gray : conflict.severityColor)
}
Text(conflict.detail)
.font(.system(size: 12))
.foregroundColor(.gray)
.fixedSize(horizontal: false, vertical: true)
if !conflict.affected_paths.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in
Text(path)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.45))
.lineLimit(1)
}
if conflict.affected_paths.count > 3 {
Text("+ \(conflict.affected_paths.count - 3) more")
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.6))
}
}
.padding(6)
.background(Color.white.opacity(0.05))
.cornerRadius(6)
}
if !dimmed, let action = conflict.fix_action {
HStack {
Spacer()
Button(action: { Task { await manager.fix(conflict) } }) {
if manager.isFixing == conflict.id {
HStack(spacing: 6) {
ProgressView().tint(.white).scaleEffect(0.75)
Text("Fixing…").font(.system(size: 13))
}
.padding(.horizontal, 16).padding(.vertical, 7)
.background(Color.gray.opacity(0.3))
.cornerRadius(8)
} else {
Text(fixButtonLabel(action))
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 16).padding(.vertical, 7)
.background(conflict.severityColor.opacity(0.25))
.foregroundColor(conflict.severityColor)
.cornerRadius(8)
}
}
.buttonStyle(.plain)
.disabled(manager.isFixing != nil)
}
}
}
.padding(.vertical, 4)
}
}
// MARK: - Helpers
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 2) {
Text("\(count)")
.font(.system(size: 24, weight: .bold))
.foregroundColor(count > 0 ? color : .gray)
Text(label)
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
private func fixButtonLabel(_ action: String) -> String {
switch action {
case "fix_duplicate_album": return "Merge Duplicate"
case "fix_missing_files": return "Remove from DB"
case "fix_picard_tags": return "Fix Tags"
case "fix_stale_paths": return "Rescan Library"
case "fix_orphaned_tracks": return "Trigger Rescan"
case "fix_album_reassigned": return "Reapply Tags"
default: return "Fix"
}
}
}