Initial Commit
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user