Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.
Fix 2 — Seek bar:
Replaced DragGesture with onTapGesture for reliable watchOS taps.
Hit target increased from 3pt to 20pt. Both local and remote views.
Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
756 lines
30 KiB
Swift
756 lines
30 KiB
Swift
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)")
|
|
}
|
|
|
|
/// Notify the watch that a song's ID3 tags were updated via the Companion API.
|
|
/// The watch updates its offline store metadata in-place and invalidates stale caches.
|
|
/// Uses sendMessage if reachable, transferUserInfo as fallback (queued delivery on next activation).
|
|
func notifyTagsUpdated(songId: String, title: String?, artist: String?,
|
|
album: String?, albumArtist: String?) {
|
|
guard let session, session.isPaired, session.isWatchAppInstalled else { return }
|
|
var payload: [String: Any] = ["type": "tagsUpdated", "songId": songId]
|
|
if let v = title { payload["title"] = v }
|
|
if let v = artist { payload["artist"] = v }
|
|
if let v = album { payload["album"] = v }
|
|
if let v = albumArtist { payload["albumArtist"] = v }
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil, errorHandler: { [weak self] err in
|
|
self?.wcLog("tagsUpdated sendMessage failed — queuing: \(err.localizedDescription)")
|
|
session.transferUserInfo(payload)
|
|
})
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
wcLog("notifyTagsUpdated: \(songId) title=\(title ?? "-")")
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// Send a message telling the watch to download directly from server.
|
|
// The watch has its own server credentials and can stream at 192kbps MP3
|
|
// without needing the iPhone to download and transfer.
|
|
let metadata: [String: Any] = [
|
|
"type": "downloadSong",
|
|
"songId": song.id,
|
|
"title": song.title,
|
|
"artist": song.artist ?? "Unknown",
|
|
"album": song.album ?? "Unknown",
|
|
"duration": song.duration ?? 0,
|
|
"coverArt": song.coverArt ?? "",
|
|
"suffix": song.suffix ?? "mp3"
|
|
]
|
|
|
|
if session.isReachable {
|
|
// Watch is active — send interactive message (faster)
|
|
session.sendMessage(metadata, replyHandler: { reply in
|
|
self.wcLog("Watch acknowledged download: \(reply)")
|
|
}, errorHandler: { error in
|
|
self.wcLog("Watch message failed: \(error.localizedDescription) — falling back to userInfo")
|
|
// Fall back to transferUserInfo (queued, delivered when watch wakes)
|
|
session.transferUserInfo(metadata)
|
|
})
|
|
} else {
|
|
// Watch not reachable — queue for delivery when it wakes
|
|
session.transferUserInfo(metadata)
|
|
wcLog("Watch not reachable — queued download command via transferUserInfo")
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.transferringIds.insert(song.id)
|
|
self.transferTitleMap[song.id] = song.title
|
|
|
|
// Auto-remove from transferring after 30s since we can't track watch download progress
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
|
|
self.transferringIds.remove(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: { [weak self] error in
|
|
self?.wcLog("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 }
|
|
|
|
let player = AudioPlayer.shared
|
|
var info: [String: Any] = [
|
|
"type": "nowPlaying",
|
|
"isPlaying": isPlaying,
|
|
"currentTime": currentTime,
|
|
"duration": duration,
|
|
"shuffleEnabled": player.shuffleEnabled,
|
|
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
|
"volume": Double(player.volume)
|
|
]
|
|
|
|
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 {
|
|
wcLog("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]) {
|
|
guard let type = message["type"] as? String else { return }
|
|
|
|
#if os(iOS)
|
|
if type == "watchDownloadStatus" {
|
|
// Watch completed or removed a download — update our tracking
|
|
if let songId = message["songId"] as? String,
|
|
let downloaded = message["downloaded"] as? Bool {
|
|
DispatchQueue.main.async {
|
|
if downloaded {
|
|
self.watchSongIds.insert(songId)
|
|
} else {
|
|
self.watchSongIds.remove(songId)
|
|
}
|
|
self.objectWillChange.send()
|
|
}
|
|
wcLog("Watch download status: \(songId) → \(downloaded ? "downloaded" : "removed")")
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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 "seekCommand":
|
|
if let time = message["time"] as? TimeInterval {
|
|
DispatchQueue.main.async {
|
|
AudioPlayer.shared.seek(to: time)
|
|
replyHandler(["success": true])
|
|
}
|
|
} else {
|
|
replyHandler(["error": "missing time"])
|
|
}
|
|
|
|
case "shuffleCommand":
|
|
DispatchQueue.main.async {
|
|
AudioPlayer.shared.toggleShuffle()
|
|
replyHandler(["shuffleEnabled": AudioPlayer.shared.shuffleEnabled])
|
|
}
|
|
|
|
case "repeatCommand":
|
|
DispatchQueue.main.async {
|
|
AudioPlayer.shared.cycleRepeat()
|
|
let mode = AudioPlayer.shared.repeatMode
|
|
replyHandler(["repeatMode": mode == .off ? 0 : mode == .all ? 1 : 2])
|
|
}
|
|
|
|
case "volumeCommand":
|
|
if let vol = message["volume"] as? Double {
|
|
DispatchQueue.main.async {
|
|
AudioPlayer.shared.setVolume(Float(vol))
|
|
replyHandler(["success": true])
|
|
}
|
|
} else {
|
|
replyHandler(["error": "missing volume"])
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|
|
}
|
|
|
|
case "requestNowPlaying":
|
|
DispatchQueue.main.async {
|
|
let player = AudioPlayer.shared
|
|
var reply: [String: Any] = [
|
|
"isPlaying": player.isPlaying,
|
|
"currentTime": player.currentTime,
|
|
"duration": player.duration,
|
|
"shuffleEnabled": player.shuffleEnabled,
|
|
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
|
"volume": Double(player.volume)
|
|
]
|
|
if let song = player.currentSong {
|
|
reply["title"] = song.title
|
|
reply["artist"] = song.artist ?? ""
|
|
reply["album"] = song.album ?? ""
|
|
reply["coverArtId"] = song.coverArt ?? ""
|
|
}
|
|
replyHandler(reply)
|
|
}
|
|
|
|
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 {
|
|
wcLog("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)
|
|
wcLog("Saved offline song: \(songId) to \(destURL.lastPathComponent)")
|
|
|
|
// Save metadata catalog
|
|
var catalog = loadWatchCatalog()
|
|
catalog[songId] = metadata
|
|
saveWatchCatalog(catalog)
|
|
} catch {
|
|
wcLog("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
|
|
}
|