2026-03-28 13:49:47 -07:00
import SwiftUI
import Network
// MARK: - D o w n l o a d s V i e w
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 ) {
// T a b p i c k e r
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: - O f f l i n e T a b
private var offlineTab : some View {
List {
// S t o r a g e i n f o
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 )
}
}
// D o w n l o a d e d s o n g s
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 )
}
}
}
}
// R e m o v e a l l
if ! offlineManager . downloadedSongs . isEmpty {
Section {
Button ( " Remove All Downloads " , role : . destructive ) {
offlineManager . removeAll ( )
}
}
}
}
}
// MARK: - W a t c h T a b
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 {
// W a t c h s t a t u s
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 " )
}
// P e n d i n g t r a n s f e r s
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 ) ) " )
}
}
// C o m p l e t e d
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 " )
}
}
// S o n g s o n w a t c h
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 " )
}
// S e n d a l l o f f l i n e s o n g s t o w a t c h
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: - S t r e a m i n g Q u a l i t y M a n a g e r ( s h a r e d )
class StreamingQuality : ObservableObject {
static let shared = StreamingQuality ( )
// W i F i
@ Published var wifiFormat : String { didSet { UserDefaults . standard . set ( wifiFormat , forKey : " wifi_format " ) } }
@ Published var wifiBitRate : String { didSet { UserDefaults . standard . set ( wifiBitRate , forKey : " wifi_bitrate " ) } }
// C e l l u l a r
@ Published var cellularFormat : String { didSet { UserDefaults . standard . set ( cellularFormat , forKey : " cell_format " ) } }
@ Published var cellularBitRate : String { didSet { UserDefaults . standard . set ( cellularBitRate , forKey : " cell_bitrate " ) } }
// C a c h e / D o w n l o a d s
@ Published var cacheFormat : String { didSet { UserDefaults . standard . set ( cacheFormat , forKey : " cache_format " ) } }
@ Published var cacheBitRate : String { didSet { UserDefaults . standard . set ( cacheBitRate , forKey : " cache_bitrate " ) } }
// S c r o b b l i n g
@ Published var scrobbleEnabled : Bool { didSet { UserDefaults . standard . set ( scrobbleEnabled , forKey : " scrobble_enabled " ) } }
// / F o r m a t s s u p p o r t e d b y N a v i d r o m e ' s s t r e a m e n d p o i n t
static let formatOptions : [ ( value : String , label : String ) ] = [
( " raw " , " Raw/Original " ) ,
( " mp3 " , " MP3 " ) ,
( " opus " , " Opus " ) ,
( " aac " , " AAC " ) ,
( " ogg " , " OGG Vorbis " ) ,
]
// / B i t r a t e o p t i o n s f o r t r a n s c o d e d f o r m a t s
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 " ) ,
]
// / A c t i v e f o r m a t b a s e d o n c u r r e n t c o n n e c t i o n
var format : String ? {
let fmt = isOnCellular ? cellularFormat : wifiFormat
return fmt = = " raw " ? nil : fmt // n i l = n o f o r m a t p a r a m = o r i g i n a l
}
// / A c t i v e b i t r a t e b a s e d o n c u r r e n t c o n n e c t i o n
var maxBitRate : Int ? {
let br = isOnCellular ? cellularBitRate : wifiBitRate
if br = = " 0 " { return nil }
return Int ( br )
}
// / F o r m a t f o r d o w n l o a d s / c a c h e
var downloadFormat : String ? {
return cacheFormat = = " raw " ? nil : cacheFormat
}
var downloadBitRate : Int ? {
if cacheBitRate = = " 0 " { return nil }
return Int ( cacheBitRate )
}
// / S u m m a r y s t r i n g f o r c u r r e n t W i F i c o n f i g
var wifiSummary : String {
if wifiFormat = = " raw " { return " Raw/Original " }
let br = wifiBitRate = = " 0 " ? " No limit " : " \( wifiBitRate ) kbps "
return " \( wifiFormat . uppercased ( ) ) · \( br ) "
}
// / S u m m a r y s t r i n g f o r c u r r e n t C e l l u l a r c o n f i g
var cellularSummary : String {
if cellularFormat = = " raw " { return " Raw/Original " }
let br = cellularBitRate = = " 0 " ? " No limit " : " \( cellularBitRate ) kbps "
return " \( cellularFormat . uppercased ( ) ) · \( br ) "
}
// / S u m m a r y s t r i n g f o r c a c h e c o n f i g
var cacheSummary : String {
if cacheFormat = = " raw " { return " Raw/Original " }
let br = cacheBitRate = = " 0 " ? " No limit " : " \( cacheBitRate ) kbps "
return " \( cacheFormat . uppercased ( ) ) · \( br ) "
}
// L e g a c y c o m p a t
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: - S e t t i n g s V i e w
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
2026-03-28 13:49:47 -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
2026-03-28 13:49:47 -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 {
// A c t i v e S e r v e r
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 )
}
}
}
}
2026-03-28 13:49:47 -07:00
}
// W i F i S t r e a m i n g
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. " )
}
}
// C e l l u l a r S t r e a m i n g
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. " )
}
// C a c h e / D o w n l o a d s
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. " )
}
// W a t c h
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. " )
}
// S c r o b b l i n g
Section ( " Activity " ) {
Toggle ( " Scrobble Plays " , isOn : $ quality . scrobbleEnabled )
. tint ( accentPink )
}
2026-04-05 08:23:07 -07:00
// V i s u a l i z e r & S m a r t D J
Section ( " Audio & Visualizer " ) {
NavigationLink {
SmartDJVisualizerSettingsView ( )
} label : {
2026-04-10 07:41:31 -07:00
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 )
2026-04-10 07:41:31 -07:00
. foregroundColor ( . gray )
2026-03-28 13:49:47 -07:00
Image ( systemName : " chevron.right " )
. font ( . system ( size : 12 ) )
. foregroundColor ( . gray )
}
}
}
// S t o r a g e
Section {
Button ( action : {
ImageCache . shared . clearAll ( )
cacheSizeText = " 0 MB "
} ) {
HStack {
2026-04-05 08:23:07 -07:00
Text ( " Clear Image Cache " ) . foregroundColor ( . white )
2026-03-28 13:49:47 -07:00
Spacer ( )
2026-04-05 08:23:07 -07:00
Text ( cacheSizeText ) . foregroundColor ( . gray )
2026-03-28 13:49:47 -07:00
}
}
2026-04-05 08:23:07 -07:00
Button ( action : { LibraryCache . shared . clearAll ( ) } ) {
2026-03-28 13:49:47 -07:00
HStack {
2026-04-05 08:23:07 -07:00
Text ( " Clear Library Cache " ) . foregroundColor ( . white )
2026-03-28 13:49:47 -07:00
Spacer ( )
}
}
2026-04-05 08:23:07 -07:00
} header : { Text ( " Storage " ) }
2026-03-28 13:49:47 -07:00
// A b o u t
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 )
}
}
// L o g o u t
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
2026-03-28 13:49:47 -07:00
}
}
}
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: - S m a r t D J & V i s u a l i z e r S e t t i n g s
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 {
// S m a r t D J
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. " )
}
// C o m b i n e d A n a l y s i s
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. " )
}
// C a c h e m a n a g e m e n t
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 " ) }
}
2026-04-10 07:41:31 -07:00
. 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 ) {
2026-03-28 13:49:47 -07:00
guard ! isAnalyzing else { return }
isAnalyzing = true
analysisProgress = " Scanning... "
2026-04-05 08:23:07 -07:00
2026-03-28 13:49:47 -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
2026-03-28 13:49:47 -07:00
let fps = VisualizerSettings . shared . effectiveFPS
let cutoff = VisualizerSettings . shared . frequencyCutoff
2026-04-05 08:23:07 -07:00
2026-03-28 13:49:47 -07:00
Task {
let storage = VisualizerStorageManager . shared
if force {
await storage . clearCache ( )
2026-04-05 08:23:07 -07:00
SmartDJCache . shared . clearAll ( )
2026-03-28 13:49:47 -07:00
}
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-03-28 13:49:47 -07:00
}
}
}
2026-04-05 08:23:07 -07:00
2026-03-28 13:49:47 -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 = " " }
2026-03-28 13:49:47 -07:00
return
}
2026-04-05 08:23:07 -07:00
2026-03-28 13:49:47 -07:00
for ( idx , item ) in toAnalyze . enumerated ( ) {
2026-04-05 08:23:07 -07:00
await MainActor . run { analysisProgress = " \( idx + 1 ) / \( toAnalyze . count ) " }
2026-03-28 13:49:47 -07:00
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 )
}
2026-03-28 13:49:47 -07:00
} catch {
2026-04-05 08:23:07 -07:00
DebugLogger . shared . log ( " Analysis failed: \( item . songId ) — \( error . localizedDescription ) " , category : " FFT " )
2026-03-28 13:49:47 -07:00
}
}
2026-04-05 08:23:07 -07:00
2026-03-28 13:49:47 -07:00
await MainActor . run {
2026-04-05 08:23:07 -07:00
analysisProgress = " Done — \( toAnalyze . count ) songs "
2026-03-28 13:49:47 -07:00
isAnalyzing = false
2026-04-05 08:23:07 -07:00
smartDJCacheCount = SmartDJCache . shared . cachedCount
2026-03-28 13:49:47 -07:00
}
2026-04-05 08:23:07 -07:00
Task { await refreshVisCacheText ( ) }
2026-03-28 13:49:47 -07:00
}
}
}
// MARK: - M a n a g e S e r v e r s V i e w
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: - L i b r a r y C o n f l i c t s U I
// A p p e n d e d h e r e s o n o n e w f i l e i s n e e d e d — a v o i d s X c o d e p r o j e c t r e g i s t r a t i o n i s s u e s .
// = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
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 = A c t i v e , 1 = I g n o r e d
@ 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
// T a b p i c k e r
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 {
// ─ ─ A c t i v e t a b ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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
// ─ ─ I g n o r e d t a b ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
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: - C o n f l i c t R o w
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: - H e l p e r s
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