180 lines
5.9 KiB
Swift
180 lines
5.9 KiB
Swift
|
|
import Foundation
|
||
|
|
import Combine
|
||
|
|
|
||
|
|
/// Manages offline songs stored directly on the Apple Watch
|
||
|
|
class WatchOfflineStore: ObservableObject {
|
||
|
|
|
||
|
|
static let shared = WatchOfflineStore()
|
||
|
|
|
||
|
|
@Published var songs: [WatchOfflineSong] = []
|
||
|
|
@Published var totalSize: Int64 = 0
|
||
|
|
@Published var isDownloading = false
|
||
|
|
@Published var downloadProgress: [String: Double] = [:]
|
||
|
|
|
||
|
|
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 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
|
||
|
|
}
|
||
|
|
|
||
|
|
private init() {
|
||
|
|
loadCatalog()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Song Management
|
||
|
|
|
||
|
|
func addSong(_ song: Song, localPath: String) {
|
||
|
|
guard !songs.contains(where: { $0.id == song.id }) else { return }
|
||
|
|
|
||
|
|
let fileSize: Int64
|
||
|
|
if let attrs = try? fileManager.attributesOfItem(atPath: localPath),
|
||
|
|
let size = attrs[.size] as? Int64 {
|
||
|
|
fileSize = size
|
||
|
|
} else {
|
||
|
|
fileSize = 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]
|
||
|
|
// Reconstruct path from filename
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
|
||
|
|
func removeAll() {
|
||
|
|
for song in songs {
|
||
|
|
try? fileManager.removeItem(atPath: song.localPath)
|
||
|
|
}
|
||
|
|
songs.removeAll()
|
||
|
|
totalSize = 0
|
||
|
|
saveCatalog()
|
||
|
|
}
|
||
|
|
|
||
|
|
func localURL(for songId: String) -> URL? {
|
||
|
|
guard let song = songs.first(where: { $0.id == songId }) else { return nil }
|
||
|
|
// Reconstruct path from filename to handle sandbox UUID changes
|
||
|
|
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 {
|
||
|
|
return localURL(for: songId) != nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Download directly from server (WiFi)
|
||
|
|
|
||
|
|
func downloadFromServer(song: Song) async {
|
||
|
|
guard !songs.contains(where: { $0.id == song.id }) else { return }
|
||
|
|
|
||
|
|
await MainActor.run {
|
||
|
|
isDownloading = true
|
||
|
|
downloadProgress[song.id] = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
do {
|
||
|
|
// WatchSessionManager.downloadSong already transcodes at 192kbps MP3
|
||
|
|
let (data, _) = try await WatchSessionManager.shared.downloadSong(id: song.id)
|
||
|
|
|
||
|
|
// Always mp3 since watch downloads are transcoded
|
||
|
|
let filename = "\(song.id).mp3"
|
||
|
|
let localURL = musicDirectory.appendingPathComponent(filename)
|
||
|
|
|
||
|
|
try data.write(to: localURL)
|
||
|
|
|
||
|
|
await MainActor.run {
|
||
|
|
addSong(song, localPath: localURL.path)
|
||
|
|
downloadProgress.removeValue(forKey: song.id)
|
||
|
|
isDownloading = downloadProgress.isEmpty ? false : true
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
await MainActor.run {
|
||
|
|
downloadProgress.removeValue(forKey: song.id)
|
||
|
|
isDownloading = downloadProgress.isEmpty ? false : true
|
||
|
|
}
|
||
|
|
print("Watch download failed: \(error)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func downloadAlbum(_ album: AlbumWithSongs) async {
|
||
|
|
guard let albumSongs = album.song else { return }
|
||
|
|
for song in albumSongs {
|
||
|
|
await downloadFromServer(song: song)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Update catalog from phone sync
|
||
|
|
|
||
|
|
func updateCatalog(_ phoneSongs: [Song]) {
|
||
|
|
// This updates the expected catalog; actual files arrive via file transfer
|
||
|
|
// We can show which songs are expected vs available
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Grouped Access
|
||
|
|
|
||
|
|
var songsByAlbum: [String: [WatchOfflineSong]] {
|
||
|
|
Dictionary(grouping: songs) { $0.song.album ?? "Unknown Album" }
|
||
|
|
}
|
||
|
|
|
||
|
|
var songsByArtist: [String: [WatchOfflineSong]] {
|
||
|
|
Dictionary(grouping: songs) { $0.song.artist ?? "Unknown Artist" }
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
// Reconstruct paths from filenames to handle sandbox UUID changes
|
||
|
|
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 }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|