NavidromeApp/watchOS/App/WatchOfflineStore.swift

235 lines
9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.01.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)
}
}