Files
MobileMusicAssistant/Mobile Music Assistant/ServicesMAPlayerManager.swift
T

442 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 400700 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
}
}
}