Active tab — all unignored issues, swipe left → “Ignore” (grey) • Ignored tab — all ignored issues shown dimmed, swipe left → “Restore” (pink) to bring them back • Fix buttons hidden on ignored issues • Ignored IDs persisted in @AppStorage so they survive app restarts • Tab labels show live counts: Active (1) | Ignored (31)
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)
|
|
}
|
|
}
|
|
}
|