From 4639b50a3d0f198b81f8272648bd52a7a28991f2 Mon Sep 17 00:00:00 2001 From: Samuele Giacomin Date: Tue, 3 Nov 2026 13:04:04 +0100 Subject: [PATCH] feat: Add iOS background playback support (#3170) * Add iOS background playback support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove redundant iOS validation workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix Kotlin Native iOS media session APIs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix iOS stream repository injection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: sibylla canton Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../audioplayer/DeviceAudioPlayerQueue.kt | 26 ++- .../core/audioplayer/AudioPlayer.ios.kt | 44 ++++- .../core/audioplayer/IosMediaSession.kt | 187 ++++++++++++++++++ iosApp/iosApp/Info.plist | 4 + 4 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 7798ab77..56344386 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -18,7 +18,10 @@ package org.spotube.spotube.core.audioplayer import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import org.spotube.spotube.PlatformType +import org.spotube.spotube.getPlatform import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository import org.spotube.spotube.modules.plugin.PluginProvider @@ -51,6 +54,7 @@ class DeviceAudioPlayerQueue( private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() + private val streamingUrlRepository = get() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -515,7 +519,25 @@ class DeviceAudioPlayerQueue( private suspend fun QueueEntry.toMediaItem(): MediaItem? { return when (this) { is QueueEntry.StreamingTrack -> { - val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) } + val isIos = getPlatform().type == PlatformType.IOS + val requiresIosResolution = isIos && ( + url.isBlank() || + url.startsWith("http://127.0.0.1") || + url.startsWith("https://127.0.0.1") || + url.startsWith("http://[::1]") || + url.startsWith("https://[::1]") || + url.startsWith("http://localhost") || + url.startsWith("https://localhost")) + val resolvedStream = if (requiresIosResolution) { + streamingUrlRepository.resolveStreamInfo(track) + } else { + null + } + val resolvedUrl = if (requiresIosResolution) { + resolvedStream?.url ?: buildStreamingUrl(track.id, protocol) + } else { + url.ifBlank { buildStreamingUrl(track.id, protocol) } + } if (resolvedUrl.isBlank()) return null MediaItem( title = track.title, @@ -524,7 +546,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = artworkUrl.orEmpty(), url = resolvedUrl, - protocol = protocol) + protocol = resolvedStream?.protocol ?: protocol) } is QueueEntry.LocalTrack -> { diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index d5df44b4..d2bac3ca 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -48,6 +48,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface }.create() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val lock = Mutex() + private val mediaSession = IosMediaSession() private var disposed = false @@ -94,8 +95,22 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE) NativePlayerState.OPENING, NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING) - NativePlayerState.PLAYING -> _playerState.tryEmit(PlayerState.PLAYING) - NativePlayerState.PAUSED -> _playerState.tryEmit(PlayerState.PAUSED) + NativePlayerState.PLAYING -> { + _playerState.tryEmit(PlayerState.PLAYING) + mediaSession.update( + _currentMediaItem.value, + _position.value.inWholeMilliseconds / 1000.0, + _duration.value.inWholeMilliseconds / 1000.0, + true) + } + NativePlayerState.PAUSED -> { + _playerState.tryEmit(PlayerState.PAUSED) + mediaSession.update( + _currentMediaItem.value, + _position.value.inWholeMilliseconds / 1000.0, + _duration.value.inWholeMilliseconds / 1000.0, + false) + } NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } } } } @@ -103,6 +118,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface override fun onTime(positionMs: Long) { if (disposed || positionMs < 0) return _position.tryEmit(positionMs.milliseconds) + mediaSession.update( + _currentMediaItem.value, + positionMs / 1000.0, + _duration.value.inWholeMilliseconds / 1000.0, + _playerState.value == PlayerState.PLAYING) } override fun onDuration(durationMs: Long) { @@ -128,6 +148,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface init { engine.setListener(engineListener) + mediaSession.configure( + onPlay = { scope.launch { play() } }, + onPause = { scope.launch { pause() } }, + onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } }, + onNext = { scope.launch { skipToNext() } }, + onPrevious = { scope.launch { skipToPrevious() } }) } /** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */ @@ -167,6 +193,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun play() { lock.withLock { if (disposed || currentPlaylist.isEmpty()) return + mediaSession.activate() if (currentIndex !in currentPlaylist.indices) { currentIndex = 0 _currentMediaItem.tryEmit(currentPlaylist[0]) @@ -186,6 +213,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface lock.withLock { if (disposed) return engine.stop() + mediaSession.deactivate() + mediaSession.update( + _currentMediaItem.value, + 0.0, + _duration.value.inWholeMilliseconds / 1000.0, + false) engineHasMedia = false pendingStartMs = 0L _position.tryEmit(Duration.ZERO) @@ -275,6 +308,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex) _currentMediaItem.tryEmit(currentPlaylist[currentIndex]) _duration.tryEmit(currentPlaylist[currentIndex].duration) + mediaSession.update( + _currentMediaItem.value, + 0.0, + _duration.value.inWholeMilliseconds / 1000.0, + false) if (autoPlay) { loadCurrentIntoEngineLocked(autoPlay = true) @@ -414,6 +452,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface disposed = true engine.setListener(null) engine.destroy() + mediaSession.deactivate() + mediaSession.clear() scope.cancel() originalPlaylist.clear() diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt new file mode 100644 index 00000000..6264c595 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/IosMediaSession.kt @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package org.spotube.spotube.core.audioplayer + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFAudio.AVAudioSessionModeDefault +import platform.AVFAudio.setActive +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSNumber +import platform.Foundation.NSOperationQueue +import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent +import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle +import platform.MediaPlayer.MPMediaItemPropertyArtist +import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration +import platform.MediaPlayer.MPMediaItemPropertyTitle +import platform.MediaPlayer.MPNowPlayingInfoCenter +import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime +import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate +import platform.MediaPlayer.MPRemoteCommand +import platform.MediaPlayer.MPRemoteCommandCenter +import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed +import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess + +internal class IosMediaSession { + private val audioSession = AVAudioSession.sharedInstance() + private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter() + private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter() + + private var configured = false + private val notificationCenter = NSNotificationCenter.defaultCenter() + private val notificationTokens = mutableListOf() + private val remoteCommandTokens = mutableListOf>() + private var onPlay: (() -> Unit)? = null + private var onPause: (() -> Unit)? = null + private var onSeek: ((Double) -> Unit)? = null + private var onNext: (() -> Unit)? = null + private var onPrevious: (() -> Unit)? = null + + fun configure( + onPlay: () -> Unit, + onPause: () -> Unit, + onSeek: (Double) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit + ) { + this.onPlay = onPlay + this.onPause = onPause + this.onSeek = onSeek + this.onNext = onNext + this.onPrevious = onPrevious + + if (configured) return + configured = true + + audioSession.setCategory( + category = AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeDefault, + options = 0u, + error = null) + registerAudioSessionNotifications() + registerRemoteCommands() + } + + fun activate() { + audioSession.setActive(true, error = null) + } + + fun deactivate() { + audioSession.setActive(false, error = null) + } + + fun update( + item: MediaItem?, + positionSeconds: Double, + durationSeconds: Double, + isPlaying: Boolean + ) { + if (item == null) { + nowPlayingInfoCenter.nowPlayingInfo = null + return + } + + val info = buildMap { + put(MPMediaItemPropertyTitle, item.title) + put(MPMediaItemPropertyArtist, item.artist) + item.album.takeIf { it.isNotBlank() }?.let { + put(MPMediaItemPropertyAlbumTitle, it) + } + if (durationSeconds.isFinite() && durationSeconds > 0) { + put(MPMediaItemPropertyPlaybackDuration, durationSeconds) + } + if (positionSeconds.isFinite() && positionSeconds >= 0) { + put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds) + } + put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0) + } + nowPlayingInfoCenter.nowPlayingInfo = info + } + + fun clear() { + nowPlayingInfoCenter.nowPlayingInfo = null + notificationTokens.forEach { notificationCenter.removeObserver(it) } + notificationTokens.clear() + remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) } + remoteCommandTokens.clear() + configured = false + onPlay = null + onPause = null + onSeek = null + onNext = null + onPrevious = null + } + + private fun registerAudioSessionNotifications() { + notificationCenter.addObserverForName( + name = platform.AVFAudio.AVAudioSessionInterruptionNotification, + `object` = audioSession, + queue = NSOperationQueue.mainQueue()) { notification -> + val type = notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber + if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) { + onPause?.invoke() + } else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) { + activate() + val options = notification?.userInfo?.get( + platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber + val shouldResume = options?.unsignedLongValue + ?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume) + ?.let { it != 0UL } == true + if (shouldResume) onPlay?.invoke() + } + }?.let { notificationTokens += it } + } + + private fun registerRemoteCommands() { + fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) { + command.addTargetWithHandler(handler)?.let { + remoteCommandTokens += command to it + } + } + + register(remoteCommandCenter.playCommand) { + onPlay?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + register(remoteCommandCenter.pauseCommand) { + onPause?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + register(remoteCommandCenter.changePlaybackPositionCommand) { event -> + val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime + if (position != null && position.isFinite() && position >= 0) { + onSeek?.invoke(position) + MPRemoteCommandHandlerStatusSuccess + } else { + MPRemoteCommandHandlerStatusCommandFailed + } + } + register(remoteCommandCenter.nextTrackCommand) { + onNext?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + register(remoteCommandCenter.previousTrackCommand) { + onPrevious?.invoke() + MPRemoteCommandHandlerStatusSuccess + } + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5663e50d..4a484863 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,10 @@ CADisableMinimumFrameDurationOnPhone + UIBackgroundModes + + audio + CFBundleURLTypes