NavidromeApp/Shared/Storage/WatchConnectivityManager.swift

677 lines
26 KiB
Swift
Raw Normal View History

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<String> = []
@Published var transferredIds: Set<String> = []
/// 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<String> = []
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
}