import Foundation import WatchConnectivity import Combine /// Bridges data between iOS and watchOS apps class WatchConnectivityManager: NSObject, ObservableObject { static let shared = WatchConnectivityManager() @Published var isReachable = false @Published var isWatchPaired = false @Published var receivedServers: [ServerConfig] = [] @Published var transferringIds: Set = [] @Published var transferredIds: Set = [] /// Per-song transfer progress (0.0 to 1.0) @Published var transferProgressMap: [String: Double] = [:] /// Song titles for display @Published var transferTitleMap: [String: String] = [:] private var session: WCSession? private var activeTransfers: [String: WCSessionFileTransfer] = [:] private var progressTimer: Timer? private override init() { super.init() #if os(iOS) loadWatchSongCache() #endif if WCSession.isSupported() { session = WCSession.default session?.delegate = self session?.activate() wcLog("WCSession supported, activating...") } else { wcLog("WCSession NOT supported on this device") } } private func wcLog(_ msg: String) { #if os(iOS) DebugLogger.shared.log(msg, category: "Watch") #else print("[Watch] \(msg)") #endif } // MARK: - Send message (both platforms) func sendMessage(_ message: [String: Any], replyHandler: (([String: Any]) -> Void)? = nil) { guard let session = session, session.isReachable else { wcLog("sendMessage failed: session not reachable") return } session.sendMessage(message, replyHandler: replyHandler, errorHandler: { [weak self] error in self?.wcLog("Message send error: \(error)") }) } // ========================================================================= // MARK: - iOS-Only: Sending data TO the watch // ========================================================================= #if os(iOS) func sendServersToWatch(_ servers: [ServerConfig]) { guard let session = session, session.isPaired, session.isWatchAppInstalled else { wcLog("sendServers: watch not paired/installed") return } do { let data = try JSONEncoder().encode(servers) let dict: [String: Any] = ["servers": data] try session.updateApplicationContext(dict) wcLog("Sent \(servers.count) servers to watch via applicationContext") } catch { wcLog("Failed to send servers: \(error)") } } func transferSongToWatch(fileURL: URL, metadata: [String: Any]) { guard let session = session, session.isPaired, session.isWatchAppInstalled else { wcLog("transferFile: watch not available") return } let transfer = session.transferFile(fileURL, metadata: metadata) wcLog("Started file transfer: \(transfer.file.fileURL.lastPathComponent)") } /// High-level: send a downloaded song to the watch for offline playback. /// Always transcodes to MP3 192kbps via the server's stream endpoint for fast transfer. @discardableResult func sendSongToWatch(_ song: Song) -> Bool { wcLog("sendSongToWatch: \(song.title) (id: \(song.id))") guard let session = session else { wcLog("FAIL: WCSession is nil") return false } guard session.isPaired, session.isWatchAppInstalled else { wcLog("FAIL: Watch not paired or app not installed") return false } // Check if local file is already a small MP3 — use it directly if let localURL = OfflineManager.shared.localURL(for: song.id), FileManager.default.fileExists(atPath: localURL.path) { let suffix = (song.suffix ?? "").lowercased() let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0 let isSmallMP3 = suffix == "mp3" && fileSize < 15_000_000 // < 15MB if isSmallMP3 { wcLog("Local MP3 is small enough (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file))), sending directly") return transferFile(localURL, song: song, suffix: "mp3") } } // Otherwise, download a transcoded MP3 192kbps from server wcLog("Transcoding to MP3 192kbps via server stream endpoint") DispatchQueue.main.async { self.transferringIds.insert(song.id) self.transferProgressMap[song.id] = 0.0 self.transferTitleMap[song.id] = song.title } guard let streamURL = ServerManager.shared.client.streamURL( songId: song.id, format: "mp3", maxBitRate: 192 ) else { wcLog("FAIL: Could not build stream URL") DispatchQueue.main.async { self.transferringIds.remove(song.id) self.transferProgressMap.removeValue(forKey: song.id) self.transferTitleMap.removeValue(forKey: song.id) } return false } // Download transcoded file to temp, then transfer Task { do { let (data, _) = try await URLSession.shared.data(from: streamURL) let tempDir = FileManager.default.temporaryDirectory let tempFile = tempDir.appendingPathComponent("watch_\(song.id).mp3") try data.write(to: tempFile) let size = ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file) self.wcLog("Transcoded: \(size) → sending to watch") await MainActor.run { _ = self.transferFile(tempFile, song: song, suffix: "mp3") } } catch { self.wcLog("FAIL: Transcode download failed: \(error.localizedDescription)") await MainActor.run { self.transferringIds.remove(song.id) self.transferProgressMap.removeValue(forKey: song.id) self.transferTitleMap.removeValue(forKey: song.id) } } } return true } /// Transfer a file to the watch with song metadata private func transferFile(_ url: URL, song: Song, suffix: String) -> Bool { guard let session = session, session.isPaired, session.isWatchAppInstalled else { return false } let metadata: [String: Any] = [ "type": "offlineSong", "songId": song.id, "id": song.id, "title": song.title, "artist": song.artist ?? "Unknown", "album": song.album ?? "Unknown", "duration": song.duration ?? 0, "coverArt": song.coverArt ?? "", "coverArtId": song.coverArt ?? "", "suffix": suffix ] DispatchQueue.main.async { self.transferringIds.insert(song.id) self.transferProgressMap[song.id] = 0.0 self.transferTitleMap[song.id] = song.title } let transfer = session.transferFile(url, metadata: metadata) activeTransfers[song.id] = transfer startProgressPolling() let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 wcLog("Transfer initiated: \(url.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file))) → Watch") wcLog("Outstanding transfers: \(session.outstandingFileTransfers.count)") return true } /// Poll active transfers for progress updates private func startProgressPolling() { guard progressTimer == nil else { return } progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in guard let self = self else { return } var anyActive = false for (songId, transfer) in self.activeTransfers { let pct = transfer.progress.fractionCompleted DispatchQueue.main.async { self.transferProgressMap[songId] = pct } if !transfer.progress.isFinished { anyActive = true } } if !anyActive { self.progressTimer?.invalidate() self.progressTimer = nil } } } var isWatchAvailable: Bool { isWatchPaired } /// Cancel a specific pending transfer func cancelTransfer(songId: String) { if let transfer = activeTransfers[songId] { transfer.cancel() activeTransfers.removeValue(forKey: songId) wcLog("Cancelled transfer for: \(transferTitleMap[songId] ?? songId)") } DispatchQueue.main.async { self.transferringIds.remove(songId) self.transferProgressMap.removeValue(forKey: songId) self.transferTitleMap.removeValue(forKey: songId) } } /// Cancel all pending transfers func cancelAllTransfers() { for (_, transfer) in activeTransfers { transfer.cancel() } activeTransfers.removeAll() wcLog("Cancelled all transfers") DispatchQueue.main.async { self.transferringIds.removeAll() self.transferProgressMap.removeAll() self.transferTitleMap.removeAll() } } /// Ordered list of pending transfer song IDs (for display) var pendingTransferList: [(id: String, title: String, progress: Double)] { transferringIds.map { id in (id: id, title: transferTitleMap[id] ?? id, progress: transferProgressMap[id] ?? 0) }.sorted { $0.title < $1.title } } /// Request the watch's song catalog @Published var watchSongs: [WatchSongInfo] = [] @Published var isLoadingWatchSongs = false /// Cached set of song IDs on the watch — persists across disconnects @Published var watchSongIds: Set = [] struct WatchSongInfo: Identifiable, Codable { let id: String let title: String let artist: String let album: String let fileSize: Int64 } /// Check if a song is on the watch (uses cached data) func isSongOnWatch(_ songId: String) -> Bool { watchSongIds.contains(songId) } /// Persist watch song IDs to UserDefaults private func saveWatchSongCache() { let ids = Array(watchSongIds) UserDefaults.standard.set(ids, forKey: "cached_watch_song_ids") if let data = try? JSONEncoder().encode(watchSongs) { UserDefaults.standard.set(data, forKey: "cached_watch_songs") } } /// Load cached watch song data from UserDefaults private func loadWatchSongCache() { if let ids = UserDefaults.standard.stringArray(forKey: "cached_watch_song_ids") { watchSongIds = Set(ids) } if let data = UserDefaults.standard.data(forKey: "cached_watch_songs"), let songs = try? JSONDecoder().decode([WatchSongInfo].self, from: data) { watchSongs = songs } } func requestWatchSongList() { guard let session = session, session.isReachable else { wcLog("requestWatchSongList: watch not reachable") return } isLoadingWatchSongs = true session.sendMessage(["type": "requestSongList"], replyHandler: { [weak self] reply in if let data = reply["songs"] as? Data, let songs = try? JSONDecoder().decode([WatchSongInfo].self, from: data) { DispatchQueue.main.async { self?.watchSongs = songs self?.watchSongIds = Set(songs.map { $0.id }) self?.isLoadingWatchSongs = false self?.saveWatchSongCache() self?.wcLog("Received \(songs.count) songs from watch") } } else { DispatchQueue.main.async { self?.isLoadingWatchSongs = false } } }, errorHandler: { [weak self] error in DispatchQueue.main.async { self?.isLoadingWatchSongs = false self?.wcLog("requestSongList failed: \(error.localizedDescription)") } }) } /// Request the watch to delete a song func requestWatchDeleteSong(_ songId: String) { guard let session = session, session.isReachable else { return } session.sendMessage(["type": "deleteSong", "songId": songId], replyHandler: { [weak self] _ in DispatchQueue.main.async { self?.watchSongs.removeAll { $0.id == songId } self?.watchSongIds.remove(songId) self?.saveWatchSongCache() self?.wcLog("Watch deleted: \(songId)") } }, errorHandler: { error in print("Delete song failed: \(error)") }) } /// Request the watch to delete all songs func requestWatchDeleteAll() { guard let session = session, session.isReachable else { return } session.sendMessage(["type": "deleteAllSongs"], replyHandler: { [weak self] _ in DispatchQueue.main.async { self?.watchSongs.removeAll() self?.watchSongIds.removeAll() self?.saveWatchSongCache() self?.wcLog("Watch deleted all songs") } }, errorHandler: nil) } /// Try to wake the watch app by sending a user info transfer /// This queues up and is delivered when the watch app launches func pingWatch() { guard let session = session, session.isPaired, session.isWatchAppInstalled else { wcLog("pingWatch: watch not available") return } // transferUserInfo is queued and delivered even if watch app is not running session.transferUserInfo(["type": "wake", "timestamp": Date().timeIntervalSince1970]) wcLog("pingWatch: sent wake signal. isReachable: \(session.isReachable)") if session.isReachable { wcLog("pingWatch: watch app is already running") } else { wcLog("pingWatch: watch app not running — transfer will queue") } } /// Returns a summary of watch connectivity state for debugging var watchStatusDescription: String { guard let session = session else { return "WCSession not supported" } var parts: [String] = [] parts.append("Paired: \(session.isPaired)") parts.append("App installed: \(session.isWatchAppInstalled)") parts.append("Reachable: \(session.isReachable)") parts.append("Activation: \(session.activationState.rawValue)") parts.append("Outstanding: \(session.outstandingFileTransfers.count)") return parts.joined(separator: " · ") } func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) { guard let session = session, session.isReachable else { return } var info: [String: Any] = [ "type": "nowPlaying", "isPlaying": isPlaying, "currentTime": currentTime, "duration": duration ] if let song = song { info["songId"] = song.id info["title"] = song.title info["artist"] = song.artist ?? "" info["album"] = song.album ?? "" info["coverArtId"] = song.coverArt ?? "" } session.sendMessage(info, replyHandler: nil, errorHandler: nil) } func syncOfflineSongsToWatch(songs: [DownloadedSong]) { guard let session = session, session.isPaired, session.isWatchAppInstalled else { return } do { let catalogData = try JSONEncoder().encode(songs.map { $0.song }) try session.updateApplicationContext([ "offlineCatalog": catalogData, "songCount": songs.count ]) } catch { print("Failed to send catalog: \(error)") } for downloaded in songs { let fileURL = URL(fileURLWithPath: downloaded.localPath) guard FileManager.default.fileExists(atPath: fileURL.path) else { continue } let metadata: [String: Any] = [ "songId": downloaded.song.id, "title": downloaded.song.title, "artist": downloaded.song.artist ?? "", "album": downloaded.song.album ?? "", "coverArtId": downloaded.song.coverArt ?? "", "duration": downloaded.song.duration ?? 0, "suffix": downloaded.song.suffix ?? "mp3" ] transferSongToWatch(fileURL: fileURL, metadata: metadata) } } #endif } // MARK: - WCSessionDelegate extension WatchConnectivityManager: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { let stateNames = ["notActivated", "inactive", "activated"] let stateName = activationState.rawValue < stateNames.count ? stateNames[Int(activationState.rawValue)] : "unknown" wcLog("Activation complete: \(stateName), error: \(error?.localizedDescription ?? "none")") #if os(iOS) wcLog("isPaired: \(session.isPaired), isWatchAppInstalled: \(session.isWatchAppInstalled), isReachable: \(session.isReachable)") #endif DispatchQueue.main.async { self.isReachable = session.isReachable #if os(iOS) self.isWatchPaired = session.isPaired && session.isWatchAppInstalled #endif } #if os(iOS) if activationState == .activated { sendServersToWatch(ServerManager.shared.servers) } #endif } func sessionReachabilityDidChange(_ session: WCSession) { wcLog("Reachability changed: \(session.isReachable)") DispatchQueue.main.async { self.isReachable = session.isReachable } } // These two delegate methods are REQUIRED on iOS but do not exist on watchOS #if os(iOS) func sessionDidBecomeInactive(_ session: WCSession) { } func sessionDidDeactivate(_ session: WCSession) { session.activate() } #endif // MARK: - Receive messages func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { guard let type = message["type"] as? String else { replyHandler(["error": "unknown message type"]) return } #if os(iOS) // iOS handles commands from watch switch type { case "requestServers": do { let data = try JSONEncoder().encode(ServerManager.shared.servers) replyHandler(["servers": data]) } catch { replyHandler(["error": error.localizedDescription]) } case "playCommand": DispatchQueue.main.async { AudioPlayer.shared.togglePlayPause() replyHandler(["isPlaying": AudioPlayer.shared.isPlaying]) } case "nextCommand": DispatchQueue.main.async { AudioPlayer.shared.next() replyHandler(["success": true]) } case "previousCommand": DispatchQueue.main.async { AudioPlayer.shared.previous() replyHandler(["success": true]) } case "requestDownload": if let songId = message["songId"] as? String { Task { do { let client = ServerManager.shared.client if let song = try await client.getSong(id: songId), let server = ServerManager.shared.activeServer { OfflineManager.shared.downloadSong(song, server: server) replyHandler(["status": "downloading"]) } else { replyHandler(["error": "Song not found"]) } } catch { replyHandler(["error": error.localizedDescription]) } } } default: replyHandler(["error": "unhandled type: \(type)"]) } #else // watchOS handles messages from iPhone replyHandler(["error": "unhandled on watchOS: \(type)"]) #endif } // Receive application context 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.receivedServers = servers } } } // Receive file transfers — watchOS saves songs for offline playback func session(_ session: WCSession, didReceive file: WCSessionFile) { #if os(watchOS) guard let metadata = file.metadata, let songId = metadata["id"] as? String, let suffix = metadata["suffix"] as? String else { print("Received file without proper metadata") return } let fm = FileManager.default let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first! let musicDir = docs.appendingPathComponent("OfflineMusic", isDirectory: true) try? fm.createDirectory(at: musicDir, withIntermediateDirectories: true) let destURL = musicDir.appendingPathComponent("\(songId).\(suffix)") // Remove existing if re-transferring try? fm.removeItem(at: destURL) do { try fm.moveItem(at: file.fileURL, to: destURL) print("Saved offline song: \(songId) to \(destURL.lastPathComponent)") // Save metadata catalog var catalog = loadWatchCatalog() catalog[songId] = metadata saveWatchCatalog(catalog) } catch { print("Failed to save transferred file: \(error)") } #endif } #if os(watchOS) private var watchCatalogURL: URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! return docs.appendingPathComponent("watchOfflineCatalog.json") } func loadWatchCatalog() -> [String: [String: Any]] { guard let data = try? Data(contentsOf: watchCatalogURL), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] else { return [:] } return dict } private func saveWatchCatalog(_ catalog: [String: [String: Any]]) { if let data = try? JSONSerialization.data(withJSONObject: catalog) { try? data.write(to: watchCatalogURL) } } func watchOfflineSongs() -> [(id: String, title: String, artist: String, album: String, duration: Int, suffix: String)] { let catalog = loadWatchCatalog() let musicDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! .appendingPathComponent("OfflineMusic", isDirectory: true) return catalog.compactMap { id, meta in let suffix = meta["suffix"] as? String ?? "mp3" let url = musicDir.appendingPathComponent("\(id).\(suffix)") guard FileManager.default.fileExists(atPath: url.path) else { return nil } return ( id: id, title: meta["title"] as? String ?? "Unknown", artist: meta["artist"] as? String ?? "Unknown", album: meta["album"] as? String ?? "Unknown", duration: meta["duration"] as? Int ?? 0, suffix: suffix ) }.sorted { $0.title < $1.title } } func watchOfflineURL(for songId: String) -> URL? { let catalog = loadWatchCatalog() guard let meta = catalog[songId] else { return nil } let suffix = meta["suffix"] as? String ?? "mp3" let musicDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! .appendingPathComponent("OfflineMusic", isDirectory: true) let url = musicDir.appendingPathComponent("\(songId).\(suffix)") return FileManager.default.fileExists(atPath: url.path) ? url : nil } #endif // File transfer completion — iOS only #if os(iOS) func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: Error?) { let songId = fileTransfer.file.metadata?["id"] as? String let title = fileTransfer.file.metadata?["title"] as? String ?? "unknown" if let id = songId { activeTransfers.removeValue(forKey: id) } DispatchQueue.main.async { if let id = songId { self.transferringIds.remove(id) self.transferProgressMap.removeValue(forKey: id) self.transferTitleMap.removeValue(forKey: id) if error == nil { self.transferredIds.insert(id) self.watchSongIds.insert(id) self.saveWatchSongCache() } } } if let error = error { wcLog("Transfer FAILED for '\(title)': \(error.localizedDescription)") } else { wcLog("Transfer COMPLETE: '\(title)' (\(fileTransfer.file.fileURL.lastPathComponent))") wcLog("Remaining outstanding transfers: \(session.outstandingFileTransfers.count)") } } #endif }