mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
4 Commits
6d5f0b2cce
...
42b416fcd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b416fcd1 | ||
|
|
1f228a2aa0 | ||
|
|
d7b00d6230 | ||
|
|
38a1a8ee83 |
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
@ -98,12 +101,23 @@ const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
|
|||||||
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))
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,42 @@ 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 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")
|
@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 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,191 +95,116 @@ 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 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 {
|
init {
|
||||||
engine.setListener(engineListener)
|
|
||||||
mediaSession.configure(
|
mediaSession.configure(
|
||||||
onPlay = { scope.launch { play() } },
|
onPlay = { scope.launch { play() } },
|
||||||
onPause = { scope.launch { pause() } },
|
onPause = { scope.launch { pause() } },
|
||||||
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
|
onSeek = { seconds -> scope.launch { seekTo(seconds.seconds) } },
|
||||||
onNext = { scope.launch { skipToNext() } },
|
onNext = { scope.launch { skipToNext() } },
|
||||||
onPrevious = { scope.launch { skipToPrevious() } })
|
onPrevious = { scope.launch { skipToPrevious() } })
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
avPlayer.addPeriodicTimeObserverForInterval(
|
||||||
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
|
interval = CMTimeMake(1, 4),
|
||||||
val item = currentPlaylist.getOrNull(currentIndex) ?: return
|
queue = null
|
||||||
val startMs = pendingStartMs
|
) { time ->
|
||||||
pendingStartMs = 0L
|
if (!disposed) {
|
||||||
engineHasMedia = true
|
val seconds = CMTimeGetSeconds(time)
|
||||||
_position.tryEmit(startMs.milliseconds)
|
_position.tryEmit(seconds.seconds)
|
||||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
|
||||||
_duration.tryEmit(item.duration)
|
|
||||||
engine.load(item.url, autoPlay, startMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switches to [index] and starts it. Caller holds [lock]. */
|
avPlayer.currentItem?.let { item ->
|
||||||
private fun playIndexLocked(index: Int) {
|
val durationSeconds = CMTimeGetSeconds(item.duration)
|
||||||
currentIndex = index
|
if (durationSeconds > 0) {
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[index])
|
_duration.tryEmit(durationSeconds.seconds)
|
||||||
pendingStartMs = 0L
|
|
||||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
|
||||||
}
|
}
|
||||||
|
val loadedRanges = item.loadedTimeRanges
|
||||||
private fun onMediaEndedLocked() {
|
// if (loadedRanges.count() > 0u) {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
// val range = loadedRanges.objectAtIndex(0u)
|
||||||
_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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual override suspend fun play() {
|
|
||||||
lock.withLock {
|
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
|
||||||
mediaSession.activate()
|
|
||||||
if (currentIndex !in currentPlaylist.indices) {
|
|
||||||
currentIndex = 0
|
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[0])
|
|
||||||
}
|
|
||||||
if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual override suspend fun pause() {
|
|
||||||
lock.withLock {
|
|
||||||
if (disposed) return
|
|
||||||
engine.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual override suspend fun stop() {
|
|
||||||
lock.withLock {
|
|
||||||
if (disposed) return
|
|
||||||
engine.stop()
|
|
||||||
mediaSession.deactivate()
|
|
||||||
mediaSession.update(
|
mediaSession.update(
|
||||||
_currentMediaItem.value,
|
item = _currentMediaItem.value,
|
||||||
0.0,
|
positionSeconds = seconds,
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
false)
|
isPlaying = avPlayer.timeControlStatus == AVPlayerTimeControlStatusPlaying)
|
||||||
engineHasMedia = false
|
}
|
||||||
pendingStartMs = 0L
|
}
|
||||||
_position.tryEmit(Duration.ZERO)
|
|
||||||
|
scope.launch {
|
||||||
|
while (!disposed) {
|
||||||
|
syncTimeControlStatus()
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveState(): PlayerState {
|
||||||
|
return when (avPlayer.timeControlStatus) {
|
||||||
|
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
||||||
|
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
||||||
|
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
||||||
|
else -> PlayerState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
|
||||||
|
val nsUrl = NSURL.URLWithString(url) ?: return null
|
||||||
|
return AVPlayerItem(nsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual override suspend fun play() {
|
||||||
|
mediaSession.activate()
|
||||||
|
avPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual override suspend fun pause() {
|
||||||
|
avPlayer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual override suspend fun stop() {
|
||||||
|
avPlayer.pause()
|
||||||
|
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.update(
|
||||||
|
item = _currentMediaItem.value,
|
||||||
|
positionSeconds = 0.0,
|
||||||
|
durationSeconds = _duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
isPlaying = false)
|
||||||
|
}
|
||||||
|
|
||||||
actual override suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
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(
|
||||||
@ -280,186 +212,150 @@ 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)
|
||||||
|
|
||||||
|
if (avItem == null) {
|
||||||
|
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
|
_currentMediaItem.tryEmit(item)
|
||||||
mediaSession.update(
|
mediaSession.update(
|
||||||
_currentMediaItem.value,
|
item = item,
|
||||||
0.0,
|
positionSeconds = 0.0,
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
durationSeconds = item.duration.inWholeMilliseconds / 1000.0,
|
||||||
false)
|
isPlaying = false)
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
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)
|
||||||
|
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)
|
||||||
|
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.deactivate()
|
||||||
mediaSession.clear()
|
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)
|
||||||
@ -469,3 +365,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)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,81 +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 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
|
|
||||||
}
|
|
||||||
@ -6,10 +6,6 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; };
|
|
||||||
/* 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; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -45,7 +41,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -90,7 +85,6 @@
|
|||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
|
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
||||||
@ -120,9 +114,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = EFA169D322B90F32841D602F;
|
mainGroup = EFA169D322B90F32841D602F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
|
||||||
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
|
|
||||||
);
|
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -377,25 +368,6 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = B056E79C138AE849968965F1 /* Project object */;
|
||||||
}
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b",
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "vlckit",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/videolan/vlckit",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 3
|
|
||||||
}
|
|
||||||
@ -1,256 +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 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user