NavidromeApp/iOS/Views/Library/DownloadsSettingsView.swift

1276 lines
55 KiB
Swift
Raw Normal View History

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
2026-04-11 02:23:03 -07:00
@State private var showVisualizerSettings = false
@State private var cacheSizeText = "..."
2026-04-11 02:23:03 -07:00
@State private var conflictErrorCount = 0
@State private var conflictTotalCount = 0
2026-04-05 08:23:07 -07:00
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)
}
}
}
2026-04-11 02:23:03 -07:00
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)
}
2026-04-05 08:23:07 -07:00
// Visualizer & Smart DJ
Section("Audio & Visualizer") {
NavigationLink {
SmartDJVisualizerSettingsView()
} label: {
Text("Crossfade & Visualizer Analyzer")
2026-04-05 08:23:07 -07:00
}
Button(action: { showVisualizerSettings = true }) {
HStack {
Text("Visualizer Appearance")
.foregroundColor(.white)
Spacer()
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
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 {
2026-04-05 08:23:07 -07:00
Text("Clear Image Cache").foregroundColor(.white)
Spacer()
2026-04-05 08:23:07 -07:00
Text(cacheSizeText).foregroundColor(.gray)
}
}
2026-04-05 08:23:07 -07:00
Button(action: { LibraryCache.shared.clearAll() }) {
HStack {
2026-04-05 08:23:07 -07:00
Text("Clear Library Cache").foregroundColor(.white)
Spacer()
}
}
2026-04-05 08:23:07 -07:00
} 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()
2026-04-11 02:23:03 -07:00
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)
}
2026-04-05 08:23:07 -07:00
}
// 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")
2026-04-05 08:23:07 -07:00
.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..."
2026-04-05 08:23:07 -07:00
let downloaded = OfflineManager.shared.downloadedSongs
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
let points = VisualizerSettings.shared.nowPlaying.numberOfPoints
let fps = VisualizerSettings.shared.effectiveFPS
let cutoff = VisualizerSettings.shared.frequencyCutoff
2026-04-05 08:23:07 -07:00
Task {
let storage = VisualizerStorageManager.shared
if force {
await storage.clearCache()
2026-04-05 08:23:07 -07:00
SmartDJCache.shared.clearAll()
}
2026-04-05 08:23:07 -07:00
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))
}
}
}
2026-04-05 08:23:07 -07:00
if toAnalyze.isEmpty {
2026-04-05 08:23:07 -07:00
await MainActor.run { analysisProgress = "All up to date"; isAnalyzing = false }
try? await Task.sleep(for: .seconds(2))
await MainActor.run { analysisProgress = "" }
return
}
2026-04-05 08:23:07 -07:00
for (idx, item) in toAnalyze.enumerated() {
2026-04-05 08:23:07 -07:00
await MainActor.run { analysisProgress = "\(idx + 1)/\(toAnalyze.count)" }
do {
2026-04-05 08:23:07 -07:00
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 {
2026-04-05 08:23:07 -07:00
DebugLogger.shared.log("Analysis failed: \(item.songId)\(error.localizedDescription)", category: "FFT")
}
}
2026-04-05 08:23:07 -07:00
await MainActor.run {
2026-04-05 08:23:07 -07:00
analysisProgress = "Done — \(toAnalyze.count) songs"
isAnalyzing = false
2026-04-05 08:23:07 -07:00
smartDJCacheCount = SmartDJCache.shared.cachedCount
}
2026-04-05 08:23:07 -07:00
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)
}
}
}
2026-04-11 01:59:36 -07:00
// =============================================================================
// 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"
2026-04-11 08:36:32 -07:00
case "album_reassigned": return "arrow.triangle.2.circlepath.circle.fill"
2026-04-11 01:59:36 -07:00
default: return "exclamationmark.circle"
}
}
}
struct LibraryConflictsView: View {
@StateObject private var manager = ConflictManager.shared
2026-04-11 02:20:54 -07:00
@State private var selectedTab = 0 // 0 = Active, 1 = Ignored
@AppStorage("ignoredConflictIDs") private var ignoredIDsRaw = ""
2026-04-11 01:59:36 -07:00
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
2026-04-11 02:20:54 -07:00
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) }
}
2026-04-11 01:59:36 -07:00
var body: some View {
List {
2026-04-11 02:20:54 -07:00
// Tab picker
Section {
Picker("", selection: $selectedTab) {
Text("Active (\(activeIssues.count))").tag(0)
Text("Ignored (\(ignoredIssues.count))").tag(1)
}
.pickerStyle(.segmented)
.padding(.vertical, 4)
}
2026-04-11 01:59:36 -07:00
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)
}
2026-04-11 02:20:54 -07:00
} else if selectedTab == 0 {
// Active tab
if activeIssues.isEmpty {
2026-04-11 01:59:36 -07:00
Section {
HStack(spacing: 12) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 28))
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
2026-04-11 02:20:54 -07:00
Text("No active issues")
2026-04-11 01:59:36 -07:00
.font(.system(size: 15, weight: .medium))
Text("Your library looks clean.")
.font(.system(size: 13))
.foregroundColor(.gray)
}
}
.padding(.vertical, 6)
}
} else {
2026-04-11 02:20:54 -07:00
let errors = activeIssues.filter { $0.severity == "error" }.count
let warnings = activeIssues.filter { $0.severity == "warning" }.count
2026-04-11 01:59:36 -07:00
Section {
HStack(spacing: 16) {
2026-04-11 02:20:54 -07:00
summaryBadge(count: errors, label: "Errors", color: accentPink)
summaryBadge(count: warnings, label: "Warnings", color: .yellow)
2026-04-11 01:59:36 -07:00
Spacer()
}
.padding(.vertical, 4)
} header: { Text("Summary") }
2026-04-11 02:20:54 -07:00
ForEach(activeIssues) { conflict in
2026-04-11 01:59:36 -07:00
conflictRow(conflict)
2026-04-11 02:20:54 -07:00
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
withAnimation { ignore(conflict) }
} label: {
Label("Ignore", systemImage: "eye.slash")
}
.tint(.gray)
}
2026-04-11 01:59:36 -07:00
}
}
} else {
2026-04-11 02:20:54 -07:00
// 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)
}
}
2026-04-11 01:59:36 -07:00
}
}
}
.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() }
}
}
2026-04-11 02:20:54 -07:00
// MARK: - Conflict Row
2026-04-11 01:59:36 -07:00
@ViewBuilder
2026-04-11 02:20:54 -07:00
private func conflictRow(_ conflict: LibraryConflict, dimmed: Bool = false) -> some View {
2026-04-11 01:59:36 -07:00
Section {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: conflict.typeIcon)
.font(.system(size: 16))
2026-04-11 02:20:54 -07:00
.foregroundColor(dimmed ? .gray : conflict.severityColor)
2026-04-11 01:59:36 -07:00
Text(conflict.title)
.font(.system(size: 14, weight: .semibold))
2026-04-11 02:20:54 -07:00
.foregroundColor(dimmed ? .gray : .white)
2026-04-11 01:59:36 -07:00
Spacer()
Image(systemName: conflict.severityIcon)
.font(.system(size: 12))
2026-04-11 02:20:54 -07:00
.foregroundColor(dimmed ? .gray : conflict.severityColor)
2026-04-11 01:59:36 -07:00
}
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)
}
2026-04-11 02:20:54 -07:00
if !dimmed, let action = conflict.fix_action {
2026-04-11 01:59:36 -07:00
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)
}
}
2026-04-11 02:20:54 -07:00
// MARK: - Helpers
2026-04-11 01:59:36 -07:00
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"
2026-04-11 08:36:32 -07:00
case "fix_album_reassigned": return "Reapply Tags"
2026-04-11 01:59:36 -07:00
default: return "Fix"
}
}
}
2026-04-11 02:20:54 -07:00