// // MAAudioPlayer.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import Foundation import AVFoundation import MediaPlayer import OSLog private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "AudioPlayer") /// Audio player for local playback on iPhone @Observable final class MAAudioPlayer: NSObject { // MARK: - Properties private let service: MAService private var player: AVPlayer? private(set) var currentItem: MAQueueItem? private var timeObserver: Any? // Playback state private(set) var isPlaying = false private(set) var currentTime: TimeInterval = 0 private(set) var duration: TimeInterval = 0 // Volume var volume: Float { get { AVAudioSession.sharedInstance().outputVolume } set { // Volume can only be changed via system controls on iOS // This is here for compatibility } } // MARK: - Initialization init(service: MAService) { self.service = service super.init() setupAudioSession() setupRemoteCommands() setupNotifications() } deinit { cleanupPlayer() NotificationCenter.default.removeObserver(self) } // MARK: - Audio Session Setup private func setupAudioSession() { let audioSession = AVAudioSession.sharedInstance() do { // Configure for playback try audioSession.setCategory( .playback, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP] ) try audioSession.setActive(true) logger.info("Audio session configured") } catch { logger.error("Failed to configure audio session: \(error.localizedDescription)") } } // MARK: - Remote Commands (Lock Screen Controls) private func setupRemoteCommands() { let commandCenter = MPRemoteCommandCenter.shared() // Play command commandCenter.playCommand.addTarget { [weak self] _ in self?.play() return .success } // Pause command commandCenter.pauseCommand.addTarget { [weak self] _ in self?.pause() return .success } // Stop command commandCenter.stopCommand.addTarget { [weak self] _ in self?.stop() return .success } // Next track commandCenter.nextTrackCommand.addTarget { [weak self] _ in Task { await self?.nextTrack() } return .success } // Previous track commandCenter.previousTrackCommand.addTarget { [weak self] _ in Task { await self?.previousTrack() } return .success } // Change playback position commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: event.positionTime) return .success } logger.info("Remote commands configured") } // MARK: - Notifications private func setupNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance() ) NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance() ) } @objc private func handleInterruption(notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: // Interruption began (e.g., phone call) pause() logger.info("Audio interrupted - pausing") case .ended: // Interruption ended guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { play() logger.info("Audio interruption ended - resuming") } @unknown default: break } } @objc private func handleRouteChange(notification: Notification) { guard let userInfo = notification.userInfo, let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } switch reason { case .oldDeviceUnavailable: // Headphones unplugged pause() logger.info("Audio route changed - pausing") default: break } } // MARK: - Playback Control /// Play current item or resume func play() { guard let player else { return } player.play() isPlaying = true updateNowPlayingInfo() logger.info("Playing") } /// Pause playback func pause() { guard let player else { return } player.pause() isPlaying = false updateNowPlayingInfo() logger.info("Paused") } /// Stop playback func stop() { cleanupPlayer() isPlaying = false currentItem = nil clearNowPlayingInfo() logger.info("Stopped") } /// Next track func nextTrack() async { logger.info("Next track requested") // TODO: Get next item from queue // For now, just stop stop() } /// Previous track func previousTrack() async { logger.info("Previous track requested") // TODO: Get previous item from queue // For now, restart current track seek(to: 0) } /// Seek to position func seek(to time: TimeInterval) { guard let player else { return } let cmTime = CMTime(seconds: time, preferredTimescale: 600) player.seek(to: cmTime) { [weak self] _ in self?.updateNowPlayingInfo() } logger.info("Seeking to \(time)s") } // MARK: - Load & Play Media /// Play a queue item from a specific player's queue func playQueueItem(_ item: MAQueueItem, queueId: String) async throws { logger.info("Playing queue item: \(item.name) from queue \(queueId)") // Get stream URL from server let streamURL = try await service.getStreamURL( queueId: queueId, queueItemId: item.queueItemId ) logger.info("Got stream URL: \(streamURL.absoluteString)") // Load and play await loadAndPlay(item: item, streamURL: streamURL) } /// Play a media item by URI (adds to queue and plays) func playMediaItem(uri: String, queueId: String) async throws { logger.info("Playing media item: \(uri)") // First, tell the server to add this to the queue try await service.playMedia(playerId: queueId, uri: uri) // Wait a bit for the queue to update try await Task.sleep(for: .milliseconds(500)) // Get the updated queue let queue = try await service.getQueue(playerId: queueId) // Find the item we just added (should be first or currently playing) guard let item = queue.first else { throw MAWebSocketClient.ClientError.serverError("Queue is empty after adding item") } // Get stream URL and play try await playQueueItem(item, queueId: queueId) } /// Load and play a media item with stream URL private func loadAndPlay(item: MAQueueItem, streamURL: URL) async { await MainActor.run { logger.info("Loading media: \(item.name)") cleanupPlayer() currentItem = item // Build authenticated request if needed var headers: [String: String] = [:] if let token = service.authManager.currentToken { headers["Authorization"] = "Bearer \(token)" } // Create asset with auth headers let asset = AVURLAsset(url: streamURL, options: [ "AVURLAssetHTTPHeaderFieldsKey": headers ]) // Create player item let playerItem = AVPlayerItem(asset: asset) // Create player player = AVPlayer(playerItem: playerItem) // Observe playback time let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = player?.addPeriodicTimeObserver( forInterval: interval, queue: .main ) { [weak self] time in self?.updatePlaybackTime(time) } // Observe player status NotificationCenter.default.addObserver( self, selector: #selector(playerDidFinishPlaying), name: .AVPlayerItemDidPlayToEndTime, object: playerItem ) // Get duration (async) Task { let duration = try? await asset.load(.duration) if let duration, duration.seconds.isFinite { await MainActor.run { self.duration = duration.seconds self.updateNowPlayingInfo() } } } // Start playing play() } } @objc private func playerDidFinishPlaying() { logger.info("Player finished playing") // Auto-play next track Task { await nextTrack() } } private func updatePlaybackTime(_ time: CMTime) { let seconds = time.seconds guard seconds.isFinite else { return } currentTime = seconds updateNowPlayingInfo() } private func cleanupPlayer() { if let timeObserver { player?.removeTimeObserver(timeObserver) self.timeObserver = nil } player?.pause() player = nil currentTime = 0 duration = 0 } // MARK: - Now Playing Info (Lock Screen) private func updateNowPlayingInfo() { guard let item = currentItem else { clearNowPlayingInfo() return } var nowPlayingInfo: [String: Any] = [:] // Track info nowPlayingInfo[MPMediaItemPropertyTitle] = item.name if let mediaItem = item.mediaItem { if let artists = mediaItem.artists, !artists.isEmpty { nowPlayingInfo[MPMediaItemPropertyArtist] = artists.map { $0.name }.joined(separator: ", ") } if let album = mediaItem.album { nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album.name } } // Duration & position nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 // Artwork (async load) if let mediaItem = item.mediaItem, let imageUrl = mediaItem.imageUrl, let coverURL = service.imageProxyURL(path: imageUrl, size: 512) { Task { await loadArtwork(from: coverURL, into: &nowPlayingInfo) } } MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } private func loadArtwork(from url: URL, into info: inout [String: Any]) async { do { let (data, _) = try await URLSession.shared.data(from: url) if let image = UIImage(data: data) { let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } await MainActor.run { var updatedInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] updatedInfo[MPMediaItemPropertyArtwork] = artwork MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedInfo } } } catch { logger.error("Failed to load artwork: \(error.localizedDescription)") } } private func clearNowPlayingInfo() { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } }