NavidromeApp/watchOS/App/WatchSessionManager.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

362 lines
14 KiB
Swift

import Foundation
import WatchConnectivity
import Combine
/// watchOS-side WatchConnectivity session manager
class WatchSessionManager: NSObject, ObservableObject {
static let shared = WatchSessionManager()
@Published var servers: [ServerConfig] = []
@Published var activeServer: ServerConfig?
@Published var isPhoneReachable = false
@Published var isSyncing = false
// Now playing from iPhone
@Published var phoneNowPlaying: PhoneNowPlaying?
struct PhoneNowPlaying {
var title: String
var artist: String
var album: String
var isPlaying: Bool
var currentTime: TimeInterval
var duration: TimeInterval
var coverArtId: String?
}
private var session: WCSession?
private let client = SubsonicClient()
private override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
loadLocalServers()
}
// MARK: - Server Management
func addServer(_ server: ServerConfig) {
servers.append(server)
saveLocalServers()
}
func setActive(_ server: ServerConfig) {
activeServer = server
client.currentServer = server
}
func testConnection(_ server: ServerConfig) async -> Bool {
do {
return try await client.ping(server: server)
} catch {
return false
}
}
// MARK: - Request data from phone
func requestServersFromPhone() {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "requestServers"], replyHandler: { reply in
if let data = reply["servers"] as? Data,
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
self.servers = servers
self.activeServer = servers.first
if let first = servers.first {
self.client.currentServer = first
}
self.saveLocalServers()
}
}
}, errorHandler: { error in
print("Request servers failed: \(error)")
})
}
func sendPlayCommand() {
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
}
func sendNextCommand() {
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
}
func sendPreviousCommand() {
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
}
func requestDownload(songId: String) {
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
}
// MARK: - Direct API Access (watch can also talk to server directly)
func getAlbumList(type: String = "newest", size: Int = 20) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: type, size: size)
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
return try await client.getAlbum(id: id)
}
func getPlaylists() async throws -> [Playlist] {
return try await client.getPlaylists()
}
func getPlaylist(id: String) async throws -> PlaylistWithSongs? {
return try await client.getPlaylist(id: id)
}
func search(query: String) async throws -> SearchResult3? {
return try await client.search3(query: query)
}
func streamURL(songId: String, maxBitRate: Int? = 192) -> URL? {
return client.streamURL(songId: songId, format: maxBitRate != nil ? "mp3" : nil, maxBitRate: maxBitRate)
}
func coverArtURL(id: String, size: Int = 100) -> URL? {
return client.coverArtURL(id: id, size: size)
}
/// Downloads a song at 192kbps MP3 for watch storage efficiency
func downloadSong(id: String) async throws -> (Data, URLResponse) {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "format", value: "mp3"),
URLQueryItem(name: "maxBitRate", value: "192")
]
return try await client.requestData(endpoint: "stream", params: params)
}
// MARK: - Persistence
private func saveLocalServers() {
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: "watch_servers")
}
if let data = try? JSONEncoder().encode(activeServer) {
UserDefaults.standard.set(data, forKey: "watch_active_server")
}
}
private func loadLocalServers() {
if let data = UserDefaults.standard.data(forKey: "watch_servers"),
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
servers = decoded
}
if let data = UserDefaults.standard.data(forKey: "watch_active_server"),
let decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) {
activeServer = decoded
client.currentServer = decoded
} else if let first = servers.first {
activeServer = first
client.currentServer = first
}
}
}
// MARK: - WCSessionDelegate
extension WatchSessionManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
DispatchQueue.main.async {
self.isPhoneReachable = session.isReachable
}
if activationState == .activated {
// Check existing application context (already sent before watch app opened)
let existingContext = session.receivedApplicationContext
if let data = existingContext["servers"] as? Data,
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
if self.servers.isEmpty {
self.servers = decoded
if self.activeServer == nil, let first = decoded.first {
self.activeServer = first
self.client.currentServer = first
}
self.saveLocalServers()
}
}
}
// Also proactively request from phone if reachable
if session.isReachable && self.servers.isEmpty {
self.requestServersFromPhone()
}
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isPhoneReachable = session.isReachable
}
}
// Receive application context (server configs from iPhone)
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
if let data = applicationContext["servers"] as? Data,
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
self.servers = servers
if self.activeServer == nil, let first = servers.first {
self.activeServer = first
self.client.currentServer = first
}
self.saveLocalServers()
}
}
if let catalogData = applicationContext["offlineCatalog"] as? Data,
let songs = try? JSONDecoder().decode([Song].self, from: catalogData) {
DispatchQueue.main.async {
// Update offline store with catalog
WatchOfflineStore.shared.updateCatalog(songs)
}
}
}
// Receive messages (now playing info from iPhone)
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let type = message["type"] as? String else { return }
if type == "nowPlaying" {
DispatchQueue.main.async {
self.phoneNowPlaying = PhoneNowPlaying(
title: message["title"] as? String ?? "",
artist: message["artist"] as? String ?? "",
album: message["album"] as? String ?? "",
isPlaying: message["isPlaying"] as? Bool ?? false,
currentTime: message["currentTime"] as? TimeInterval ?? 0,
duration: message["duration"] as? TimeInterval ?? 0,
coverArtId: message["coverArtId"] as? String
)
}
}
}
// Messages that need a reply
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
guard let type = message["type"] as? String else {
replyHandler(["error": "no type"])
return
}
switch type {
case "requestSongList":
let store = WatchOfflineStore.shared
struct WatchSongInfoEnc: Codable {
let id: String; let title: String; let artist: String; let album: String; let fileSize: Int64
}
let encoded = store.songs.map {
WatchSongInfoEnc(id: $0.id, title: $0.song.title, artist: $0.song.artist ?? "Unknown", album: $0.song.album ?? "Unknown", fileSize: $0.fileSize)
}
if let data = try? JSONEncoder().encode(encoded) {
replyHandler(["songs": data])
} else {
replyHandler(["songs": Data()])
}
print("[Watch] Sent song list: \(store.songs.count) songs")
case "deleteSong":
if let songId = message["songId"] as? String {
DispatchQueue.main.async {
WatchOfflineStore.shared.removeSong(songId)
}
print("[Watch] Deleted song: \(songId)")
replyHandler(["ok": true])
} else {
replyHandler(["error": "no songId"])
}
case "deleteAllSongs":
DispatchQueue.main.async {
WatchOfflineStore.shared.removeAll()
}
print("[Watch] Deleted all songs")
replyHandler(["ok": true])
default:
replyHandler(["error": "unknown type"])
}
}
// Receive user info (wake signals, queued data from phone)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if let type = userInfo["type"] as? String {
print("[Watch] Received userInfo: \(type)")
if type == "wake" {
// Phone pinged us we're alive now and ready to receive files
print("[Watch] Wake signal received — app is active")
}
}
}
// Receive files (downloaded songs from iPhone)
func session(_ session: WCSession, didReceive file: WCSessionFile) {
let metadata = file.metadata ?? [:]
print("[Watch] Received file transfer. Keys: \(metadata.keys.sorted())")
// Support both "songId" and "id" keys for compatibility
guard let songId = (metadata["songId"] as? String) ?? (metadata["id"] as? String),
let suffix = metadata["suffix"] as? String else {
print("[Watch] ERROR: Missing songId or suffix in metadata: \(metadata)")
return
}
print("[Watch] Processing song: \(songId), suffix: \(suffix)")
// Move file to permanent location
let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let musicDir = documentsDir.appendingPathComponent("OfflineMusic", isDirectory: true)
try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true)
let destURL = musicDir.appendingPathComponent("\(songId).\(suffix)")
do {
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.moveItem(at: file.fileURL, to: destURL)
let fileSize = (try? FileManager.default.attributesOfItem(atPath: destURL.path)[.size] as? Int64) ?? 0
print("[Watch] Saved: \(destURL.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)))")
// Support both key naming conventions
let coverArtId = (metadata["coverArtId"] as? String) ?? (metadata["coverArt"] as? String)
let song = Song(
id: songId,
parent: nil, isDir: false,
title: metadata["title"] as? String ?? "Unknown",
album: metadata["album"] as? String,
artist: metadata["artist"] as? String,
track: nil, year: nil, genre: nil,
coverArt: coverArtId,
size: nil, contentType: nil,
suffix: suffix,
transcodedContentType: nil, transcodedSuffix: nil,
duration: metadata["duration"] as? Int,
bitRate: nil, path: nil, playCount: nil,
discNumber: nil, created: nil,
albumId: nil, artistId: nil,
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
)
DispatchQueue.main.async {
WatchOfflineStore.shared.addSong(song, localPath: destURL.path)
print("[Watch] Added to offline store: \(song.title)")
}
} catch {
print("[Watch] ERROR saving file: \(error.localizedDescription)")
}
}
}