Compare commits

...

7 Commits

Author SHA1 Message Date
sibylla canton
6d5f0b2cce Fix iOS stream repository injection
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 12:11:49 +01:00
sibylla canton
30baf0cdcf Fix Kotlin Native iOS media session APIs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 12:11:49 +01:00
sibylla canton
5edafa1201 Remove redundant iOS validation workflow
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 12:11:48 +01:00
sibylla canton
b76a69d049 Add iOS background playback support
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 12:11:48 +01:00
Kingkor Roy Tirtho
f9c040015e
Merge pull request #3206 from team-spotube/ios-vlckit-player
feat(ios): play audio with VLCKit and drop AVPlayer format restrictions
2026-11-03 16:55:12 +06:00
Kingkor Roy Tirtho
538ba636d4 feat(ios): drop AVPlayer-era streaming format restrictions
VLC on iOS plays every container, codec and protocol plugins return, so iOS
no longer needs special casing:

- Stream selection no longer ranks WebM/MKV/Ogg or DASH streams as
  unplayable on iOS; the platform playability mechanism is removed.
- Opus in WebM is the default streaming format on every platform.
- Saved Opus/WebM preferences are no longer migrated to AAC on iOS.
- The streaming format picker offers all formats on iOS.
2026-11-03 16:53:36 +06:00
Kingkor Roy Tirtho
0a30aa7137 feat(ios): play audio with VLCKit instead of AVPlayer
AVPlayer misreads the duration of some fragmented MP4 streams (it reports
double the real length, leaving the second half silent and never ending the
track). Playback on iOS now goes through VLCKit 4, added as a Swift package
pinned to videolan/vlckit@6b87735.

- NativePlayerEngine (iosMain): single-media engine contract that Swift
  implements and registers at launch; VLCKit ships as an SPM binary target,
  so it can't be bound with cinterop from Gradle.
- AudioPlayer.ios.kt: rewritten on the engine. Playlist, loop modes, shuffle
  and auto-advance live in Kotlin, mirroring the JVM player (the old iOS
  player never advanced to the next track).
- VlcPlayerEngine.swift: VLCKit-backed engine. VLCKit 4 has no "ended" state,
  so natural ends are told apart from requested stops; after a pause/resume
  it stops reporting time, so a no-op seek restores position updates.
2026-11-03 16:53:36 +06:00
16 changed files with 951 additions and 320 deletions

View File

@ -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<DeviceAudioPlayerQueue>()
private val streamingUrlRepository = get<StreamingUrlRepository>()
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 -> {

View File

@ -23,11 +23,9 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.plugin.AudioPluginSource
import org.spotube.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.filterNotNull
@ -80,8 +78,7 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
@ -146,7 +143,7 @@ class StreamingUrlRepository(
} else {
stream.streams
}
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
}
@ -256,12 +253,11 @@ class StreamingUrlRepository(
logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
"(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"lowest wins):\n" +
streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " +
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
@ -269,10 +265,6 @@ class StreamingUrlRepository(
}
when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(platform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject"
}
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})"
@ -400,21 +392,16 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
}
}
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
/**
* Selects the available stream closest to the user's preferred format and quality.
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
* preferences say: e.g. opus/webm is unplayable on iOS.
* Selects the available stream closest to the user's preferred format and quality. Every
* platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container,
* codec and protocol plugins return, so nothing is ruled out up front.
*/
internal fun selectPreferredAudioStream(
streams: List<AudioStream>,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream ->
if (stream.isPlayableOn(platform)) 0 else 1
}.thenBy { stream ->
formatMismatchCount(stream, preferredFormat)
}.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1
@ -425,25 +412,6 @@ internal fun selectPreferredAudioStream(
}
)
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
// AVPlayer has no WebM/Ogg/Matroska demuxer.
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
/**
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) &&
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)

View File

@ -17,9 +17,6 @@
package org.spotube.spotube.modules.settings
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import kotlinx.serialization.SerialName
@ -101,23 +98,12 @@ const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
internal fun isJamServerUrl(url: String): Boolean =
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
/**
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
*/
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
val aac = !isContainerPlayableOn("webm", platform)
return AudioFormat(
codec = if (aac) "aac" else "opus",
container = if (aac) "mp4" else "webm",
/** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */
internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat(
codec = "opus",
container = "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)))
}
/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */
internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings =
if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this
else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform))

