Initial Commit

This commit is contained in:
2026-03-27 09:21:41 +01:00
commit e9b6412d71
40 changed files with 6801 additions and 0 deletions
@@ -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
}
}