235 lines
9 KiB
Swift
235 lines
9 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
|
||
private var pendingSongs: [String: Song] = [:] // taskId → Song
|
||
private var taskToSongId: [Int: String] = [:] // URLSession task ID → songId
|
||
|
||
private 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()
|
||
}
|
||
|
||
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 } // already downloading
|
||
|
||
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
|
||
}
|
||
|
||
pendingSongs[song.id] = song
|
||
|
||
let task = backgroundSession.downloadTask(with: url)
|
||
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) {
|
||
guard let songId = taskToSongId[downloadTask.taskIdentifier] 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) {
|
||
guard let songId = taskToSongId[downloadTask.taskIdentifier],
|
||
let song = pendingSongs[songId] 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.pendingSongs.removeValue(forKey: songId)
|
||
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
|
||
|
||
// Haptic feedback
|
||
WKInterfaceDevice.current().play(.success)
|
||
|
||
// Notify iOS
|
||
self.notifyiOS(songId: songId, downloaded: true)
|
||
|
||
// Clear completion indicator after 2s
|
||
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.pendingSongs.removeValue(forKey: songId)
|
||
}
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||
guard let error, let songId = taskToSongId[task.taskIdentifier] else { return }
|
||
print("[Watch] Download error for \(songId): \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
self.downloadProgress.removeValue(forKey: songId)
|
||
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 {
|
||
let size = songs.reduce(Int64(0)) { $0 + $1.fileSize }
|
||
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||
}
|
||
}
|