445 lines
13 KiB
Swift
445 lines
13 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|