View File

@ -59,7 +59,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY]
if (json != null) {
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
settingsJson.decodeFromString<UserSettings>(json as String)
} else {
UserSettings() // Default value
}
@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first()
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
return settingsJson.decodeFromString<UserSettings>(storedJson)
}
}

View File

@ -18,8 +18,6 @@
package org.spotube.spotube.modules.settings.sections
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.theme.MillenniumTheme
@ -58,7 +56,6 @@ private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000))
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
internal val streamingFormatPresets = listOf(
AudioFormat(
codec = "opus",
@ -72,7 +69,7 @@ internal val streamingFormatPresets = listOf(
codec = "vorbis",
container = "ogg",
qualities = standardLossyQualities)
).filter { isContainerPlayableOn(it.container, currentPlatform) }
)
internal val downloadFormatPresets = listOf(
AudioFormat(

View File

@ -5,7 +5,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
@ -100,9 +99,7 @@ class StreamingUrlRepositoryBehaviorTest {
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs,
// Not the host's platform: Windows and iOS don't prefer opus/webm
platform = PlatformType.Linux)
streamUrlCacheTtlMs = cacheTtlMs)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)

View File

@ -1,6 +1,5 @@
package org.spotube.spotube.core.server
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
@ -14,7 +13,6 @@ import kotlin.test.assertSame
* Tests for the stream preference selection used by [StreamingUrlRepository].
*
* Ordering contract (best first):
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
* 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate.
@ -56,8 +54,7 @@ class StreamingUrlRepositoryTest {
private fun select(
streams: List<AudioStream>,
format: AudioFormat = opusWebm(),
quality: AudioQuality = AudioQuality.Lossy(256_000),
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
// ---------- empty / single ----------
@ -407,33 +404,20 @@ class StreamingUrlRepositoryTest {
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
}
// ---------- device playability ----------
// ---------- no platform restrictions ----------
@Test
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
fun `the preferred opus webm stream wins over aac`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
assertSame(opus, select(listOf(aac, opus)))
}
@Test
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
fun `a dash manifest closest to the preferences is not skipped`() {
val dash = lossy("dash", 256_000, protocol = StreamProtocol.DASH)
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
}
@Test
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
val opus = lossy("opus-160", 160_000)
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
}
@Test
fun `off iOS the preferred opus webm stream still wins`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
assertSame(dash, select(listOf(progressive, dash)))
}
}

View File

@ -17,39 +17,19 @@
package org.spotube.spotube.modules.settings
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.spotube.PlatformType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class StreamingFormatDefaultsTest {
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
@Test
fun `ios defaults to aac in mp4`() {
val format = defaultStreamingMusicFormat(PlatformType.IOS)
assertEquals("aac" to "mp4", format.codec to format.container)
fun `streaming defaults to opus in webm`() {
val format = defaultStreamingMusicFormat()
assertEquals("opus" to "webm", format.codec to format.container)
}
@Test
fun `other platforms keep opus in webm`() {
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
val format = defaultStreamingMusicFormat(platform)
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
}
}
@Test
fun `saved opus webm is moved to aac on ios`() {
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
assertEquals("mp4", migrated.streamingMusicFormat.container)
}
@Test
fun `saved opus webm is kept off ios`() {
val settings = UserSettings(streamingMusicFormat = opusWebm)
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
fun `user settings default to the streaming default`() {
assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat)
}
}

View File

