NavidromeApp/watchOS/App/WatchSessionManager.swift
Dallas Groot 5205d708c3 Watch: fix command routing, seek bar, volume UI
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.
2026-04-20 13:06:06 -07:00

605 lines
23 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?
var shuffleEnabled: Bool = false
var repeatMode: Int = 0 // 0=off, 1=all, 2=one
var volume: Float = 1.0
}
private var session: WCSession?
private let client = SubsonicClient()
/// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler)
static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying {
PhoneNowPlaying(
title: dict["title"] as? String ?? "",
artist: dict["artist"] as? String ?? "",
album: dict["album"] as? String ?? "",
isPlaying: dict["isPlaying"] as? Bool ?? false,
currentTime: dict["currentTime"] as? TimeInterval ?? 0,
duration: dict["duration"] as? TimeInterval ?? 0,
coverArtId: dict["coverArtId"] as? String,
shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false,
repeatMode: dict["repeatMode"] as? Int ?? 0,
volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0
)
}
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: { [weak self] reply in
if let isPlaying = reply["isPlaying"] as? Bool {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.isPlaying = isPlaying
}
}
}, errorHandler: nil)
}
func sendNextCommand() {
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
}
func sendPreviousCommand() {
session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil)
}
func sendSeekCommand(to time: TimeInterval) {
session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil)
}
func sendShuffleCommand() {
session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in
if let enabled = reply["shuffleEnabled"] as? Bool {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.shuffleEnabled = enabled
}
}
}, errorHandler: nil)
}
func sendRepeatCommand() {
session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in
if let mode = reply["repeatMode"] as? Int {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.repeatMode = mode
}
}
}, errorHandler: nil)
}
func sendVolumeCommand(_ volume: Float) {
session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil)
}
/// Ask iPhone for current playback state (used on watch app launch)
func requestNowPlaying() {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in
guard let title = reply["title"] as? String, !title.isEmpty else {
DispatchQueue.main.async { self.phoneNowPlaying = nil }
return
}
DispatchQueue.main.async {
self.phoneNowPlaying = Self.parseNowPlaying(from: reply)
}
}, errorHandler: { _ in })
}
func requestDownload(songId: String) {
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
}
// MARK: - Direct API Access (watch can also talk to server directly)
func getArtists() async throws -> [ArtistIndex] {
guard activeServer != nil else { return [] }
return try await client.getArtists()
}
func getArtist(id: String) async throws -> ArtistWithAlbums? {
return try await client.getArtist(id: id)
}
func getGenres() async throws -> [Genre] {
guard activeServer != nil else { return [] }
return try await client.getGenres()
}
func getAlbumList(type: String = "newest", size: Int = 20, offset: Int = 0) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: type, size: size, offset: offset)
}
/// Fetch ALL albums (paginated until exhausted)
func getAllAlbums() async throws -> [Album] {
guard activeServer != nil else { return [] }
var all: [Album] = []
var offset = 0
let pageSize = 500
while true {
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
all.append(contentsOf: page)
if page.count < pageSize { break }
offset += pageSize
}
return all
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
return try await client.getAlbum(id: id)
}
func getAlbumsByGenre(_ genre: String) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: "byGenre", size: 500, genre: genre)
}
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: - Watch Library Cache (UserDefaults, persists across launches)
private let cachePrefix = "wcache_"
func cacheEncode<T: Encodable>(_ data: T, key: String) {
if let encoded = try? JSONEncoder().encode(data) {
UserDefaults.standard.set(encoded, forKey: cachePrefix + key)
}
}
func cacheDecode<T: Decodable>(_ type: T.Type, key: String) -> T? {
guard let data = UserDefaults.standard.data(forKey: cachePrefix + key) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
/// Fetch with cache-first pattern: returns cached immediately, refreshes in background
func cachedArtists() -> [ArtistIndex] {
return cacheDecode([ArtistIndex].self, key: "artists") ?? []
}
func cachedGenres() -> [Genre] {
return cacheDecode([Genre].self, key: "genres") ?? []
}
func cachedAlbums() -> [Album] {
return cacheDecode([Album].self, key: "all_albums") ?? []
}
func cachedPlaylists() -> [Playlist] {
return cacheDecode([Playlist].self, key: "playlists") ?? []
}
func cachedAlbumDetail(id: String) -> AlbumWithSongs? {
return cacheDecode(AlbumWithSongs.self, key: "album_\(id)")
}
func cachedArtistDetail(id: String) -> ArtistWithAlbums? {
return cacheDecode(ArtistWithAlbums.self, key: "artist_\(id)")
}
/// Full sync called on launch, caches everything
func syncLibrary() async {
guard activeServer != nil else { return }
await MainActor.run { isSyncing = true }
do {
let artists = try await getArtists()
cacheEncode(artists, key: "artists")
let genres = try await getGenres()
cacheEncode(genres, key: "genres")
let albums = try await getAllAlbums()
cacheEncode(albums, key: "all_albums")
let playlists = try await getPlaylists()
cacheEncode(playlists, key: "playlists")
let recent = try await getAlbumList(type: "newest", size: 30)
cacheEncode(recent, key: "recent")
print("[Watch] Library synced: \(artists.flatMap { $0.artist ?? [] }.count) artists, \(albums.count) albums, \(genres.count) genres")
} catch {
print("[Watch] Library sync failed: \(error.localizedDescription)")
}
await MainActor.run { isSyncing = false }
}
// MARK: - Tag Update Handler
private func handleTagsUpdated(_ message: [String: Any]) {
guard let songId = message["songId"] as? String else { return }
let title = message["title"] as? String
let artist = message["artist"] as? String
let album = message["album"] as? String
let albumArtist = message["albumArtist"] as? String
DispatchQueue.main.async {
// Update offline store if this song is downloaded on the watch
WatchOfflineStore.shared.updateSongMetadata(
id: songId, title: title, artist: artist,
album: album, albumArtist: albumArtist
)
// Invalidate stale library caches next fetch will repopulate from server
for key in ["all_albums", "recent", "playlists"] {
UserDefaults.standard.removeObject(forKey: self.cachePrefix + key)
}
// Refresh now-playing display if this song is currently playing
if let t = title { self.phoneNowPlaying?.title = t }
if let a = artist { self.phoneNowPlaying?.artist = a }
if let b = album { self.phoneNowPlaying?.album = b }
print("[Watch] Tags updated for \(songId)")
}
}
// MARK: - Server-Direct Download Handler
/// Called when iPhone tells us to download a song directly from server.
/// Watch downloads at 192kbps MP3 no iPhone storage or transfer needed.
private func handleDownloadCommand(_ message: [String: Any]) {
guard let songId = message["songId"] as? String else {
print("[Watch] downloadSong: missing songId")
return
}
// Skip if already downloaded
if WatchOfflineStore.shared.isSongAvailable(songId) {
print("[Watch] Already downloaded: \(songId)")
return
}
let song = Song(
id: songId,
parent: nil, isDir: false,
title: message["title"] as? String ?? "Unknown",
album: message["album"] as? String,
artist: message["artist"] as? String,
albumArtist: message["albumArtist"] as? String,
track: nil, year: nil, genre: nil,
coverArt: message["coverArt"] as? String,
size: nil, contentType: nil,
suffix: message["suffix"] as? String ?? "mp3",
transcodedContentType: nil, transcodedSuffix: nil,
duration: message["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
)
print("[Watch] Downloading from server: \(song.title)")
DispatchQueue.main.async {
WatchOfflineStore.shared.downloadFromServer(song: song)
}
}
// 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()
}
}
}
// Receive messages (now playing info + download commands 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 {
// If no title, iPhone stopped playing clear remote state
guard let title = message["title"] as? String, !title.isEmpty else {
self.phoneNowPlaying = nil
return
}
self.phoneNowPlaying = Self.parseNowPlaying(from: message)
}
} else if type == "tagsUpdated" {
handleTagsUpdated(message)
} else if type == "downloadSong" {
handleDownloadCommand(message)
}
}
// 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 "downloadSong":
handleDownloadCommand(message)
replyHandler(["status": "downloading"])
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 download commands 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" {
print("[Watch] Wake signal received — app is active")
} else if type == "downloadSong" {
handleDownloadCommand(userInfo)
} else if type == "tagsUpdated" {
handleTagsUpdated(userInfo)
}
}
}
// 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,
albumArtist: metadata["albumArtist"] 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)")
}
}
}