// // MAPlayerManager.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import Foundation import OSLog import UIKit private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "PlayerManager") /// Manages player state and real-time updates @Observable final class MAPlayerManager { // MARK: - Properties private(set) var players: [String: MAPlayer] = [:] private(set) var playerQueues: [String: MAPlayerQueue] = [:] private(set) var queues: [String: [MAQueueItem]] = [:] private weak var service: MAService? private var eventTask: Task? let liveActivityManager = MALiveActivityManager() // MARK: - Initialization init(service: MAService?) { self.service = service } func setService(_ service: MAService) { self.service = service } deinit { stopListening() } // MARK: - Event Listening /// Start listening to player events func startListening() { guard eventTask == nil, let service else { return } logger.info("Starting event listener") eventTask = Task { for await event in service.webSocketClient.eventStream { await handleEvent(event) } } } /// Stop listening to events func stopListening() { logger.info("Stopping event listener") eventTask?.cancel() eventTask = nil } private func handleEvent(_ event: MAEvent) async { logger.debug("Handling event: \(event.event)") switch event.event { case "player_updated": await handlePlayerUpdated(event) case "queue_updated": await handleQueueUpdated(event) case "queue_items_updated": await handleQueueItemsUpdated(event) default: logger.debug("Unhandled event: \(event.event)") } } private func handlePlayerUpdated(_ event: MAEvent) async { guard let data = event.data else { return } do { let player = try data.decode(as: MAPlayer.self) await MainActor.run { players[player.playerId] = player logger.debug("Updated player: \(player.name) state=\(player.state.rawValue) item=\(player.currentItem?.name ?? "nil")") updateLiveActivity() } } catch { logger.error("Failed to decode player_updated event: \(error)") } } private func handleQueueUpdated(_ event: MAEvent) async { guard let data = event.data else { return } // The event data IS the PlayerQueue object — decode it directly for current_item if let queue = try? data.decode(as: MAPlayerQueue.self), !queue.queueId.isEmpty { await MainActor.run { playerQueues[queue.queueId] = queue logger.debug("Updated queue state for player \(queue.queueId), current: \(queue.currentItem?.name ?? "nil")") updateLiveActivity() } return } // Fallback: extract queue_id and fetch from API guard let dict = data.value as? [String: Any], let queueId = dict["queue_id"] as? String, let service else { return } do { let queue = try await service.getPlayerQueue(playerId: queueId) await MainActor.run { playerQueues[queueId] = queue logger.debug("Fetched queue state for player \(queueId), current: \(queue.currentItem?.name ?? "nil")") } } catch { logger.error("Failed to reload queue state: \(error.localizedDescription)") } } private func handleQueueItemsUpdated(_ event: MAEvent) async { // Update queue state (current item, current index) await handleQueueUpdated(event) // Reload the items list if we already have it cached (i.e., queue view was opened) guard let data = event.data, let dict = data.value as? [String: Any], let queueId = dict["queue_id"] as? String, queues[queueId] != nil, let service else { return } do { let items = try await service.getQueue(playerId: queueId) await MainActor.run { queues[queueId] = items logger.debug("Reloaded queue items for player \(queueId): \(items.count) items") } } catch { logger.error("Failed to reload queue items: \(error.localizedDescription)") } } // MARK: - Live Activity /// Finds the best currently-playing player and pushes its state to the Live Activity. /// Spawns a Task to fetch artwork with auth before updating. private func updateLiveActivity() { guard UserDefaults.standard.object(forKey: "liveActivityEnabled") as? Bool ?? true else { liveActivityManager.end() return } let playing = players.values .filter { $0.state == .playing } .first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil } ?? players.values.first { $0.state == .playing } guard let player = playing else { liveActivityManager.end() return } guard let item = playerQueues[player.playerId]?.currentItem ?? player.currentItem else { liveActivityManager.end() return } let media = item.mediaItem let trackTitle = item.name.isEmpty ? (media?.name ?? "Unknown Track") : item.name let artistName = media?.artists?.first?.name ?? "" let isPlaying = player.state == .playing let playerName = player.name let imagePath = media?.imageUrl let imageProvider = media?.imageProvider logger.debug("updateLiveActivity: track='\(trackTitle)' imagePath=\(imagePath ?? "nil")") // Update immediately so the live activity appears without waiting for artwork. liveActivityManager.update( trackTitle: trackTitle, artistName: artistName, artworkData: nil, isPlaying: isPlaying, playerName: playerName ) // Then fetch artwork in background and refresh. let capturedService = service Task { let artworkData = await Self.fetchArtworkData(path: imagePath, provider: imageProvider, service: capturedService) logger.debug("fetchArtworkData result: \(artworkData != nil ? "\(artworkData!.count) bytes" : "nil")") guard let artworkData else { return } liveActivityManager.update( trackTitle: trackTitle, artistName: artistName, artworkData: artworkData, isPlaying: isPlaying, playerName: playerName ) } } /// Fetches artwork as small JPEG data for the Live Activity. /// Checks the app's ImageCache at sizes the app normally loads (512, 64) before /// falling back to a fresh network download at size 128 with auth. private static func fetchArtworkData(path: String?, provider: String?, service: MAService?) async -> Data? { guard let path, !path.isEmpty else { logger.debug("fetchArtworkData: no image path") return nil } guard let service else { logger.debug("fetchArtworkData: service is nil") return nil } // Check cache at sizes the app commonly loads for size in [512, 64] { guard let url = service.imageProxyURL(path: path, provider: provider, size: size) else { continue } let key = ImageCache.shared.cacheKey(for: url) if let img = ImageCache.shared.memoryImage(for: key) { logger.debug("fetchArtworkData: memory cache hit at size \(size)") return resizeAndEncode(img) } if let img = await Task.detached(priority: .userInitiated) { ImageCache.shared.diskImage(for: key) }.value { logger.debug("fetchArtworkData: disk cache hit at size \(size)") return resizeAndEncode(img) } } // Not cached — download fresh at compact size with auth header guard let downloadURL = service.imageProxyURL(path: path, provider: provider, size: 128) else { return nil } logger.debug("fetchArtworkData: downloading from \(downloadURL)") var request = URLRequest(url: downloadURL) if let token = service.authManager.currentToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } else { logger.warning("fetchArtworkData: no auth token available") } do { let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger.debug("fetchArtworkData: HTTP \(status), \(data.count) bytes") guard status == 200, let img = UIImage(data: data) else { return nil } ImageCache.shared.store(img, data: data, for: ImageCache.shared.cacheKey(for: downloadURL)) return resizeAndEncode(img) } catch { logger.error("fetchArtworkData: network error: \(error.localizedDescription)") return nil } } /// Exposed for unit testing only. static func testResizeAndEncode(_ image: UIImage) -> Data? { resizeAndEncode(image) } private static func resizeAndEncode(_ image: UIImage) -> Data? { // ActivityKit ContentState limit is 4 KB total (Data fields are base64 in the payload). // 40×40 JPEG at 0.3 quality ≈ 400–700 bytes, well within limits. let size = CGSize(width: 40, height: 40) let renderer = UIGraphicsImageRenderer(size: size) let scaled = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } return scaled.jpegData(compressionQuality: 0.3) } // MARK: - Data Loading /// Load all players and their queue states func loadPlayers() async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } logger.info("Loading players") let playerList = try await service.getPlayers() await MainActor.run { players = Dictionary(uniqueKeysWithValues: playerList.map { ($0.playerId, $0) }) } // Concurrently fetch queue state for each player to get current_item var queueResults: [String: MAPlayerQueue] = [:] await withTaskGroup(of: (String, MAPlayerQueue?).self) { group in for player in playerList { let pid = player.playerId group.addTask { let queue = try? await service.getPlayerQueue(playerId: pid) return (pid, queue) } } for await (pid, queue) in group { if let queue { queueResults[pid] = queue } } } await MainActor.run { for (pid, queue) in queueResults { playerQueues[pid] = queue } logger.info("Loaded queue states for \(queueResults.count) players") updateLiveActivity() } } /// Load queue for specific player func loadQueue(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } logger.info("Loading queue for player \(playerId)") let items = try await service.getQueue(playerId: playerId) await MainActor.run { queues[playerId] = items } } // MARK: - Player Control func play(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.play(playerId: playerId) } func pause(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.pause(playerId: playerId) } func stop(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.stop(playerId: playerId) } func nextTrack(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.nextTrack(playerId: playerId) } func previousTrack(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.previousTrack(playerId: playerId) } func seek(playerId: String, position: Double) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.seek(playerId: playerId, position: position) } func setGroupVolume(playerId: String, level: Int) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.setGroupVolume(playerId: playerId, level: level) } func setVolume(playerId: String, level: Int) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.setVolume(playerId: playerId, level: level) } func syncPlayer(playerId: String, targetPlayerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.syncPlayer(playerId: playerId, targetPlayerId: targetPlayerId) } func unsyncPlayer(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.unsyncPlayer(playerId: playerId) } func playMedia(playerId: String, uri: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.playMedia(playerId: playerId, uri: uri) } func playMedia(playerId: String, uris: [String]) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.playMedia(playerId: playerId, uris: uris) } func enqueueMedia(playerId: String, uri: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.enqueueMedia(playerId: playerId, uri: uri) } func playIndex(playerId: String, index: Int) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.playIndex(playerId: playerId, index: index) } func setShuffle(playerId: String, enabled: Bool) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.setQueueShuffle(playerId: playerId, enabled: enabled) } func setRepeatMode(playerId: String, mode: RepeatMode) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.setQueueRepeatMode(playerId: playerId, mode: mode) } func clearQueue(playerId: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.clearQueue(playerId: playerId) } func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.moveQueueItem(playerId: playerId, queueItemId: queueItemId, posShift: posShift) // Optimistic local update — move the item in our cached list if var items = queues[playerId], let idx = items.firstIndex(where: { $0.queueItemId == queueItemId }) { let item = items.remove(at: idx) let dest = max(0, min(items.count, idx + posShift)) items.insert(item, at: dest) queues[playerId] = items } } }