@ -15,16 +15,12 @@
* 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.CValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -32,41 +28,39 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.AVPlayerTimeControlStatus
import platform.AVFoundation.AVPlayerTimeControlStatusPaused
import platform.AVFoundation.AVPlayerTimeControlStatusPlaying
import platform.AVFoundation.AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate
import platform.AVFoundation.addPeriodicTimeObserverForInterval
import platform.AVFoundation.currentItem
import platform.AVFoundation.duration
import platform.AVFoundation.loadedTimeRanges
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.seekToTime
import platform.AVFoundation.timeControlStatus
import platform.AVFoundation.volume
import platform.CoreMedia.CMTime
import platform.CoreMedia.CMTimeGetSeconds
import platform.Foundation.NSURL
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Duration.Companion.milliseconds
/**
* iOS player. Playback is done by VLCKit through [NativePlayerEngine]; AVPlayer misreports the
* duration of some fragmented MP4 streams (doubling it, which leaves the second half silent).
* The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here.
*/
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
actual val context: Any = context
private val avPlayer: AVPlayer = AVPlayer()
private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) {
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
}.create()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Mutex()
private val mediaSession = IosMediaSession()
private var disposed = false
private val originalPlaylist = mutableListOf<MediaItem>()
private val currentPlaylist = mutableListOf<MediaItem>()
private val urlIndexMap = mutableMapOf<String, Int>()
private var currentIndex = -1
private var shuffleEnabled = false
// The engine holds media only after the first play(); until then a requested start position
// is kept here and handed over with the media.
private var engineHasMedia = false
private var pendingStartMs = 0L
private val _playerState = MutableStateFlow(PlayerState.IDLE)
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
@ -94,97 +88,191 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
private val engineListener = object : NativePlayerListener {
override fun onState(state: NativePlayerState) {
if (disposed) return
when (state) {
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
NativePlayerState.OPENING,
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
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() } }
}
}
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) {
if (disposed || durationMs <= 0) return
_duration.tryEmit(durationMs.milliseconds)
}
override fun onBuffered(percent: Float) {
if (disposed) return
val durationMs = _duration.value.inWholeMilliseconds
_bufferingPosition.tryEmit(
if (durationMs > 0) (durationMs * (percent.coerceIn(0f, 100f) / 100f)).toLong().milliseconds
else Duration.ZERO
)
}
override fun onError(message: String) {
if (disposed) return
_playerState.tryEmit(PlayerState.IDLE)
_error.tryEmit(IllegalStateException("VLC playback error: $message"))
}
}
init {
avPlayer.addPeriodicTimeObserverForInterval(
interval = CMTimeMake(1, 4),
queue = null
) { time ->
if (!disposed) {
val seconds = CMTimeGetSeconds(time)
_position.tryEmit(seconds.seconds)
avPlayer.currentItem?.let { item ->
val durationSeconds = CMTimeGetSeconds(item.duration)
if (durationSeconds > 0) {
_duration.tryEmit(durationSeconds.seconds)
}
val loadedRanges = item.loadedTimeRanges
// if (loadedRanges.count() > 0u) {
// val range = loadedRanges.objectAtIndex(0u)
// }
}
}
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() } })
}
scope.launch {
while (!disposed) {
syncTimeControlStatus()
delay(500)
}
}
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
val item = currentPlaylist.getOrNull(currentIndex) ?: return
val startMs = pendingStartMs
pendingStartMs = 0L
engineHasMedia = true
_position.tryEmit(startMs.milliseconds)
_bufferingPosition.tryEmit(Duration.ZERO)
_duration.tryEmit(item.duration)
engine.load(item.url, autoPlay, startMs)
}
private fun syncTimeControlStatus() {
val status = avPlayer.timeControlStatus
if (status == lastTimeControlStatus) return
lastTimeControlStatus = status
val playerState = when (status) {
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
else -> PlayerState.IDLE
}
_playerState.tryEmit(playerState)
if (avPlayer.currentItem == null) {
_playerState.tryEmit(PlayerState.IDLE)
}
/** Switches to [index] and starts it. Caller holds [lock]. */
private fun playIndexLocked(index: Int) {
currentIndex = index
_currentMediaItem.tryEmit(currentPlaylist[index])
pendingStartMs = 0L
loadCurrentIntoEngineLocked(autoPlay = true)
}
private fun resolveState(): PlayerState {
return when (avPlayer.timeControlStatus) {
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
else -> PlayerState.IDLE
private fun onMediaEndedLocked() {
if (disposed || currentPlaylist.isEmpty()) return
_completion.tryEmit(Unit)
when {
_loopState.value == LoopState.ONE -> playIndexLocked(currentIndex.coerceAtLeast(0))
currentIndex < currentPlaylist.lastIndex -> playIndexLocked(currentIndex + 1)
_loopState.value == LoopState.ALL -> playIndexLocked(0)
else -> {
_playerState.tryEmit(PlayerState.COMPLETED)
engineHasMedia = false
}
}
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
val nsUrl = NSURL.URLWithString(url) ?: return null
return AVPlayerItem(nsUrl)
}
actual override suspend fun play() {
avPlayer.play()
lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return
mediaSession.activate()
if (currentIndex !in currentPlaylist.indices) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist[0])
}
if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
}
}
actual override suspend fun pause() {
avPlayer.pause()
lock.withLock {
if (disposed) return
engine.pause()
}
}
actual override suspend fun stop() {
avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1))
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)
_playerState.tryEmit(PlayerState.IDLE)
}
}
actual override suspend fun seekTo(position: Duration) {
val seconds = position.inWholeMilliseconds / 1000.0
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
avPlayer.seekToTime(cmTime)
_position.tryEmit(position)
lock.withLock {
if (disposed) return
val maxMs = _duration.value.inWholeMilliseconds
val targetMs = if (maxMs > 0) {
position.inWholeMilliseconds.coerceIn(0, maxMs)
} else {
position.inWholeMilliseconds.coerceAtLeast(0)
}
if (engineHasMedia) engine.seekTo(targetMs) else pendingStartMs = targetMs
_position.tryEmit(targetMs.milliseconds)
}
}
actual override suspend fun loop(state: LoopState) {
lock.withLock {
if (disposed) return
_loopState.tryEmit(state)
}
}
actual override suspend fun shuffle(enabled: Boolean) {
lock.withLock {
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
val currentItem = _currentMediaItem.value
val rebuilt = if (enabled) {
if (currentItem != null) {
listOf(currentItem) + originalPlaylist.filterNot { it.url == currentItem.url }.shuffled()
} else {
originalPlaylist.shuffled()
}
} else {
originalPlaylist.toList()
}
currentPlaylist.clear()
currentPlaylist.addAll(rebuilt)
shuffleEnabled = enabled
_shuffleMode.tryEmit(enabled)
_playlist.tryEmit(currentPlaylist.toList())
currentIndex = if (currentItem == null) 0 else {
currentPlaylist.indexOfFirst { it.url == currentItem.url }.takeIf { it >= 0 } ?: 0
}
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
}
}
actual override suspend fun load(
@ -192,143 +280,186 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
autoPlay: Boolean,
startPosition: Int
) {
lock.withLock {
if (disposed) return
originalPlaylist.clear()
originalPlaylist.addAll(playlist)
currentPlaylist.clear()
currentPlaylist.addAll(playlist)
urlIndexMap.clear()
playlist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
shuffleEnabled = false
_shuffleMode.tryEmit(false)
_playlist.tryEmit(currentPlaylist.toList())
if (playlist.isEmpty()) {
_playerState.tryEmit(PlayerState.IDLE)
_currentMediaItem.tryEmit(null)
engine.stop()
engineHasMedia = false
pendingStartMs = 0L
_position.tryEmit(Duration.ZERO)
_bufferingPosition.tryEmit(Duration.ZERO)
if (currentPlaylist.isEmpty()) {
currentIndex = -1
_currentMediaItem.tryEmit(null)
_playerState.tryEmit(PlayerState.IDLE)
_duration.tryEmit(Duration.ZERO)
return
}
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
val item = playlist[safeIndex]
val avItem = buildAVPlayerItem(item.url)
if (avItem == null) {
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
_playerState.tryEmit(PlayerState.IDLE)
return
}
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
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) {
avPlayer.play()
loadCurrentIntoEngineLocked(autoPlay = true)
} else {
_playerState.tryEmit(PlayerState.PAUSED)
}
}
}
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
lock.withLock {
if (disposed) return
originalPlaylist.add(mediaItem)
currentPlaylist.add(mediaItem)
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
_playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
}
}
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url
}
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
lock.withLock {
if (disposed) return
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem)
currentPlaylist.add(insertIndex, mediaItem)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
_playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
}
}
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
val index = urlIndexMap[mediaItem.url] ?: return
currentPlaylist.removeAt(index)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, item ->
urlIndexMap[item.url] = i
lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return
originalPlaylist.removeAll { it.url == mediaItem.url }
val currentUrl = _currentMediaItem.value?.url
val removedCurrent = currentUrl == mediaItem.url
val removalIndices = currentPlaylist.withIndex()
.filter { it.value.url == mediaItem.url }
.map { it.index }
if (removalIndices.isEmpty()) return
// Items before the playing one shift it down, so keep the index on the same item.
val removedBefore = removalIndices.count { it < currentIndex }
removalIndices.asReversed().forEach { currentPlaylist.removeAt(it) }
if (currentPlaylist.isEmpty()) {
currentIndex = -1
_currentMediaItem.tryEmit(null)
engine.stop()
engineHasMedia = false
_playerState.tryEmit(PlayerState.IDLE)
} else if (removedCurrent) {
currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex)
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
engine.stop()
engineHasMedia = false
} else {
currentIndex = (currentIndex - removedBefore).coerceIn(0, currentPlaylist.lastIndex)
}
_playlist.tryEmit(currentPlaylist.toList())
}
}
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
lock.withLock {
if (disposed) return
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
val item = currentPlaylist.removeAt(fromIndex)
currentPlaylist.add(toIndex, item)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, it ->
urlIndexMap[it.url] = i
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
}
val currentUrl = _currentMediaItem.value?.url
currentIndex = currentUrl?.let { url ->
currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 }
} ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex)
_playlist.tryEmit(currentPlaylist.toList())
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
}
}
actual override suspend fun skipToNext() {
val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url
}
val nextIndex = currentIndex + 1
if (nextIndex < currentPlaylist.size) {
val nextItem = currentPlaylist[nextIndex]
val avItem = buildAVPlayerItem(nextItem.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(nextItem)
avPlayer.play()
}
lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return
playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex))
}
}
actual override suspend fun skipToPrevious() {
val currentIndex = currentPlaylist.indexOfFirst {
it.url == _currentMediaItem.value?.url
}
val prevIndex = currentIndex - 1
if (prevIndex >= 0) {
val prevItem = currentPlaylist[prevIndex]
val avItem = buildAVPlayerItem(prevItem.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(prevItem)
avPlayer.play()
}
lock.withLock {
if (disposed || currentPlaylist.isEmpty()) return
playIndexLocked((currentIndex - 1).coerceAtLeast(0))
}
}
actual override suspend fun jumpTo(index: Int) {
if (index in currentPlaylist.indices) {
val item = currentPlaylist[index]
val avItem = buildAVPlayerItem(item.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
}
lock.withLock {
if (disposed || index !in currentPlaylist.indices) return
playIndexLocked(index)
}
}
actual override suspend fun setVolume(volume: Float) {
lock.withLock {
if (disposed) return
val clamped = volume.coerceIn(0f, 1f)
avPlayer.volume = clamped
engine.setVolume(clamped)
_volume.tryEmit(clamped)
}
}
actual override suspend fun setPlaybackSpeed(speed: Float) {
lock.withLock {
if (disposed) return
val clamped = speed.coerceIn(0.25f, 4f)
avPlayer.rate = clamped
engine.setRate(clamped)
_playbackSpeed.tryEmit(clamped)
}
}
actual override fun isDisposed(): Boolean = disposed
actual override fun dispose() {
if (disposed) return
disposed = true
avPlayer.pause()
avPlayer.replaceCurrentItemWithPlayerItem(null)
engine.setListener(null)
engine.destroy()
mediaSession.deactivate()
mediaSession.clear()
scope.cancel()
originalPlaylist.clear()
currentPlaylist.clear()
urlIndexMap.clear()
currentIndex = -1
engineHasMedia = false
_playerState.tryEmit(PlayerState.IDLE)
_currentMediaItem.tryEmit(null)
@ -338,11 +469,3 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_bufferingPosition.tryEmit(Duration.ZERO)
}
}
private fun CMTimeMake(value: Long, timescale: Int): CValue<CMTime> {
return platform.CoreMedia.CMTimeMake(value, timescale)
}
private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue<CMTime> {
return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale)
}

