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 } } } }