Compare commits

..

No commits in common. "6223c438cfe3ac77c5f5611d87a222c877f2d10b" and "784d9260d11259bb32d27acd329fa815bc20cd3d" have entirely different histories.

20 changed files with 452 additions and 1075 deletions

View File

@ -659,16 +659,6 @@ jobs:
- name: Make gradlew executable - name: Make gradlew executable
run: chmod +x gradlew run: chmod +x gradlew
# MobileVLCKit comes from CocoaPods; iosApp/Pods/ is gitignored and the workspace needs it.
- name: Cache CocoaPods
uses: actions/cache@v6
with:
path: iosApp/Pods
key: pods-${{ runner.os }}-${{ hashFiles('iosApp/Podfile.lock') }}
- name: Install CocoaPods dependencies
run: pod install --deployment --project-directory=iosApp
# Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading # Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading
# (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate. # (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate.
# The Gradle build phase inside Xcode reads these as project properties. # The Gradle build phase inside Xcode reads these as project properties.
@ -678,7 +668,7 @@ jobs:
ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }} ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }}
run: > run: >
xcodebuild xcodebuild
-workspace iosApp/iosApp.xcworkspace -project iosApp/iosApp.xcodeproj
-scheme iosApp -scheme iosApp
-configuration Release -configuration Release
-sdk iphoneos -sdk iphoneos

1
.gitignore vendored
View File

@ -16,7 +16,6 @@ captures
!*.xcodeproj/project.xcworkspace/ !*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
iosApp/Pods/
node_modules/ node_modules/
composeApp/release/ composeApp/release/

View File