View File

@ -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 <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.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<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 = buildMap<Any?, Any?> {
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
}
}
}

View File

@ -0,0 +1,81 @@
/*
* 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/>.
*/
package org.spotube.spotube.core.audioplayer
/**
* Single-media playback engine implemented in Swift on top of VLCKit (`iosApp/iosApp/VlcPlayerEngine.swift`).
*
* VLCKit ships as a SwiftPM binary target, so it can't be bound with cinterop from Gradle. Swift
* implements this contract instead and registers it in [NativePlayerRegistry] before the Compose
* view controller is created. The playlist, loop and shuffle logic stays in Kotlin
* ([AudioPlayer]); the engine only ever plays one URL at a time.
*
* Implementations may be called from any thread and must hop to the thread VLCKit needs
* themselves. [NativePlayerListener] callbacks may arrive on any thread.
*/
interface NativePlayerEngine {
fun setListener(listener: NativePlayerListener?)
/** Replaces the current media. Starts playing right away when [autoPlay] is set. */
fun load(url: String, autoPlay: Boolean, startPositionMs: Long)
fun play()
fun pause()
fun stop()
fun seekTo(positionMs: Long)
/** [volume] is 0..1. */
fun setVolume(volume: Float)
fun setRate(rate: Float)
/**
* Releases the native player. The engine must not be used afterwards. Not named `release`:
* that would export to Objective-C as `-release` and clash with NSObject.
*/
fun destroy()
}
interface NativePlayerListener {
fun onState(state: NativePlayerState)
fun onTime(positionMs: Long)
fun onDuration(durationMs: Long)
/** Network cache fill of the current media, 0..100. */
fun onBuffered(percent: Float)
fun onError(message: String)
}
enum class NativePlayerState {
/** Nothing loaded, or playback was stopped. */
IDLE,
OPENING,
BUFFERING,
PLAYING,
PAUSED,
/** The media played to its end. Sent once per loaded media. */
ENDED
}
interface NativePlayerEngineFactory {
fun create(): NativePlayerEngine
}
/** Set from Swift (`iOSApp.init`) before the first [AudioPlayer] is created. */
object NativePlayerRegistry {
var factory: NativePlayerEngineFactory? = null
}

