442 lines
16 KiB
Swift
442 lines
16 KiB
Swift
//
|
||
// 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<Void, Never>?
|
||
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
|
||
}
|
||
}
|
||
}
|