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