@ -8,7 +8,6 @@
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). - Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`).
- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. - Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`.
- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. - iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
- iOS audio: MobileVLCKit from CocoaPods (`iosApp/Podfile`), driven by `iosApp/iosApp/VlcPlayerEngine.swift` through `NativePlayerEngine` (iosMain). Run `pod install` in `iosApp/` first, then build `iosApp/iosApp.xcworkspace` (not the `.xcodeproj`, which can't build the Pods). Android Studio's iOS run config must point at the workspace too.
## High-value commands ## High-value commands
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.

View File

@ -23,9 +23,11 @@ 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.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.plugin.AudioPluginSource
import org.spotube.spotube.modules.settings.UserSettingsSource import org.spotube.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
@ -78,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -143,7 +146,7 @@ class StreamingUrlRepository(
} else { } else {
stream.streams stream.streams
} }
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
} }
@ -253,11 +256,12 @@ class StreamingUrlRepository(
logger.i { logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"lowest wins):\n" + "lowest wins):\n" +
streams.joinToString("\n") { stream -> streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " " val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
@ -265,6 +269,10 @@ class StreamingUrlRepository(
} }
when { when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" } 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 { formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})" "got ${selected.codec}/${selected.container} (${selected.protocol})"
@ -392,16 +400,21 @@ 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. Every * Selects the available stream closest to the user's preferred format and quality.
* platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container, * Streams the [platform]'s player can decode always win over ones it can't, whatever the
* codec and protocol plugins return, so nothing is ruled out up front. * preferences say: e.g. opus/webm is unplayable on iOS.
*/ */
internal fun selectPreferredAudioStream( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( preferredQuality: AudioQuality,
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream -> compareBy<AudioStream> { stream ->
if (stream.isPlayableOn(platform)) 0 else 1
}.thenBy { stream ->
formatMismatchCount(stream, preferredFormat) formatMismatchCount(stream, preferredFormat)
}.thenBy { stream -> }.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1 if (stream.matchesQualityType(preferredQuality)) 0 else 1
@ -412,6 +425,25 @@ 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 = private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)

View File

@ -17,6 +17,9 @@
package org.spotube.spotube.modules.settings 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.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -92,18 +95,29 @@ data class UserSettings(
val lastSupportReminderEpochMs: Long = 0L) val lastSupportReminderEpochMs: Long = 0L)
/** The public Group Jam room server. */ /** The public Group Jam room server. */
const val DEFAULT_JAM_SERVER_URL = "https://jam.spotube.cc" const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */ /** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
internal fun isJamServerUrl(url: String): Boolean = internal fun isJamServerUrl(url: String): Boolean =
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim()) Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
/** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */ /**
internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat( * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
codec = "opus", * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
container = "webm", */
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",
qualities = listOf( qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_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 -> override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
settingsJson.decodeFromString<UserSettings>(json as String) settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
suspend fun getCurrentSettings(): UserSettings { suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first() val preferences = database.settingsDataStore.data.first()
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return settingsJson.decodeFromString<UserSettings>(storedJson) return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
} }
} }

View File

@ -18,6 +18,8 @@
package org.spotube.spotube.modules.settings.sections 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.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_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( internal val streamingFormatPresets = listOf(
AudioFormat( AudioFormat(
codec = "opus", codec = "opus",
@ -69,7 +72,7 @@ internal val streamingFormatPresets = listOf(
codec = "vorbis", codec = "vorbis",
container = "ogg", container = "ogg",
qualities = standardLossyQualities) qualities = standardLossyQualities)
) ).filter { isContainerPlayableOn(it.container, currentPlatform) }
internal val downloadFormatPresets = listOf( internal val downloadFormatPresets = listOf(
AudioFormat( AudioFormat(

View File

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

View File

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

View File

@ -17,19 +17,39 @@
package org.spotube.spotube.modules.settings 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertSame
class StreamingFormatDefaultsTest { class StreamingFormatDefaultsTest {
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
@Test @Test
fun `streaming defaults to opus in webm`() { fun `ios defaults to aac in mp4`() {
val format = defaultStreamingMusicFormat() val format = defaultStreamingMusicFormat(PlatformType.IOS)
assertEquals("opus" to "webm", format.codec to format.container) assertEquals("aac" to "mp4", format.codec to format.container)
} }
@Test @Test
fun `user settings default to the streaming default`() { fun `other platforms keep opus in webm`() {
assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat) 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))
} }
} }

View File

@ -15,12 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalForeignApi::class)
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -28,39 +32,41 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import platform.AVFoundation.AVPlayer
import kotlinx.coroutines.sync.withLock 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 kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds
/**
* iOS player. Playback is done by MobileVLCKit 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") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
actual val context: Any = context actual val context: Any = context
private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) { private val avPlayer: AVPlayer = AVPlayer()
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
}.create()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Mutex()
private val mediaSession = IosMediaSession()
private var disposed = false private var disposed = false
private val originalPlaylist = mutableListOf<MediaItem>()
private val currentPlaylist = mutableListOf<MediaItem>() private val currentPlaylist = mutableListOf<MediaItem>()
private var currentIndex = -1 private val urlIndexMap = mutableMapOf<String, Int>()
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 _playerState = MutableStateFlow(PlayerState.IDLE)
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null) private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
@ -88,182 +94,97 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow() actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow() actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
private val engineListener = object : NativePlayerListener { private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
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 onError(message: String) {
if (disposed) return
_playerState.tryEmit(PlayerState.IDLE)
_error.tryEmit(IllegalStateException("VLC playback error: $message"))
}
}
init { init {
engine.setListener(engineListener) avPlayer.addPeriodicTimeObserverForInterval(
mediaSession.configure( interval = CMTimeMake(1, 4),
onPlay = { scope.launch { play() } }, queue = null
onPause = { scope.launch { pause() } }, ) { time ->
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } }, if (!disposed) {
onNext = { scope.launch { skipToNext() } }, val seconds = CMTimeGetSeconds(time)
onPrevious = { scope.launch { skipToPrevious() } }) _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)
// }
}
}
} }
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */ scope.launch {
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) { while (!disposed) {
val item = currentPlaylist.getOrNull(currentIndex) ?: return syncTimeControlStatus()
val startMs = pendingStartMs delay(500)
pendingStartMs = 0L }
engineHasMedia = true }
_position.tryEmit(startMs.milliseconds)
_bufferingPosition.tryEmit(Duration.ZERO)
_duration.tryEmit(item.duration)
engine.load(item.url, autoPlay, startMs)
} }
/** Switches to [index] and starts it. Caller holds [lock]. */ private fun syncTimeControlStatus() {
private fun playIndexLocked(index: Int) { val status = avPlayer.timeControlStatus
currentIndex = index if (status == lastTimeControlStatus) return
_currentMediaItem.tryEmit(currentPlaylist[index]) lastTimeControlStatus = status
pendingStartMs = 0L
loadCurrentIntoEngineLocked(autoPlay = true) 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)
}
} }
private fun onMediaEndedLocked() { private fun resolveState(): PlayerState {
if (disposed || currentPlaylist.isEmpty()) return return when (avPlayer.timeControlStatus) {
_completion.tryEmit(Unit) AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
when { AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
_loopState.value == LoopState.ONE -> playIndexLocked(currentIndex.coerceAtLeast(0)) AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
currentIndex < currentPlaylist.lastIndex -> playIndexLocked(currentIndex + 1) else -> PlayerState.IDLE
_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() { actual override suspend fun play() {
lock.withLock { avPlayer.play()
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() { actual override suspend fun pause() {
lock.withLock { avPlayer.pause()
if (disposed) return
engine.pause()
}
} }
actual override suspend fun stop() { actual override suspend fun stop() {
lock.withLock { avPlayer.pause()
if (disposed) return avPlayer.seekToTime(CMTimeMake(0, 1))
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) _playerState.tryEmit(PlayerState.IDLE)
} }
}
actual override suspend fun seekTo(position: Duration) { actual override suspend fun seekTo(position: Duration) {
lock.withLock { val seconds = position.inWholeMilliseconds / 1000.0
if (disposed) return val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
val maxMs = _duration.value.inWholeMilliseconds avPlayer.seekToTime(cmTime)
val targetMs = if (maxMs > 0) { _position.tryEmit(position)
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) { actual override suspend fun loop(state: LoopState) {
lock.withLock {
if (disposed) return
_loopState.tryEmit(state) _loopState.tryEmit(state)
} }
}
actual override suspend fun shuffle(enabled: Boolean) { 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) _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( actual override suspend fun load(
@ -271,186 +192,143 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int startPosition: Int
) { ) {
lock.withLock {
if (disposed) return
originalPlaylist.clear()
originalPlaylist.addAll(playlist)
currentPlaylist.clear() currentPlaylist.clear()
currentPlaylist.addAll(playlist) currentPlaylist.addAll(playlist)
shuffleEnabled = false urlIndexMap.clear()
_shuffleMode.tryEmit(false) playlist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
engine.stop() if (playlist.isEmpty()) {
engineHasMedia = false
pendingStartMs = 0L
_position.tryEmit(Duration.ZERO)
_bufferingPosition.tryEmit(Duration.ZERO)
if (currentPlaylist.isEmpty()) {
currentIndex = -1
_currentMediaItem.tryEmit(null)
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
_currentMediaItem.tryEmit(null)
_position.tryEmit(Duration.ZERO)
_duration.tryEmit(Duration.ZERO) _duration.tryEmit(Duration.ZERO)
return return
} }
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex) val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
_currentMediaItem.tryEmit(currentPlaylist[currentIndex]) val item = playlist[safeIndex]
_duration.tryEmit(currentPlaylist[currentIndex].duration) val avItem = buildAVPlayerItem(item.url)
mediaSession.update(
_currentMediaItem.value, if (avItem == null) {
0.0, _error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
_duration.value.inWholeMilliseconds / 1000.0, _playerState.tryEmit(PlayerState.IDLE)
false) return
}
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
if (autoPlay) { if (autoPlay) {
loadCurrentIntoEngineLocked(autoPlay = true) avPlayer.play()
} else {
_playerState.tryEmit(PlayerState.PAUSED)
}
} }
} }
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual override suspend fun addMediaItem(mediaItem: MediaItem) {
lock.withLock {
if (disposed) return
originalPlaylist.add(mediaItem)
currentPlaylist.add(mediaItem) currentPlaylist.add(mediaItem)
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
}
} }
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
lock.withLock { val currentIndex = currentPlaylist.indexOfFirst {
if (disposed) return it.url == _currentMediaItem.value?.url
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0 }
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem) val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
currentPlaylist.add(insertIndex, mediaItem) currentPlaylist.add(insertIndex, mediaItem)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
_playlist.tryEmit(currentPlaylist.toList()) _playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
}
} }
actual override suspend fun removeMediaItem(mediaItem: MediaItem) { actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
lock.withLock { val index = urlIndexMap[mediaItem.url] ?: return
if (disposed || currentPlaylist.isEmpty()) return currentPlaylist.removeAt(index)
urlIndexMap.clear()
originalPlaylist.removeAll { it.url == mediaItem.url } currentPlaylist.forEachIndexed { i, item ->
val currentUrl = _currentMediaItem.value?.url urlIndexMap[item.url] = i
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()) _playlist.tryEmit(currentPlaylist.toList())
} }
}
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { 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 if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
val item = currentPlaylist.removeAt(fromIndex) val item = currentPlaylist.removeAt(fromIndex)
currentPlaylist.add(toIndex, item) currentPlaylist.add(toIndex, item)
urlIndexMap.clear()
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) { currentPlaylist.forEachIndexed { i, it ->
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex)) urlIndexMap[it.url] = i
} }
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()) _playlist.tryEmit(currentPlaylist.toList())
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
}
} }
actual override suspend fun skipToNext() { actual override suspend fun skipToNext() {
lock.withLock { val currentIndex = currentPlaylist.indexOfFirst {
if (disposed || currentPlaylist.isEmpty()) return it.url == _currentMediaItem.value?.url
playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex)) }
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()
}
} }
} }
actual override suspend fun skipToPrevious() { actual override suspend fun skipToPrevious() {
lock.withLock { val currentIndex = currentPlaylist.indexOfFirst {
if (disposed || currentPlaylist.isEmpty()) return it.url == _currentMediaItem.value?.url
playIndexLocked((currentIndex - 1).coerceAtLeast(0)) }
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()
}
} }
} }
actual override suspend fun jumpTo(index: Int) { actual override suspend fun jumpTo(index: Int) {
lock.withLock { if (index in currentPlaylist.indices) {
if (disposed || index !in currentPlaylist.indices) return val item = currentPlaylist[index]
playIndexLocked(index) val avItem = buildAVPlayerItem(item.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
}
} }
} }
actual override suspend fun setVolume(volume: Float) { actual override suspend fun setVolume(volume: Float) {
lock.withLock {
if (disposed) return
val clamped = volume.coerceIn(0f, 1f) val clamped = volume.coerceIn(0f, 1f)
engine.setVolume(clamped) avPlayer.volume = clamped
_volume.tryEmit(clamped) _volume.tryEmit(clamped)
} }
}
actual override suspend fun setPlaybackSpeed(speed: Float) { actual override suspend fun setPlaybackSpeed(speed: Float) {
lock.withLock {
if (disposed) return
val clamped = speed.coerceIn(0.25f, 4f) val clamped = speed.coerceIn(0.25f, 4f)
engine.setRate(clamped) avPlayer.rate = clamped
_playbackSpeed.tryEmit(clamped) _playbackSpeed.tryEmit(clamped)
} }
}
actual override fun isDisposed(): Boolean = disposed actual override fun isDisposed(): Boolean = disposed
actual override fun dispose() { actual override fun dispose() {
if (disposed) return
disposed = true disposed = true
engine.setListener(null) avPlayer.pause()
engine.destroy() avPlayer.replaceCurrentItemWithPlayerItem(null)
mediaSession.deactivate()
mediaSession.clear()
scope.cancel()
originalPlaylist.clear()
currentPlaylist.clear() currentPlaylist.clear()
currentIndex = -1 urlIndexMap.clear()
engineHasMedia = false
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
_currentMediaItem.tryEmit(null) _currentMediaItem.tryEmit(null)
@ -460,3 +338,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_bufferingPosition.tryEmit(Duration.ZERO) _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

@ -1,187 +0,0 @@
/*
* 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

@ -1,79 +0,0 @@
/*
* 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 MobileVLCKit
* (`iosApp/iosApp/VlcPlayerEngine.swift`).
*
* MobileVLCKit comes from CocoaPods into the Xcode project, so Gradle can't bind it with cinterop.
* 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 VLC 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)
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

@ -1,9 +0,0 @@
# MobileVLCKit plays audio on iOS (iosApp/iosApp/VlcPlayerEngine.swift).
# After `pod install`, open and build iosApp.xcworkspace, not iosApp.xcodeproj.
platform :ios, '18.2'
target 'iosApp' do
use_frameworks!
pod 'MobileVLCKit', '3.7.5'
end

View File

@ -1,16 +0,0 @@
PODS:
- MobileVLCKit (3.7.5)
DEPENDENCIES:
- MobileVLCKit (= 3.7.5)
SPEC REPOS:
trunk:
- MobileVLCKit
SPEC CHECKSUMS:
MobileVLCKit: 9fb4475cd820b2d271ac394d5d7ba18c525332ab
PODFILE CHECKSUM: 6dfb07dbc8a64d0c6fe7231a81687c56f08e8250
COCOAPODS: 1.16.2

View File

@ -6,15 +6,8 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -38,8 +31,6 @@
}; };
E265D0F6D10F52F5C1374EEB /* Configuration */ = { E265D0F6D10F52F5C1374EEB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Configuration; path = Configuration;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -50,50 +41,29 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
835615636B5FE86AA850B7DB /* Products */ = {
isa = PBXGroup;
children = (
11DF02E4B44E5BCF61736473 /* Spotube.app */,
);
name = Products;
sourceTree = "<group>";
};
85BADDD666F59900213B22FE /* Frameworks */ = {
isa = PBXGroup;
children = (
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
92A495F3BC0A87A606BB8FD4 /* Pods */ = {
isa = PBXGroup;
children = (
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */,
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
EFA169D322B90F32841D602F = { EFA169D322B90F32841D602F = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E265D0F6D10F52F5C1374EEB /* Configuration */, E265D0F6D10F52F5C1374EEB /* Configuration */,
5BB730BF90365122B74925FF /* iosApp */, 5BB730BF90365122B74925FF /* iosApp */,
835615636B5FE86AA850B7DB /* Products */, 835615636B5FE86AA850B7DB /* Products */,
92A495F3BC0A87A606BB8FD4 /* Pods */,
85BADDD666F59900213B22FE /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
835615636B5FE86AA850B7DB /* Products */ = {
isa = PBXGroup;
children = (
11DF02E4B44E5BCF61736473 /* Spotube.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -101,12 +71,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */; buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = ( buildPhases = (
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */,
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */, 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */,
314349EF3C4149F4A4A72378 /* Sources */, 314349EF3C4149F4A4A72378 /* Sources */,
EFCF243BC7D8246E008EF23D /* Frameworks */, EFCF243BC7D8246E008EF23D /* Frameworks */,
ED9557BFD7438D37A5E9513D /* Resources */, ED9557BFD7438D37A5E9513D /* Resources */,
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -116,6 +84,8 @@
5BB730BF90365122B74925FF /* iosApp */, 5BB730BF90365122B74925FF /* iosApp */,
); );
name = iosApp; name = iosApp;
packageProductDependencies = (
);
productName = iosApp; productName = iosApp;
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -165,23 +135,6 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = { 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -201,28 +154,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
}; };
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -236,122 +167,6 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
1C42200356BD14712FAE1CFA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
4FB276594B17F1A0D71D2462 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AC996E9458BD3DAE9610EA49 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AF5828948B37FEEF5EF7F345 /* Debug */ = { AF5828948B37FEEF5EF7F345 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
@ -417,18 +232,123 @@
}; };
name = Debug; name = Debug;
}; };
1C42200356BD14712FAE1CFA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AC996E9458BD3DAE9610EA49 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
4FB276594B17F1A0D71D2462 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AC996E9458BD3DAE9610EA49 /* Debug */,
4FB276594B17F1A0D71D2462 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = { 79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@ -438,6 +358,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AC996E9458BD3DAE9610EA49 /* Debug */,
4FB276594B17F1A0D71D2462 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = B056E79C138AE849968965F1 /* Project object */; rootObject = B056E79C138AE849968965F1 /* Project object */;

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:iosApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -4,10 +4,6 @@
<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>

View File

@ -1,205 +0,0 @@
// 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 MobileVLCKit
/// Registers the MobileVLCKit 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 MobileVLCKit. The Kotlin `AudioPlayer` owns the playlist; see
/// `NativePlayerEngine.kt` for the contract.
///
/// Every VLC 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?
private var released = false
private var rate: Float = 1
private var volumePercent: Int32 = 100
private var lastReportedDurationMs: Int64 = 0
private var lastReportedState: NativePlayerState?
override init() {
super.init()
player.delegate = self
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) else {
self.listener?.onError(message: "Invalid URL: \(url)")
return
}
let media = VLCMedia(url: mediaUrl)
if startPositionMs > 0 {
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
}
self.lastReportedDurationMs = 0
self.player.media = media
self.report(.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.player.stop()
}
}
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(_ aNotification: Notification) {
onMain {
guard !self.released else { return }
switch self.player.state {
case .opening:
self.report(.opening)
case .buffering, .esAdded:
// MobileVLCKit keeps sending these while it plays; only a stall counts as buffering.
self.report(self.player.isPlaying ? .playing : .buffering)
case .playing:
self.report(.playing)
case .paused:
self.report(.paused)
case .ended:
self.report(.ended)
case .stopped:
self.report(.idle)
case .error:
self.listener?.onError(message: "VLC could not play this media")
self.report(.idle)
default:
break
}
}
}
func mediaPlayerTimeChanged(_ aNotification: Notification) {
onMain {
guard !self.released else { return }
// Time only advances while playing, whatever the last state notification said.
if self.player.isPlaying { self.report(.playing) }
self.reportDuration()
if let ms = self.player.time.value?.int64Value, ms >= 0 {
self.listener?.onTime(positionMs: ms)
}
}
}
// MARK: Internals
private func startPlayback() {
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
}
/// Sends [state] to Kotlin unless it was the last one sent.
private func report(_ state: NativePlayerState) {
guard state != lastReportedState else { return }
lastReportedState = state
if state == .playing {
applyPlaybackSettings()
reportDuration()
}
listener?.onState(state: state)
}
private func applyPlaybackSettings() {
player.audio?.volume = volumePercent
if player.rate != rate { player.rate = rate }
}
/// MobileVLCKit has no length callback, so the length is read from the media as it plays.
private func reportDuration() {
guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return }
lastReportedDurationMs = ms
listener?.onDuration(durationMs: ms)
}
private func onMain(_ work: @escaping () -> Void) {
DispatchQueue.main.async(execute: work)
}
}

View File

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