Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
256 lines
9.4 KiB
Swift
256 lines
9.4 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
/// Manages downloading songs for offline playback on both iOS and watchOS
|
|
class OfflineManager: ObservableObject {
|
|
|
|
static let shared = OfflineManager()
|
|
|
|
@Published var downloads: [String: DownloadState] = [:] // songId -> state
|
|
@Published var downloadedSongs: [DownloadedSong] = []
|
|
@Published var totalDownloadSize: Int64 = 0
|
|
@Published var isDownloading = false
|
|
|
|
private let fileManager = FileManager.default
|
|
private let catalogKey = "offline_catalog"
|
|
private var downloadQueue: [(Song, ServerConfig)] = []
|
|
private var isProcessingQueue = false
|
|
|
|
enum DownloadState: Equatable {
|
|
case queued
|
|
case downloading(progress: Double)
|
|
case completed(localPath: String)
|
|
case failed(error: String)
|
|
}
|
|
|
|
private init() {
|
|
loadCatalog()
|
|
}
|
|
|
|
// MARK: - Download Directory
|
|
|
|
private var downloadDirectory: URL {
|
|
let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
|
|
let dir = paths[0].appendingPathComponent("OfflineMusic", isDirectory: true)
|
|
if !fileManager.fileExists(atPath: dir.path) {
|
|
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
// Allow background access for audio playback
|
|
try? (dir as NSURL).setResourceValue(
|
|
URLFileProtection.completeUntilFirstUserAuthentication,
|
|
forKey: .fileProtectionKey
|
|
)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// MARK: - Download Songs
|
|
|
|
func downloadSong(_ song: Song, server: ServerConfig) {
|
|
guard downloads[song.id] == nil || {
|
|
if case .failed = downloads[song.id] { return true }
|
|
return false
|
|
}() else { return }
|
|
|
|
downloads[song.id] = .queued
|
|
downloadQueue.append((song, server))
|
|
processQueue()
|
|
}
|
|
|
|
func downloadAlbum(_ album: AlbumWithSongs, server: ServerConfig) {
|
|
guard let songs = album.song else { return }
|
|
for song in songs {
|
|
downloadSong(song, server: server)
|
|
}
|
|
}
|
|
|
|
func downloadPlaylist(_ playlist: PlaylistWithSongs, server: ServerConfig) {
|
|
guard let songs = playlist.entry else { return }
|
|
for song in songs {
|
|
downloadSong(song, server: server)
|
|
}
|
|
}
|
|
|
|
private func processQueue() {
|
|
guard !isProcessingQueue, !downloadQueue.isEmpty else { return }
|
|
isProcessingQueue = true
|
|
isDownloading = true
|
|
|
|
Task {
|
|
while !downloadQueue.isEmpty {
|
|
let (song, server) = downloadQueue.removeFirst()
|
|
await performDownload(song: song, server: server)
|
|
}
|
|
await MainActor.run {
|
|
self.isProcessingQueue = false
|
|
self.isDownloading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performDownload(song: Song, server: ServerConfig) async {
|
|
await MainActor.run {
|
|
downloads[song.id] = .downloading(progress: 0)
|
|
}
|
|
|
|
let client = SubsonicClient()
|
|
client.currentServer = server
|
|
|
|
do {
|
|
let data: Data
|
|
let ext: String
|
|
|
|
#if os(iOS)
|
|
let quality = StreamingQuality.shared
|
|
if quality.cacheFormat != "raw" {
|
|
// Stream with transcoding using cache format settings
|
|
guard let streamURL = client.streamURL(
|
|
songId: song.id,
|
|
format: quality.cacheFormat,
|
|
maxBitRate: quality.downloadBitRate
|
|
) else {
|
|
throw NSError(domain: "Download", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not build stream URL"])
|
|
}
|
|
let (streamData, _) = try await URLSession.shared.data(from: streamURL)
|
|
data = streamData
|
|
ext = quality.cacheFormat == "ogg" ? "ogg" : quality.cacheFormat
|
|
} else {
|
|
let (rawData, _) = try await client.downloadSong(id: song.id)
|
|
data = rawData
|
|
ext = song.suffix ?? "mp3"
|
|
}
|
|
#else
|
|
// watchOS always downloads original
|
|
let (rawData, _) = try await client.downloadSong(id: song.id)
|
|
data = rawData
|
|
ext = song.suffix ?? "mp3"
|
|
#endif
|
|
|
|
let filename = "\(song.id).\(ext)"
|
|
let localURL = downloadDirectory.appendingPathComponent(filename)
|
|
|
|
try data.write(to: localURL)
|
|
|
|
// Also download cover art if available
|
|
if let coverArtId = song.coverArt {
|
|
if let artURL = client.coverArtURL(id: coverArtId, size: 600) {
|
|
if let (artData, _) = try? await URLSession.shared.data(from: artURL) {
|
|
let artPath = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
|
|
try? artData.write(to: artPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
let downloaded = DownloadedSong(
|
|
id: song.id,
|
|
serverId: server.id,
|
|
song: song,
|
|
localPath: localURL.path,
|
|
downloadDate: Date(),
|
|
fileSize: Int64(data.count)
|
|
)
|
|
|
|
await MainActor.run {
|
|
self.downloads[song.id] = .completed(localPath: localURL.path)
|
|
self.downloadedSongs.append(downloaded)
|
|
self.totalDownloadSize += Int64(data.count)
|
|
self.saveCatalog()
|
|
}
|
|
|
|
// Auto-analyze for visualizer cache
|
|
#if os(iOS)
|
|
Task.detached(priority: .background) {
|
|
let storage = VisualizerStorageManager.shared
|
|
let hasCache = await storage.hasCache(for: song.id)
|
|
if !hasCache {
|
|
do {
|
|
let frames = try await OfflineAudioAnalyzer.shared.analyze(url: localURL)
|
|
try await storage.saveCache(frames: frames, for: song.id)
|
|
print("[Vis] Auto-analyzed: \(song.title)")
|
|
} catch {
|
|
print("[Vis] Auto-analyze failed for \(song.title): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
} catch {
|
|
await MainActor.run {
|
|
self.downloads[song.id] = .failed(error: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Remove Downloads
|
|
|
|
func removeSong(_ songId: String) {
|
|
if let idx = downloadedSongs.firstIndex(where: { $0.id == songId }) {
|
|
let song = downloadedSongs[idx]
|
|
let filename = (song.localPath as NSString).lastPathComponent
|
|
let url = downloadDirectory.appendingPathComponent(filename)
|
|
try? fileManager.removeItem(at: url)
|
|
totalDownloadSize -= song.fileSize
|
|
downloadedSongs.remove(at: idx)
|
|
downloads.removeValue(forKey: songId)
|
|
saveCatalog()
|
|
}
|
|
}
|
|
|
|
func removeAll() {
|
|
for song in downloadedSongs {
|
|
let filename = (song.localPath as NSString).lastPathComponent
|
|
let url = downloadDirectory.appendingPathComponent(filename)
|
|
try? fileManager.removeItem(at: url)
|
|
}
|
|
downloadedSongs.removeAll()
|
|
downloads.removeAll()
|
|
totalDownloadSize = 0
|
|
saveCatalog()
|
|
}
|
|
|
|
func isSongDownloaded(_ songId: String) -> Bool {
|
|
return downloadedSongs.contains { $0.id == songId }
|
|
}
|
|
|
|
func localURL(for songId: String) -> URL? {
|
|
guard let song = downloadedSongs.first(where: { $0.id == songId }) else { return nil }
|
|
// Reconstruct from filename — stored absolute paths break when sandbox UUID changes
|
|
let filename = (song.localPath as NSString).lastPathComponent
|
|
let url = downloadDirectory.appendingPathComponent(filename)
|
|
return fileManager.fileExists(atPath: url.path) ? url : nil
|
|
}
|
|
|
|
func coverArtLocalURL(for coverArtId: String) -> URL? {
|
|
let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
|
|
return fileManager.fileExists(atPath: path.path) ? path : nil
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveCatalog() {
|
|
if let data = try? JSONEncoder().encode(downloadedSongs) {
|
|
UserDefaults.standard.set(data, forKey: catalogKey)
|
|
}
|
|
}
|
|
|
|
private func loadCatalog() {
|
|
if let data = UserDefaults.standard.data(forKey: catalogKey),
|
|
let decoded = try? JSONDecoder().decode([DownloadedSong].self, from: data) {
|
|
downloadedSongs = decoded
|
|
totalDownloadSize = decoded.reduce(0) { $0 + $1.fileSize }
|
|
for song in decoded {
|
|
// Reconstruct path from filename — absolute paths break across app launches
|
|
let filename = (song.localPath as NSString).lastPathComponent
|
|
let url = downloadDirectory.appendingPathComponent(filename)
|
|
if fileManager.fileExists(atPath: url.path) {
|
|
downloads[song.id] = .completed(localPath: url.path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Storage Info
|
|
|
|
var formattedSize: String {
|
|
ByteCountFormatter.string(fromByteCount: totalDownloadSize, countStyle: .file)
|
|
}
|
|
}
|