mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
7 Commits
42b416fcd1
...
6d5f0b2cce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5f0b2cce | ||
|
|
30baf0cdcf | ||
|
|
5edafa1201 | ||
|
|
b76a69d049 | ||
|
|
f9c040015e | ||
|
|
538ba636d4 | ||
|
|
0a30aa7137 |
@ -18,7 +18,10 @@
|
|||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
|
import org.spotube.spotube.getPlatform
|
||||||
import org.spotube.spotube.core.di.injectLogger
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
|
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
@ -51,6 +54,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
private val streamingUrlRepository = get<StreamingUrlRepository>()
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@ -515,7 +519,25 @@ class DeviceAudioPlayerQueue(
|
|||||||
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is QueueEntry.StreamingTrack -> {
|
is QueueEntry.StreamingTrack -> {
|
||||||
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
val isIos = getPlatform().type == PlatformType.IOS
|
||||||
|
val requiresIosResolution = isIos && (
|
||||||
|
url.isBlank() ||
|
||||||
|
url.startsWith("http://127.0.0.1") ||
|
||||||
|
url.startsWith("https://127.0.0.1") ||
|
||||||
|
url.startsWith("http://[::1]") ||
|
||||||
|
url.startsWith("https://[::1]") ||
|
||||||
|
url.startsWith("http://localhost") ||
|
||||||
|
url.startsWith("https://localhost"))
|
||||||
|
val resolvedStream = if (requiresIosResolution) {
|
||||||
|
streamingUrlRepository.resolveStreamInfo(track)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val resolvedUrl = if (requiresIosResolution) {
|
||||||
|
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
|
||||||
|
} else {
|
||||||
|
url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||||
|
}
|
||||||
if (resolvedUrl.isBlank()) return null
|
if (resolvedUrl.isBlank()) return null
|
||||||
MediaItem(
|
MediaItem(
|
||||||
title = track.title,
|
title = track.title,
|
||||||
@ -524,7 +546,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = artworkUrl.orEmpty(),
|
coverURL = artworkUrl.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol)
|
protocol = resolvedStream?.protocol ?: protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
|
|||||||
@ -23,11 +23,9 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
import org.spotube.plugin_interfaces.plugin_apis.audio.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
|
||||||
@ -80,8 +78,7 @@ 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,
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||||
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()
|
||||||
@ -146,7 +143,7 @@ class StreamingUrlRepository(
|
|||||||
} else {
|
} else {
|
||||||
stream.streams
|
stream.streams
|
||||||
}
|
}
|
||||||
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
|
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
|
||||||
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,12 +253,11 @@ 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 = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
"(rank key = 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=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
|
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
|
||||||
"${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)}"
|
||||||
@ -269,10 +265,6 @@ 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})"
|
||||||
@ -400,21 +392,16 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the available stream closest to the user's preferred format and quality.
|
* Selects the available stream closest to the user's preferred format and quality. Every
|
||||||
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
|
* platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container,
|
||||||
* preferences say: e.g. opus/webm is unplayable on iOS.
|
* codec and protocol plugins return, so nothing is ruled out up front.
|
||||||
*/
|
*/
|
||||||
internal fun selectPreferredAudioStream(
|
internal fun selectPreferredAudioStream(
|
||||||
streams: List<AudioStream>,
|
streams: List<AudioStream>,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||||
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
|
||||||
@ -425,25 +412,6 @@ internal fun selectPreferredAudioStream(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
|
|
||||||
|
|
||||||
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
|
|
||||||
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
|
|
||||||
// AVPlayer has no WebM/Ogg/Matroska demuxer.
|
|
||||||
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
|
|
||||||
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
|
|
||||||
*/
|
|
||||||
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
|
|
||||||
isContainerPlayableOn(container, platform) &&
|
|
||||||
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
|
|
||||||
|
|
||||||
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
|
|
||||||
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
|
|
||||||
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
|
|
||||||
|
|
||||||
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
|
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,9 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -101,23 +98,12 @@ 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. */
|
||||||
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
|
internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat(
|
||||||
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
|
codec = "opus",
|
||||||
*/
|
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).withPlayableStreamingFormat()
|
settingsJson.decodeFromString<UserSettings>(json as String)
|
||||||
} 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).withPlayableStreamingFormat()
|
return settingsJson.decodeFromString<UserSettings>(storedJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,8 +18,6 @@
|
|||||||
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
|
||||||
@ -58,7 +56,6 @@ 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",
|
||||||
@ -72,7 +69,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,7 +5,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
import org.spotube.plugin_interfaces.plugin_apis.audio.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
|
||||||
@ -100,9 +99,7 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@ -14,7 +13,6 @@ 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.
|
||||||
@ -56,8 +54,7 @@ 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),
|
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
|
||||||
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
|
|
||||||
|
|
||||||
// ---------- empty / single ----------
|
// ---------- empty / single ----------
|
||||||
|
|
||||||
@ -407,33 +404,20 @@ class StreamingUrlRepositoryTest {
|
|||||||
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- device playability ----------
|
// ---------- no platform restrictions ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
|
fun `the preferred opus webm stream wins over aac`() {
|
||||||
val opus = lossy("opus-160", 160_000)
|
val 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(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
|
assertSame(opus, select(listOf(aac, opus)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
|
fun `a dash manifest closest to the preferences is not skipped`() {
|
||||||
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
|
val dash = lossy("dash", 256_000, 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(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
|
assertSame(dash, select(listOf(progressive, dash)))
|
||||||
}
|
|
||||||
|
|
||||||
@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,39 +17,19 @@
|
|||||||
|
|
||||||
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 `ios defaults to aac in mp4`() {
|
fun `streaming defaults to opus in webm`() {
|
||||||
val format = defaultStreamingMusicFormat(PlatformType.IOS)
|
val format = defaultStreamingMusicFormat()
|
||||||
assertEquals("aac" to "mp4", format.codec to format.container)
|
assertEquals("opus" to "webm", format.codec to format.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `other platforms keep opus in webm`() {
|
fun `user settings default to the streaming default`() {
|
||||||
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
|
assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat)
|
||||||
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,16 +15,12 @@
|
|||||||
* 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.delay
|
import kotlinx.coroutines.cancel
|
||||||
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
|
||||||
@ -32,41 +28,39 @@ 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 platform.AVFoundation.AVPlayer
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import platform.AVFoundation.AVPlayerItem
|
import kotlinx.coroutines.sync.withLock
|
||||||
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.seconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS player. Playback is done by VLCKit through [NativePlayerEngine]; AVPlayer misreports the
|
||||||
|
* duration of some fragmented MP4 streams (doubling it, which leaves the second half silent).
|
||||||
|
* The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here.
|
||||||
|
*/
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@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 avPlayer: AVPlayer = AVPlayer()
|
private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) {
|
||||||
|
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
|
||||||
|
}.create()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val 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 val urlIndexMap = mutableMapOf<String, Int>()
|
private var currentIndex = -1
|
||||||
|
private var shuffleEnabled = false
|
||||||
|
|
||||||
|
// The engine holds media only after the first play(); until then a requested start position
|
||||||
|
// is kept here and handed over with the media.
|
||||||
|
private var engineHasMedia = false
|
||||||
|
private var pendingStartMs = 0L
|
||||||
|
|
||||||
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
||||||
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
||||||
@ -94,97 +88,191 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
private val engineListener = object : NativePlayerListener {
|
||||||
|
override fun onState(state: NativePlayerState) {
|
||||||
|
if (disposed) return
|
||||||
|
when (state) {
|
||||||
|
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
NativePlayerState.OPENING,
|
||||||
|
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
|
||||||
|
NativePlayerState.PLAYING -> {
|
||||||
|
_playerState.tryEmit(PlayerState.PLAYING)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
_position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
true)
|
||||||
|
}
|
||||||
|
NativePlayerState.PAUSED -> {
|
||||||
|
_playerState.tryEmit(PlayerState.PAUSED)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
_position.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
false)
|
||||||
|
}
|
||||||
|
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTime(positionMs: Long) {
|
||||||
|
if (disposed || positionMs < 0) return
|
||||||
|
_position.tryEmit(positionMs.milliseconds)
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
positionMs / 1000.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
_playerState.value == PlayerState.PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDuration(durationMs: Long) {
|
||||||
|
if (disposed || durationMs <= 0) return
|
||||||
|
_duration.tryEmit(durationMs.milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBuffered(percent: Float) {
|
||||||
|
if (disposed) return
|
||||||
|
val durationMs = _duration.value.inWholeMilliseconds
|
||||||
|
_bufferingPosition.tryEmit(
|
||||||
|
if (durationMs > 0) (durationMs * (percent.coerceIn(0f, 100f) / 100f)).toLong().milliseconds
|
||||||
|
else Duration.ZERO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(message: String) {
|
||||||
|
if (disposed) return
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
_error.tryEmit(IllegalStateException("VLC playback error: $message"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
avPlayer.addPeriodicTimeObserverForInterval(
|
engine.setListener(engineListener)
|
||||||
interval = CMTimeMake(1, 4),
|
mediaSession.configure(
|
||||||
queue = null
|
onPlay = { scope.launch { play() } },
|
||||||
) { time ->
|
onPause = { scope.launch { pause() } },
|
||||||
if (!disposed) {
|
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
|
||||||
val seconds = CMTimeGetSeconds(time)
|
onNext = { scope.launch { skipToNext() } },
|
||||||
_position.tryEmit(seconds.seconds)
|
onPrevious = { scope.launch { skipToPrevious() } })
|
||||||
|
|
||||||
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)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
||||||
while (!disposed) {
|
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
|
||||||
syncTimeControlStatus()
|
val item = currentPlaylist.getOrNull(currentIndex) ?: return
|
||||||
delay(500)
|
val startMs = pendingStartMs
|
||||||
}
|
pendingStartMs = 0L
|
||||||
}
|
engineHasMedia = true
|
||||||
|
_position.tryEmit(startMs.milliseconds)
|
||||||
|
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||||
|
_duration.tryEmit(item.duration)
|
||||||
|
engine.load(item.url, autoPlay, startMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun syncTimeControlStatus() {
|
/** Switches to [index] and starts it. Caller holds [lock]. */
|
||||||
val status = avPlayer.timeControlStatus
|
private fun playIndexLocked(index: Int) {
|
||||||
if (status == lastTimeControlStatus) return
|
currentIndex = index
|
||||||
lastTimeControlStatus = status
|
_currentMediaItem.tryEmit(currentPlaylist[index])
|
||||||
|
pendingStartMs = 0L
|
||||||
val playerState = when (status) {
|
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
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 resolveState(): PlayerState {
|
private fun onMediaEndedLocked() {
|
||||||
return when (avPlayer.timeControlStatus) {
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
_completion.tryEmit(Unit)
|
||||||
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
when {
|
||||||
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
_loopState.value == LoopState.ONE -> playIndexLocked(currentIndex.coerceAtLeast(0))
|
||||||
else -> PlayerState.IDLE
|
currentIndex < currentPlaylist.lastIndex -> playIndexLocked(currentIndex + 1)
|
||||||
|
_loopState.value == LoopState.ALL -> playIndexLocked(0)
|
||||||
|
else -> {
|
||||||
|
_playerState.tryEmit(PlayerState.COMPLETED)
|
||||||
|
engineHasMedia = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
|
|
||||||
val nsUrl = NSURL.URLWithString(url) ?: return null
|
|
||||||
return AVPlayerItem(nsUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun play() {
|
actual override suspend fun play() {
|
||||||
avPlayer.play()
|
lock.withLock {
|
||||||
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
|
mediaSession.activate()
|
||||||
|
if (currentIndex !in currentPlaylist.indices) {
|
||||||
|
currentIndex = 0
|
||||||
|
_currentMediaItem.tryEmit(currentPlaylist[0])
|
||||||
|
}
|
||||||
|
if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
avPlayer.pause()
|
lock.withLock {
|
||||||
|
if (disposed) return
|
||||||
|
engine.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
avPlayer.pause()
|
lock.withLock {
|
||||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
if (disposed) return
|
||||||
|
engine.stop()
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.update(
|
||||||
|
_currentMediaItem.value,
|
||||||
|
0.0,
|
||||||
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
|
false)
|
||||||
|
engineHasMedia = false
|
||||||
|
pendingStartMs = 0L
|
||||||
|
_position.tryEmit(Duration.ZERO)
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual override suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
val seconds = position.inWholeMilliseconds / 1000.0
|
lock.withLock {
|
||||||
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
|
if (disposed) return
|
||||||
avPlayer.seekToTime(cmTime)
|
val maxMs = _duration.value.inWholeMilliseconds
|
||||||
_position.tryEmit(position)
|
val targetMs = if (maxMs > 0) {
|
||||||
|
position.inWholeMilliseconds.coerceIn(0, maxMs)
|
||||||
|
} else {
|
||||||
|
position.inWholeMilliseconds.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
if (engineHasMedia) engine.seekTo(targetMs) else pendingStartMs = targetMs
|
||||||
|
_position.tryEmit(targetMs.milliseconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun loop(state: LoopState) {
|
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(
|
||||||
@ -192,143 +280,186 @@ 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)
|
||||||
urlIndexMap.clear()
|
shuffleEnabled = false
|
||||||
playlist.forEachIndexed { index, item ->
|
_shuffleMode.tryEmit(false)
|
||||||
urlIndexMap[item.url] = index
|
|
||||||
}
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
|
|
||||||
if (playlist.isEmpty()) {
|
engine.stop()
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
engineHasMedia = false
|
||||||
_currentMediaItem.tryEmit(null)
|
pendingStartMs = 0L
|
||||||
_position.tryEmit(Duration.ZERO)
|
_position.tryEmit(Duration.ZERO)
|
||||||
|
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||||
|
|
||||||
|
if (currentPlaylist.isEmpty()) {
|
||||||
|
currentIndex = -1
|
||||||
|
_currentMediaItem.tryEmit(null)
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
_duration.tryEmit(Duration.ZERO)
|
_duration.tryEmit(Duration.ZERO)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
|
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
||||||
val item = playlist[safeIndex]
|
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||||
val avItem = buildAVPlayerItem(item.url)
|
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
||||||
|
mediaSession.update(
|
||||||
if (avItem == null) {
|
_currentMediaItem.value,
|
||||||
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
0.0,
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_duration.value.inWholeMilliseconds / 1000.0,
|
||||||
return
|
false)
|
||||||
}
|
|
||||||
|
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
|
||||||
_currentMediaItem.tryEmit(item)
|
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
avPlayer.play()
|
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
|
} 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) {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
if (disposed) return
|
||||||
}
|
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
|
||||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
|
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem)
|
||||||
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) {
|
||||||
val index = urlIndexMap[mediaItem.url] ?: return
|
lock.withLock {
|
||||||
currentPlaylist.removeAt(index)
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
urlIndexMap.clear()
|
|
||||||
currentPlaylist.forEachIndexed { i, item ->
|
originalPlaylist.removeAll { it.url == mediaItem.url }
|
||||||
urlIndexMap[item.url] = i
|
val currentUrl = _currentMediaItem.value?.url
|
||||||
|
val removedCurrent = currentUrl == mediaItem.url
|
||||||
|
val removalIndices = currentPlaylist.withIndex()
|
||||||
|
.filter { it.value.url == mediaItem.url }
|
||||||
|
.map { it.index }
|
||||||
|
if (removalIndices.isEmpty()) return
|
||||||
|
|
||||||
|
// Items before the playing one shift it down, so keep the index on the same item.
|
||||||
|
val removedBefore = removalIndices.count { it < currentIndex }
|
||||||
|
removalIndices.asReversed().forEach { currentPlaylist.removeAt(it) }
|
||||||
|
|
||||||
|
if (currentPlaylist.isEmpty()) {
|
||||||
|
currentIndex = -1
|
||||||
|
_currentMediaItem.tryEmit(null)
|
||||||
|
engine.stop()
|
||||||
|
engineHasMedia = false
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
} else if (removedCurrent) {
|
||||||
|
currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
||||||
|
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||||
|
engine.stop()
|
||||||
|
engineHasMedia = false
|
||||||
|
} else {
|
||||||
|
currentIndex = (currentIndex - removedBefore).coerceIn(0, currentPlaylist.lastIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_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()
|
|
||||||
currentPlaylist.forEachIndexed { i, it ->
|
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
|
||||||
urlIndexMap[it.url] = i
|
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentUrl = _currentMediaItem.value?.url
|
||||||
|
currentIndex = currentUrl?.let { url ->
|
||||||
|
currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 }
|
||||||
|
} ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
||||||
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
|
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun skipToNext() {
|
actual override suspend fun skipToNext() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
}
|
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() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
}
|
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) {
|
||||||
if (index in currentPlaylist.indices) {
|
lock.withLock {
|
||||||
val item = currentPlaylist[index]
|
if (disposed || index !in currentPlaylist.indices) return
|
||||||
val avItem = buildAVPlayerItem(item.url)
|
playIndexLocked(index)
|
||||||
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)
|
||||||
avPlayer.volume = clamped
|
engine.setVolume(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)
|
||||||
avPlayer.rate = clamped
|
engine.setRate(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
|
||||||
avPlayer.pause()
|
engine.setListener(null)
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
engine.destroy()
|
||||||
|
mediaSession.deactivate()
|
||||||
|
mediaSession.clear()
|
||||||
|
scope.cancel()
|
||||||
|
|
||||||
|
originalPlaylist.clear()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
urlIndexMap.clear()
|
currentIndex = -1
|
||||||
|
engineHasMedia = false
|
||||||
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
_currentMediaItem.tryEmit(null)
|
_currentMediaItem.tryEmit(null)
|
||||||
@ -338,11 +469,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import platform.AVFAudio.AVAudioSession
|
||||||
|
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||||
|
import platform.AVFAudio.AVAudioSessionModeDefault
|
||||||
|
import platform.AVFAudio.setActive
|
||||||
|
import platform.Foundation.NSNotificationCenter
|
||||||
|
import platform.Foundation.NSNumber
|
||||||
|
import platform.Foundation.NSOperationQueue
|
||||||
|
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyArtist
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
|
||||||
|
import platform.MediaPlayer.MPMediaItemPropertyTitle
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoCenter
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
|
||||||
|
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
|
||||||
|
import platform.MediaPlayer.MPRemoteCommand
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandCenter
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
|
||||||
|
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
|
||||||
|
|
||||||
|
internal class IosMediaSession {
|
||||||
|
private val audioSession = AVAudioSession.sharedInstance()
|
||||||
|
private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter()
|
||||||
|
private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter()
|
||||||
|
|
||||||
|
private var configured = false
|
||||||
|
private val notificationCenter = NSNotificationCenter.defaultCenter()
|
||||||
|
private val notificationTokens = mutableListOf<Any>()
|
||||||
|
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
|
||||||
|
private var onPlay: (() -> Unit)? = null
|
||||||
|
private var onPause: (() -> Unit)? = null
|
||||||
|
private var onSeek: ((Double) -> Unit)? = null
|
||||||
|
private var onNext: (() -> Unit)? = null
|
||||||
|
private var onPrevious: (() -> Unit)? = null
|
||||||
|
|
||||||
|
fun configure(
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onSeek: (Double) -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onPrevious: () -> Unit
|
||||||
|
) {
|
||||||
|
this.onPlay = onPlay
|
||||||
|
this.onPause = onPause
|
||||||
|
this.onSeek = onSeek
|
||||||
|
this.onNext = onNext
|
||||||
|
this.onPrevious = onPrevious
|
||||||
|
|
||||||
|
if (configured) return
|
||||||
|
configured = true
|
||||||
|
|
||||||
|
audioSession.setCategory(
|
||||||
|
category = AVAudioSessionCategoryPlayback,
|
||||||
|
mode = AVAudioSessionModeDefault,
|
||||||
|
options = 0u,
|
||||||
|
error = null)
|
||||||
|
registerAudioSessionNotifications()
|
||||||
|
registerRemoteCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activate() {
|
||||||
|
audioSession.setActive(true, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deactivate() {
|
||||||
|
audioSession.setActive(false, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
item: MediaItem?,
|
||||||
|
positionSeconds: Double,
|
||||||
|
durationSeconds: Double,
|
||||||
|
isPlaying: Boolean
|
||||||
|
) {
|
||||||
|
if (item == null) {
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val info = buildMap<Any?, Any?> {
|
||||||
|
put(MPMediaItemPropertyTitle, item.title)
|
||||||
|
put(MPMediaItemPropertyArtist, item.artist)
|
||||||
|
item.album.takeIf { it.isNotBlank() }?.let {
|
||||||
|
put(MPMediaItemPropertyAlbumTitle, it)
|
||||||
|
}
|
||||||
|
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
||||||
|
put(MPMediaItemPropertyPlaybackDuration, durationSeconds)
|
||||||
|
}
|
||||||
|
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
||||||
|
put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds)
|
||||||
|
}
|
||||||
|
put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0)
|
||||||
|
}
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
nowPlayingInfoCenter.nowPlayingInfo = null
|
||||||
|
notificationTokens.forEach { notificationCenter.removeObserver(it) }
|
||||||
|
notificationTokens.clear()
|
||||||
|
remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) }
|
||||||
|
remoteCommandTokens.clear()
|
||||||
|
configured = false
|
||||||
|
onPlay = null
|
||||||
|
onPause = null
|
||||||
|
onSeek = null
|
||||||
|
onNext = null
|
||||||
|
onPrevious = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerAudioSessionNotifications() {
|
||||||
|
notificationCenter.addObserverForName(
|
||||||
|
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
|
||||||
|
`object` = audioSession,
|
||||||
|
queue = NSOperationQueue.mainQueue()) { notification ->
|
||||||
|
val type = notification?.userInfo?.get(
|
||||||
|
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber
|
||||||
|
if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
||||||
|
onPause?.invoke()
|
||||||
|
} else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
||||||
|
activate()
|
||||||
|
val options = notification?.userInfo?.get(
|
||||||
|
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber
|
||||||
|
val shouldResume = options?.unsignedLongValue
|
||||||
|
?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume)
|
||||||
|
?.let { it != 0UL } == true
|
||||||
|
if (shouldResume) onPlay?.invoke()
|
||||||
|
}
|
||||||
|
}?.let { notificationTokens += it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerRemoteCommands() {
|
||||||
|
fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) {
|
||||||
|
command.addTargetWithHandler(handler)?.let {
|
||||||
|
remoteCommandTokens += command to it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(remoteCommandCenter.playCommand) {
|
||||||
|
onPlay?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
register(remoteCommandCenter.pauseCommand) {
|
||||||
|
onPause?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
register(remoteCommandCenter.changePlaybackPositionCommand) { event ->
|
||||||
|
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
|
||||||
|
if (position != null && position.isFinite() && position >= 0) {
|
||||||
|
onSeek?.invoke(position)
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
} else {
|
||||||
|
MPRemoteCommandHandlerStatusCommandFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
register(remoteCommandCenter.nextTrackCommand) {
|
||||||
|
onNext?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
register(remoteCommandCenter.previousTrackCommand) {
|
||||||
|
onPrevious?.invoke()
|
||||||
|
MPRemoteCommandHandlerStatusSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-media playback engine implemented in Swift on top of VLCKit (`iosApp/iosApp/VlcPlayerEngine.swift`).
|
||||||
|
*
|
||||||
|
* VLCKit ships as a SwiftPM binary target, so it can't be bound with cinterop from Gradle. Swift
|
||||||
|
* implements this contract instead and registers it in [NativePlayerRegistry] before the Compose
|
||||||
|
* view controller is created. The playlist, loop and shuffle logic stays in Kotlin
|
||||||
|
* ([AudioPlayer]); the engine only ever plays one URL at a time.
|
||||||
|
*
|
||||||
|
* Implementations may be called from any thread and must hop to the thread VLCKit needs
|
||||||
|
* themselves. [NativePlayerListener] callbacks may arrive on any thread.
|
||||||
|
*/
|
||||||
|
interface NativePlayerEngine {
|
||||||
|
fun setListener(listener: NativePlayerListener?)
|
||||||
|
|
||||||
|
/** Replaces the current media. Starts playing right away when [autoPlay] is set. */
|
||||||
|
fun load(url: String, autoPlay: Boolean, startPositionMs: Long)
|
||||||
|
fun play()
|
||||||
|
fun pause()
|
||||||
|
fun stop()
|
||||||
|
fun seekTo(positionMs: Long)
|
||||||
|
|
||||||
|
/** [volume] is 0..1. */
|
||||||
|
fun setVolume(volume: Float)
|
||||||
|
fun setRate(rate: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the native player. The engine must not be used afterwards. Not named `release`:
|
||||||
|
* that would export to Objective-C as `-release` and clash with NSObject.
|
||||||
|
*/
|
||||||
|
fun destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativePlayerListener {
|
||||||
|
fun onState(state: NativePlayerState)
|
||||||
|
fun onTime(positionMs: Long)
|
||||||
|
fun onDuration(durationMs: Long)
|
||||||
|
|
||||||
|
/** Network cache fill of the current media, 0..100. */
|
||||||
|
fun onBuffered(percent: Float)
|
||||||
|
fun onError(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NativePlayerState {
|
||||||
|
/** Nothing loaded, or playback was stopped. */
|
||||||
|
IDLE,
|
||||||
|
OPENING,
|
||||||
|
BUFFERING,
|
||||||
|
PLAYING,
|
||||||
|
PAUSED,
|
||||||
|
|
||||||
|
/** The media played to its end. Sent once per loaded media. */
|
||||||
|
ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativePlayerEngineFactory {
|
||||||
|
fun create(): NativePlayerEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set from Swift (`iOSApp.init`) before the first [AudioPlayer] is created. */
|
||||||
|
object NativePlayerRegistry {
|
||||||
|
var factory: NativePlayerEngineFactory? = null
|
||||||
|
}
|
||||||
@ -6,6 +6,10 @@
|
|||||||
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 */
|
||||||
@ -41,6 +45,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -85,6 +90,7 @@
|
|||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
||||||
@ -114,6 +120,9 @@
|
|||||||
);
|
);
|
||||||
mainGroup = EFA169D322B90F32841D602F;
|
mainGroup = EFA169D322B90F32841D602F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -368,6 +377,25 @@
|
|||||||
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 */;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "vlckit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/videolan/vlckit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
256
iosApp/iosApp/VlcPlayerEngine.swift
Normal file
256
iosApp/iosApp/VlcPlayerEngine.swift
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
// Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import ComposeApp
|
||||||
|
import Foundation
|
||||||
|
import VLCKit
|
||||||
|
|
||||||
|
/// Registers the VLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first
|
||||||
|
/// `ComposeView` is created.
|
||||||
|
func registerNativePlayerEngine() {
|
||||||
|
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory {
|
||||||
|
func create() -> NativePlayerEngine {
|
||||||
|
VlcPlayerEngine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays one URL at a time with VLCKit. The Kotlin `AudioPlayer` owns the playlist; see
|
||||||
|
/// `NativePlayerEngine.kt` for the contract.
|
||||||
|
///
|
||||||
|
/// Every VLCKit call is made on the main queue, in the order the Kotlin side issued it.
|
||||||
|
final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
|
||||||
|
private let player = VLCMediaPlayer()
|
||||||
|
private var listener: NativePlayerListener?
|
||||||
|
|
||||||
|
// VLCKit 4 has no "ended" state: reaching the end of a media goes Stopping -> Stopped, the
|
||||||
|
// same as stop(). `stopRequested` marks the stops this class asked for, so any other stop of
|
||||||
|
// a media that had started playing is a natural end.
|
||||||
|
private var stopRequested = false
|
||||||
|
private var hasStarted = false
|
||||||
|
private var finished = false
|
||||||
|
private var released = false
|
||||||
|
|
||||||
|
private var rate: Float = 1
|
||||||
|
private var volumePercent: Int32 = 100
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
player.delegate = self
|
||||||
|
player.timeChangeUpdateInterval = 0.25
|
||||||
|
player.minimalTimePeriod = 250_000
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NativePlayerEngine
|
||||||
|
|
||||||
|
func setListener(listener: NativePlayerListener?) {
|
||||||
|
onMain { self.listener = listener }
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
guard let mediaUrl = URL(string: url), let media = VLCMedia(url: mediaUrl) else {
|
||||||
|
self.listener?.onError(message: "Invalid URL: \(url)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stopCurrentMedia()
|
||||||
|
self.hasStarted = false
|
||||||
|
self.finished = false
|
||||||
|
if startPositionMs > 0 {
|
||||||
|
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
|
||||||
|
}
|
||||||
|
self.player.media = media
|
||||||
|
self.listener?.onState(state: .opening)
|
||||||
|
if autoPlay { self.startPlayback() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
self.startPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
onMain {
|
||||||
|
guard !self.released, self.player.isPlaying else { return }
|
||||||
|
self.player.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
self.stopCurrentMedia()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekTo(positionMs: Int64) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released, self.player.isSeekable else { return }
|
||||||
|
self.player.time = VLCTime(number: NSNumber(value: positionMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(volume: Float) {
|
||||||
|
onMain {
|
||||||
|
self.volumePercent = Int32((min(max(volume, 0), 1) * 100).rounded())
|
||||||
|
self.player.audio?.volume = self.volumePercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRate(rate: Float) {
|
||||||
|
onMain {
|
||||||
|
self.rate = rate
|
||||||
|
self.player.rate = rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func destroy() {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
self.released = true
|
||||||
|
self.listener = nil
|
||||||
|
self.player.delegate = nil
|
||||||
|
self.player.stop()
|
||||||
|
self.player.media = nil
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: VLCMediaPlayerDelegate
|
||||||
|
|
||||||
|
func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
switch newState {
|
||||||
|
case .opening:
|
||||||
|
self.listener?.onState(state: .opening)
|
||||||
|
case .playing:
|
||||||
|
self.hasStarted = true
|
||||||
|
self.applyPlaybackSettings()
|
||||||
|
self.reportDuration()
|
||||||
|
self.listener?.onState(state: .playing)
|
||||||
|
case .paused:
|
||||||
|
self.listener?.onState(state: .paused)
|
||||||
|
case .stopping, .stopped:
|
||||||
|
self.handleStop(isFinalStop: newState == .stopped)
|
||||||
|
case .error:
|
||||||
|
self.finished = true
|
||||||
|
self.listener?.onError(message: "VLC could not play this media")
|
||||||
|
self.listener?.onState(state: .idle)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released, let ms = self.player.time.value?.int64Value, ms >= 0 else { return }
|
||||||
|
self.listener?.onTime(positionMs: ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPlayerLengthChanged(_ length: Int64) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
self.listener?.onDuration(durationMs: length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPlayerBufferingChanged(_ progress: Float) {
|
||||||
|
onMain {
|
||||||
|
guard !self.released else { return }
|
||||||
|
self.listener?.onBuffered(percent: progress * 100)
|
||||||
|
// VLC always reports 0 and 1 before the first frame; only mid-playback stalls matter.
|
||||||
|
guard self.hasStarted else { return }
|
||||||
|
if progress < 1 {
|
||||||
|
self.listener?.onState(state: .buffering)
|
||||||
|
} else if self.player.isPlaying {
|
||||||
|
self.listener?.onState(state: .playing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
private func startPlayback() {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
let wasPaused = player.state == .paused
|
||||||
|
player.play()
|
||||||
|
if wasPaused { kickTimeReporting() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a pause, VLCKit 4 withholds time updates until libvlc reports a fresh time point, and
|
||||||
|
/// on iOS that report doesn't come after a resume: audio plays but the position stays frozen.
|
||||||
|
/// A seek goes through a different callback that does reset VLCKit's time tracking, so seek
|
||||||
|
/// to where we already are.
|
||||||
|
private func kickTimeReporting() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||||
|
guard let self, !self.released, self.player.isPlaying, self.player.isSeekable else { return }
|
||||||
|
self.player.time = self.player.time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the current media and marks the resulting stop events as ours.
|
||||||
|
private func stopCurrentMedia() {
|
||||||
|
switch player.state {
|
||||||
|
case .opening, .playing, .paused, .stopping:
|
||||||
|
stopRequested = true
|
||||||
|
player.stop()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleStop(isFinalStop: Bool) {
|
||||||
|
if stopRequested {
|
||||||
|
if isFinalStop {
|
||||||
|
stopRequested = false
|
||||||
|
listener?.onState(state: .idle)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasStarted && !finished {
|
||||||
|
finished = true
|
||||||
|
listener?.onState(state: .ended)
|
||||||
|
} else if isFinalStop && !finished {
|
||||||
|
listener?.onState(state: .idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPlaybackSettings() {
|
||||||
|
player.audio?.volume = volumePercent
|
||||||
|
if player.rate != rate { player.rate = rate }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reportDuration() {
|
||||||
|
if let ms = player.media?.length.value?.int64Value, ms > 0 {
|
||||||
|
listener?.onDuration(durationMs: ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onMain(_ work: @escaping () -> Void) {
|
||||||
|
DispatchQueue.main.async(execute: work)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,10 @@ 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