NavidromeApp/watchOS/App/WatchOfflineStore.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

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