View File

@ -6,6 +6,10 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -41,6 +45,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -85,6 +90,7 @@
);
name = iosApp;
packageProductDependencies = (
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
);
productName = iosApp;
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
@ -114,6 +120,9 @@
);
mainGroup = EFA169D322B90F32841D602F;
minimizedProjectReferenceProxies = 1;
packageReferences = (
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
projectDirPath = "";
@ -368,6 +377,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/videolan/vlckit";
requirement = {
kind = revision;
revision = 6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
9A1B2C3D4E5F60718293A4B3 /* VLCKit */ = {
isa = XCSwiftPackageProductDependency;
package = 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */;
productName = VLCKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = B056E79C138AE849968965F1 /* Project object */;
}

View File

@ -0,0 +1,14 @@
{
"originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b",
"pins" : [
{
"identity" : "vlckit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/videolan/vlckit",
"state" : {
"revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f"
}
}
],
"version" : 3
}

View File

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

View File

@ -0,0 +1,256 @@
// 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/>.
import AVFoundation
import ComposeApp
import Foundation
import VLCKit
/// Registers the VLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first
/// `ComposeView` is created.
func registerNativePlayerEngine() {
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory()
}
final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory {
func create() -> NativePlayerEngine {
VlcPlayerEngine()
}
}
/// Plays one URL at a time with VLCKit. The Kotlin `AudioPlayer` owns the playlist; see
/// `NativePlayerEngine.kt` for the contract.
///
/// Every VLCKit call is made on the main queue, in the order the Kotlin side issued it.
final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
private let player = VLCMediaPlayer()
private var listener: NativePlayerListener?
// VLCKit 4 has no "ended" state: reaching the end of a media goes Stopping -> Stopped, the
// same as stop(). `stopRequested` marks the stops this class asked for, so any other stop of
// a media that had started playing is a natural end.
private var stopRequested = false
private var hasStarted = false
private var finished = false
private var released = false
private var rate: Float = 1
private var volumePercent: Int32 = 100
override init() {
super.init()
player.delegate = self
player.timeChangeUpdateInterval = 0.25
player.minimalTimePeriod = 250_000
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
}
// MARK: NativePlayerEngine
func setListener(listener: NativePlayerListener?) {
onMain { self.listener = listener }
}
func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
onMain {
guard !self.released else { return }
guard let mediaUrl = URL(string: url), let media = VLCMedia(url: mediaUrl) else {
self.listener?.onError(message: "Invalid URL: \(url)")
return
}
self.stopCurrentMedia()
self.hasStarted = false
self.finished = false
if startPositionMs > 0 {
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
}
self.player.media = media
self.listener?.onState(state: .opening)
if autoPlay { self.startPlayback() }
}
}
func play() {
onMain {
guard !self.released else { return }
self.startPlayback()
}
}
func pause() {
onMain {
guard !self.released, self.player.isPlaying else { return }
self.player.pause()
}
}
func stop() {
onMain {
guard !self.released else { return }
self.stopCurrentMedia()
}
}
func seekTo(positionMs: Int64) {
onMain {
guard !self.released, self.player.isSeekable else { return }
self.player.time = VLCTime(number: NSNumber(value: positionMs))
}
}
func setVolume(volume: Float) {
onMain {
self.volumePercent = Int32((min(max(volume, 0), 1) * 100).rounded())
self.player.audio?.volume = self.volumePercent
}
}
func setRate(rate: Float) {
onMain {
self.rate = rate
self.player.rate = rate
}
}
func destroy() {
onMain {
guard !self.released else { return }
self.released = true
self.listener = nil
self.player.delegate = nil
self.player.stop()
self.player.media = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
// MARK: VLCMediaPlayerDelegate
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
onMain {
guard !self.released else { return }
switch newState {
case .opening:
self.listener?.onState(state: .opening)
case .playing:
self.hasStarted = true
self.applyPlaybackSettings()
self.reportDuration()
self.listener?.onState(state: .playing)
case .paused:
self.listener?.onState(state: .paused)
case .stopping, .stopped:
self.handleStop(isFinalStop: newState == .stopped)
case .error:
self.finished = true
self.listener?.onError(message: "VLC could not play this media")
self.listener?.onState(state: .idle)
default:
break
}
}
}
func mediaPlayerTimeChanged(_ aNotification: Notification) {
onMain {
guard !self.released, let ms = self.player.time.value?.int64Value, ms >= 0 else { return }
self.listener?.onTime(positionMs: ms)
}
}
func mediaPlayerLengthChanged(_ length: Int64) {
onMain {
guard !self.released else { return }
self.listener?.onDuration(durationMs: length)
}
}
func mediaPlayerBufferingChanged(_ progress: Float) {
onMain {
guard !self.released else { return }
self.listener?.onBuffered(percent: progress * 100)
// VLC always reports 0 and 1 before the first frame; only mid-playback stalls matter.
guard self.hasStarted else { return }
if progress < 1 {
self.listener?.onState(state: .buffering)
} else if self.player.isPlaying {
self.listener?.onState(state: .playing)
}
}
}
// MARK: Internals
private func startPlayback() {
try? AVAudioSession.sharedInstance().setActive(true)
let wasPaused = player.state == .paused
player.play()
if wasPaused { kickTimeReporting() }
}
/// After a pause, VLCKit 4 withholds time updates until libvlc reports a fresh time point, and
/// on iOS that report doesn't come after a resume: audio plays but the position stays frozen.
/// A seek goes through a different callback that does reset VLCKit's time tracking, so seek
/// to where we already are.
private func kickTimeReporting() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self, !self.released, self.player.isPlaying, self.player.isSeekable else { return }
self.player.time = self.player.time
}
}
/// Stops the current media and marks the resulting stop events as ours.
private func stopCurrentMedia() {
switch player.state {
case .opening, .playing, .paused, .stopping:
stopRequested = true
player.stop()
default:
break
}
}
private func handleStop(isFinalStop: Bool) {
if stopRequested {
if isFinalStop {
stopRequested = false
listener?.onState(state: .idle)
}
return
}
if hasStarted && !finished {
finished = true
listener?.onState(state: .ended)
} else if isFinalStop && !finished {
listener?.onState(state: .idle)
}
}
private func applyPlaybackSettings() {
player.audio?.volume = volumePercent
if player.rate != rate { player.rate = rate }
}
private func reportDuration() {
if let ms = player.media?.length.value?.int64Value, ms > 0 {
listener?.onDuration(durationMs: ms)
}
}
private func onMain(_ work: @escaping () -> Void) {
DispatchQueue.main.async(execute: work)
}
}

View File

@ -18,6 +18,10 @@ import ComposeApp
@main
struct iOSApp: App {
init() {
registerNativePlayerEngine()
}
var body: some Scene {
WindowGroup {
ContentView()