279 lines
12 KiB
Swift
279 lines
12 KiB
Swift
import Foundation
|
||
import Combine
|
||
import WatchKit
|
||
import WatchConnectivity
|
||
|
||
/// Manages offline songs stored directly on the Apple Watch.
|
||
/// Downloads use URLSession background configuration for reliability.
|
||
/// Progress tracked per-song; haptic feedback on completion.
|
||
class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate {
|
||
|
||
static let shared = WatchOfflineStore()
|
||
|
||
@Published var songs: [WatchOfflineSong] = []
|
||
@Published var totalSize: Int64 = 0
|
||
@Published var downloadProgress: [String: Double] = [:] // songId → 0.0–1.0
|
||
@Published var downloadComplete: [String: Bool] = [:] // songId → true when done
|
||
|
||
struct WatchOfflineSong: Codable, Identifiable {
|
||
let id: String
|
||
let song: Song
|
||
let localPath: String
|
||
let fileSize: Int64
|
||
let dateAdded: Date
|
||
}
|
||
|
||
private let catalogKey = "watch_offline_songs"
|
||
private let fileManager = FileManager.default
|
||
// Both pendingSongs and taskToSongId are accessed from the URLSession delegate queue
|
||
// (background) AND from the main thread. Protect them with a dedicated serial queue.
|
||
private let storeQueue = DispatchQueue(label: "com.navidromeplayer.watch.offlinestore")
|
||
private var pendingSongs: [String: Song] = [:] // access via storeQueue only
|
||
private var taskToSongId: [Int: String] = [:] // access via storeQueue only
|
||
|
||
// Internal so NavidromeWatchDelegate can touch it to re-attach the delegate
|
||
// after a background wake (WKURLSessionRefreshBackgroundTask handler).
|
||
lazy var backgroundSession: URLSession = {
|
||
let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download")
|
||
config.isDiscretionary = false
|
||
config.sessionSendsLaunchEvents = true
|
||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||
}()
|
||
|
||
var musicDirectory: URL {
|
||
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||
let dir = docs.appendingPathComponent("OfflineMusic", isDirectory: true)
|
||
if !fileManager.fileExists(atPath: dir.path) {
|
||
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
override init() {
|
||
super.init()
|
||
loadCatalog()
|
||
}
|
||
|
||
// MARK: - Song Management
|
||
|
||
func addSong(_ song: Song, localPath: String) {
|
||
guard !songs.contains(where: { $0.id == song.id }) else { return }
|
||
let fileSize: Int64 = (try? fileManager.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
|
||
let offlineSong = WatchOfflineSong(id: song.id, song: song, localPath: localPath, fileSize: fileSize, dateAdded: Date())
|
||
songs.append(offlineSong)
|
||
totalSize += fileSize
|
||
saveCatalog()
|
||
}
|
||
|
||
/// Update the embedded Song metadata for an offline song in-place.
|
||
/// Called when iOS notifies the watch of tag changes via WatchConnectivity.
|
||
func updateSongMetadata(id: String, title: String?, artist: String?,
|
||
album: String?, albumArtist: String?) {
|
||
guard let idx = songs.firstIndex(where: { $0.id == id }) else { return }
|
||
let old = songs[idx]
|
||
let updated = Song(
|
||
id: old.song.id,
|
||
parent: old.song.parent, isDir: old.song.isDir,
|
||
title: title ?? old.song.title,
|
||
album: album ?? old.song.album,
|
||
artist: artist ?? old.song.artist,
|
||
albumArtist: albumArtist ?? old.song.albumArtist,
|
||
track: old.song.track, year: old.song.year, genre: old.song.genre,
|
||
coverArt: old.song.coverArt,
|
||
size: old.song.size, contentType: old.song.contentType,
|
||
suffix: old.song.suffix,
|
||
transcodedContentType: old.song.transcodedContentType,
|
||
transcodedSuffix: old.song.transcodedSuffix,
|
||
duration: old.song.duration, bitRate: old.song.bitRate,
|
||
path: old.song.path, playCount: old.song.playCount,
|
||
discNumber: old.song.discNumber, created: old.song.created,
|
||
albumId: old.song.albumId, artistId: old.song.artistId,
|
||
type: old.song.type, starred: old.song.starred,
|
||
bpm: old.song.bpm, musicBrainzId: old.song.musicBrainzId
|
||
)
|
||
songs[idx] = WatchOfflineSong(
|
||
id: old.id, song: updated,
|
||
localPath: old.localPath, fileSize: old.fileSize, dateAdded: old.dateAdded
|
||
)
|
||
saveCatalog()
|
||
print("[Watch] Updated metadata for \(id): title=\(title ?? "-")")
|
||
}
|
||
|
||
func removeSong(_ songId: String) {
|
||
guard let idx = songs.firstIndex(where: { $0.id == songId }) else { return }
|
||
let song = songs[idx]
|
||
let filename = (song.localPath as NSString).lastPathComponent
|
||
let url = musicDirectory.appendingPathComponent(filename)
|
||
try? fileManager.removeItem(at: url)
|
||
totalSize -= song.fileSize
|
||
songs.remove(at: idx)
|
||
saveCatalog()
|
||
notifyiOS(songId: songId, downloaded: false)
|
||
}
|
||
|
||
func removeAll() {
|
||
for song in songs {
|
||
let filename = (song.localPath as NSString).lastPathComponent
|
||
try? fileManager.removeItem(at: musicDirectory.appendingPathComponent(filename))
|
||
}
|
||
songs.removeAll()
|
||
totalSize = 0
|
||
saveCatalog()
|
||
}
|
||
|
||
func localURL(for songId: String) -> URL? {
|
||
guard let song = songs.first(where: { $0.id == songId }) else { return nil }
|
||
let filename = (song.localPath as NSString).lastPathComponent
|
||
let url = musicDirectory.appendingPathComponent(filename)
|
||
return fileManager.fileExists(atPath: url.path) ? url : nil
|
||
}
|
||
|
||
func isSongAvailable(_ songId: String) -> Bool { localURL(for: songId) != nil }
|
||
|
||
// MARK: - Direct Server Download (Background URLSession)
|
||
|
||
func downloadFromServer(song: Song) {
|
||
guard !songs.contains(where: { $0.id == song.id }) else { return }
|
||
guard downloadProgress[song.id] == nil else { return }
|
||
|
||
guard let url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 128) else {
|
||
print("[Watch] No stream URL for \(song.id)")
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
self.downloadProgress[song.id] = 0.0
|
||
self.downloadComplete[song.id] = false
|
||
}
|
||
|
||
let task = backgroundSession.downloadTask(with: url)
|
||
// Protect dictionary mutations with storeQueue — delegate reads on background queue
|
||
storeQueue.sync {
|
||
self.pendingSongs[song.id] = song
|
||
self.taskToSongId[task.taskIdentifier] = song.id
|
||
}
|
||
task.resume()
|
||
print("[Watch] Download started: \(song.title)")
|
||
}
|
||
|
||
func downloadAlbum(_ album: AlbumWithSongs) {
|
||
guard let albumSongs = album.song else { return }
|
||
for song in albumSongs {
|
||
downloadFromServer(song: song)
|
||
}
|
||
}
|
||
|
||
// MARK: - URLSession Download Delegate
|
||
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||
let songId: String? = storeQueue.sync { taskToSongId[downloadTask.taskIdentifier] }
|
||
guard let songId else { return }
|
||
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||
DispatchQueue.main.async {
|
||
self.downloadProgress[songId] = progress
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||
let (songId, song): (String?, Song?) = storeQueue.sync {
|
||
let sid = taskToSongId[downloadTask.taskIdentifier]
|
||
return (sid, sid.flatMap { pendingSongs[$0] })
|
||
}
|
||
guard let songId, let song else { return }
|
||
|
||
let filename = "\(songId).mp3"
|
||
let destURL = musicDirectory.appendingPathComponent(filename)
|
||
|
||
do {
|
||
if fileManager.fileExists(atPath: destURL.path) {
|
||
try fileManager.removeItem(at: destURL)
|
||
}
|
||
try fileManager.moveItem(at: location, to: destURL)
|
||
|
||
DispatchQueue.main.async {
|
||
self.addSong(song, localPath: destURL.path)
|
||
self.downloadProgress.removeValue(forKey: songId)
|
||
self.downloadComplete[songId] = true
|
||
self.storeQueue.async {
|
||
self.pendingSongs.removeValue(forKey: songId)
|
||
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
|
||
}
|
||
WKInterfaceDevice.current().play(.success)
|
||
self.notifyiOS(songId: songId, downloaded: true)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||
self.downloadComplete.removeValue(forKey: songId)
|
||
}
|
||
}
|
||
print("[Watch] Download complete: \(song.title)")
|
||
} catch {
|
||
print("[Watch] Save failed: \(error)")
|
||
DispatchQueue.main.async {
|
||
self.downloadProgress.removeValue(forKey: songId)
|
||
self.storeQueue.async {
|
||
self.pendingSongs.removeValue(forKey: songId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||
guard let error else { return }
|
||
let songId: String? = storeQueue.sync { taskToSongId[task.taskIdentifier] }
|
||
guard let songId else { return }
|
||
print("[Watch] Download error for \(songId): \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
self.downloadProgress.removeValue(forKey: songId)
|
||
self.storeQueue.async {
|
||
self.pendingSongs.removeValue(forKey: songId)
|
||
self.taskToSongId.removeValue(forKey: task.taskIdentifier)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - iOS Notification
|
||
|
||
private func notifyiOS(songId: String, downloaded: Bool) {
|
||
guard WCSession.default.isReachable else { return }
|
||
WCSession.default.sendMessage([
|
||
"type": "watchDownloadStatus",
|
||
"songId": songId,
|
||
"downloaded": downloaded
|
||
], replyHandler: nil, errorHandler: nil)
|
||
}
|
||
|
||
// MARK: - Grouped Access
|
||
|
||
var songsByAlbum: [String: [WatchOfflineSong]] {
|
||
Dictionary(grouping: songs) { $0.song.album ?? "Unknown Album" }
|
||
}
|
||
|
||
var formattedSize: String {
|
||
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||
}
|
||
|
||
// MARK: - Persistence
|
||
|
||
private func saveCatalog() {
|
||
if let data = try? JSONEncoder().encode(songs) {
|
||
UserDefaults.standard.set(data, forKey: catalogKey)
|
||
}
|
||
}
|
||
|
||
private func loadCatalog() {
|
||
if let data = UserDefaults.standard.data(forKey: catalogKey),
|
||
let decoded = try? JSONDecoder().decode([WatchOfflineSong].self, from: data) {
|
||
songs = decoded.filter { song in
|
||
let filename = (song.localPath as NSString).lastPathComponent
|
||
let url = musicDirectory.appendingPathComponent(filename)
|
||
return fileManager.fileExists(atPath: url.path)
|
||
}
|
||
totalSize = songs.reduce(0) { $0 + $1.fileSize }
|
||
}
|
||
}
|
||
|
||
// MARK: - Cache Management
|
||
|
||
var cacheSize: String {
|
||
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
|
||
}
|
||
}
|