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