363 lines
14 KiB
Swift
363 lines
14 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?
|
||
|
|
}
|
||
|
|
|
||
|
|
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)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|