Add iOS background playback support

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
sibylla canton 2026-10-27 18:56:48 +01:00
parent 0d5b5b094e
commit 05d92a73fd
5 changed files with 370 additions and 5 deletions

127
.github/workflows/ios-validation.yml vendored Normal file
View File

@ -0,0 +1,127 @@
name: iOS validation
on:
pull_request:
paths:
- "composeApp/src/commonMain/**"
- "composeApp/src/iosMain/**"
- "iosApp/**"
- "composeApp/build.gradle.kts"
- "gradle/**"
- "gradle.properties"
- "settings.gradle.kts"
- ".github/workflows/ios-validation.yml"
push:
branches:
- master
paths:
- "composeApp/src/commonMain/**"
- "composeApp/src/iosMain/**"
- "iosApp/**"
- "composeApp/build.gradle.kts"
- "gradle/**"
- "gradle.properties"
- "settings.gradle.kts"
- ".github/workflows/ios-validation.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-validation-${{ github.ref }}
cancel-in-progress: true
env:
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
jobs:
prepare-deps:
name: Prepare dependencies
runs-on: macos-latest
steps:
- name: Check out ComposeNativeWebview
uses: actions/checkout@v4
with:
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
path: compose-webview
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Publish ComposeNativeWebview locally
working-directory: compose-webview
run: |
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Upload Maven local repository
uses: actions/upload-artifact@v4
with:
name: maven-local
path: ~/.m2/repository/
build-ios:
name: Build unsigned iOS app
needs: prepare-deps
runs-on: macos-latest
timeout-minutes: 120
steps:
- name: Check out Spotube
uses: actions/checkout@v4
- name: Download Maven local repository
uses: actions/download-artifact@v4
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make Gradle executable
run: chmod +x gradlew
- name: Compile Kotlin iOS simulator target
run: ./gradlew :composeApp:compileKotlinIosSimulatorArm64
- name: Build unsigned iOS app
run: >
xcodebuild
-project iosApp/iosApp.xcodeproj
-scheme iosApp
-configuration Release
-sdk iphonesimulator
-destination 'generic/platform=iOS Simulator'
-derivedDataPath build/ios
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_REQUIRED=NO
CODE_SIGN_IDENTITY=""
DEVELOPMENT_TEAM=""
build

View File

@ -18,7 +18,10 @@
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol 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.di.injectLogger
import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
@ -37,6 +40,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -48,6 +52,7 @@ class DeviceAudioPlayerQueue(
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
private val streamingUrlRepository by inject<StreamingUrlRepository>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -456,7 +461,25 @@ class DeviceAudioPlayerQueue(
private suspend fun QueueEntry.toMediaItem(): MediaItem? { private suspend fun QueueEntry.toMediaItem(): MediaItem? {
return when (this) { return when (this) {
is QueueEntry.StreamingTrack -> { 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 if (resolvedUrl.isBlank()) return null
MediaItem( MediaItem(
title = track.title, title = track.title,
@ -465,7 +488,7 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = artworkUrl.orEmpty(), coverURL = artworkUrl.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol) protocol = resolvedStream?.protocol ?: protocol)
} }
is QueueEntry.LocalTrack -> { is QueueEntry.LocalTrack -> {

View File

@ -62,6 +62,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val avPlayer: AVPlayer = AVPlayer() private val avPlayer: AVPlayer = AVPlayer()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mediaSession = IosMediaSession()
private var disposed = false private var disposed = false
@ -97,6 +98,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
init { init {
mediaSession.configure(
onPlay = { scope.launch { play() } },
onPause = { scope.launch { pause() } },
onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } },
onNext = { scope.launch { skipToNext() } },
onPrevious = { scope.launch { skipToPrevious() } })
avPlayer.addPeriodicTimeObserverForInterval( avPlayer.addPeriodicTimeObserverForInterval(
interval = CMTimeMake(1, 4), interval = CMTimeMake(1, 4),
queue = null queue = null
@ -115,6 +123,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
// val range = loadedRanges.objectAtIndex(0u) // val range = loadedRanges.objectAtIndex(0u)
// } // }
} }
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = seconds,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying)
} }
} }
@ -159,6 +172,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
} }
actual override suspend fun play() { actual override suspend fun play() {
mediaSession.activate()
avPlayer.play() avPlayer.play()
} }
@ -170,6 +184,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
avPlayer.pause() avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1)) avPlayer.seekToTime(CMTimeMake(0, 1))
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
mediaSession.deactivate()
mediaSession.update(
item = _currentMediaItem.value,
positionSeconds = 0.0,
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
isPlaying = false)
} }
actual override suspend fun seekTo(position: Duration) { actual override suspend fun seekTo(position: Duration) {
@ -220,9 +240,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item) _currentMediaItem.tryEmit(item)
mediaSession.update(
item = item,
positionSeconds = 0.0,
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
isPlaying = false)
if (autoPlay) { if (autoPlay) {
avPlayer.play() play()
} }
} }
@ -277,7 +302,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) { if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(nextItem) _currentMediaItem.tryEmit(nextItem)
avPlayer.play() play()
} }
} }
} }
@ -293,7 +318,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
if (avItem != null) { if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem) avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(prevItem) _currentMediaItem.tryEmit(prevItem)
avPlayer.play() play()
} }
} }
} }
@ -327,6 +352,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
disposed = true disposed = true
avPlayer.pause() avPlayer.pause()
avPlayer.replaceCurrentItemWithPlayerItem(null) avPlayer.replaceCurrentItemWithPlayerItem(null)
mediaSession.deactivate()
mediaSession.clear()
currentPlaylist.clear() currentPlaylist.clear()
urlIndexMap.clear() urlIndexMap.clear()

View File

@ -0,0 +1,184 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
@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.Foundation.NSDictionary
import platform.Foundation.NSMutableDictionary
import platform.Foundation.NSNotificationCenter
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<Any>()
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
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 = NSMutableDictionary()
info.setObject(item.title, forKey = MPMediaItemPropertyTitle)
info.setObject(item.artist, forKey = MPMediaItemPropertyArtist)
item.album.takeIf { it.isNotBlank() }?.let {
info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle)
}
if (durationSeconds.isFinite() && durationSeconds > 0) {
info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration)
}
if (positionSeconds.isFinite() && positionSeconds >= 0) {
info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime)
}
info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate)
nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary
}
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() {
notificationTokens += notificationCenter.addObserverForName(
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
`object` = audioSession,
queue = NSOperationQueue.mainQueue()) { notification ->
val type = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? Number
if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
onPause?.invoke()
} else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
activate()
val options = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number
val shouldResume = (options?.intValue ?: 0) and
platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0
if (shouldResume) onPlay?.invoke()
}
}
}
private fun registerRemoteCommands() {
remoteCommandTokens += remoteCommandCenter.playCommand to
remoteCommandCenter.playCommand.addTargetWithHandler {
onPlay?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.pauseCommand to
remoteCommandCenter.pauseCommand.addTargetWithHandler {
onPause?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to
remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event ->
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
if (position != null && position.isFinite() && position >= 0) {
onSeek?.invoke(position)
MPRemoteCommandHandlerStatusSuccess
} else {
MPRemoteCommandHandlerStatusCommandFailed
}
}
remoteCommandTokens += remoteCommandCenter.nextTrackCommand to
remoteCommandCenter.nextTrackCommand.addTargetWithHandler {
onNext?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
remoteCommandTokens += remoteCommandCenter.previousTrackCommand to
remoteCommandCenter.previousTrackCommand.addTargetWithHandler {
onPrevious?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
}
}

View File

@ -4,6 +4,10 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>