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
|
||||
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||
@ -51,6 +54,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
private val streamingUrlRepository = get<StreamingUrlRepository>()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -515,7 +519,25 @@ class DeviceAudioPlayerQueue(
|
||||
private suspend fun QueueEntry.toMediaItem(): MediaItem? {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> {
|
||||
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||
val isIos = getPlatform().type == PlatformType.IOS
|
||||
val requiresIosResolution = isIos && (
|
||||
url.isBlank() ||
|
||||
url.startsWith("http://127.0.0.1") ||
|
||||
url.startsWith("https://127.0.0.1") ||
|
||||
url.startsWith("http://[::1]") ||
|
||||
url.startsWith("https://[::1]") ||
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("https://localhost"))
|
||||
val resolvedStream = if (requiresIosResolution) {
|
||||
streamingUrlRepository.resolveStreamInfo(track)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val resolvedUrl = if (requiresIosResolution) {
|
||||
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
|
||||
} else {
|
||||
url.ifBlank { buildStreamingUrl(track.id, protocol) }
|
||||
}
|
||||
if (resolvedUrl.isBlank()) return null
|
||||
MediaItem(
|
||||
title = track.title,
|
||||
@ -524,7 +546,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = track.durationMs.milliseconds,
|
||||
coverURL = artworkUrl.orEmpty(),
|
||||
url = resolvedUrl,
|
||||
protocol = protocol)
|
||||
protocol = resolvedStream?.protocol ?: protocol)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
|
||||
@ -23,11 +23,9 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.modules.plugin.AudioPluginSource
|
||||
import org.spotube.spotube.modules.settings.UserSettingsSource
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@ -80,8 +78,7 @@ class StreamingUrlRepository(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val settingsRepository: UserSettingsSource,
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||
private val platform: PlatformType = currentPlatform) : KoinComponent {
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<StreamingUrlRepository>()
|
||||
private val streamUrlCacheMutex = Mutex()
|
||||
@ -146,7 +143,7 @@ class StreamingUrlRepository(
|
||||
} else {
|
||||
stream.streams
|
||||
}
|
||||
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
|
||||
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
|
||||
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
||||
}
|
||||
|
||||
@ -256,12 +253,11 @@ class StreamingUrlRepository(
|
||||
logger.i {
|
||||
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
|
||||
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
|
||||
"(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
||||
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
||||
"lowest wins):\n" +
|
||||
streams.joinToString("\n") { stream ->
|
||||
val marker = if (stream === selected) " -> " else " "
|
||||
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
|
||||
"${formatMismatchCount(stream, preferredFormat)}, " +
|
||||
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
|
||||
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
|
||||
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
|
||||
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
|
||||
@ -269,10 +265,6 @@ class StreamingUrlRepository(
|
||||
}
|
||||
when {
|
||||
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
|
||||
!selected.isPlayableOn(platform) -> logger.w {
|
||||
"[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
|
||||
"${selected.describeForLog()}, which the player will likely reject"
|
||||
}
|
||||
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
|
||||
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
|
||||
"got ${selected.codec}/${selected.container} (${selected.protocol})"
|
||||
@ -400,21 +392,16 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
||||
}
|
||||
}
|
||||
|
||||
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
|
||||
|
||||
/**
|
||||
* Selects the available stream closest to the user's preferred format and quality.
|
||||
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
|
||||
* preferences say: e.g. opus/webm is unplayable on iOS.
|
||||
* Selects the available stream closest to the user's preferred format and quality. Every
|
||||
* platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container,
|
||||
* codec and protocol plugins return, so nothing is ruled out up front.
|
||||
*/
|
||||
internal fun selectPreferredAudioStream(
|
||||
streams: List<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
if (stream.isPlayableOn(platform)) 0 else 1
|
||||
}.thenBy { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.thenBy { stream ->
|
||||
if (stream.matchesQualityType(preferredQuality)) 0 else 1
|
||||
@ -425,25 +412,6 @@ internal fun selectPreferredAudioStream(
|
||||
}
|
||||
)
|
||||
|
||||
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
|
||||
|
||||
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
|
||||
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
|
||||
// AVPlayer has no WebM/Ogg/Matroska demuxer.
|
||||
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
|
||||
|
||||
/**
|
||||
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
|
||||
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
|
||||
*/
|
||||
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
|
||||
isContainerPlayableOn(container, platform) &&
|
||||
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
|
||||
|
||||
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
|
||||
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
|
||||
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
|
||||
|
||||
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
|
||||
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
|
||||
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
|
||||
|
||||
@ -17,9 +17,6 @@
|
||||
|
||||
package org.spotube.spotube.modules.settings
|
||||
|
||||
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||
import org.spotube.spotube.core.server.currentPlatform
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import kotlinx.serialization.SerialName
|
||||
@ -101,23 +98,12 @@ const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
|
||||
internal fun isJamServerUrl(url: String): Boolean =
|
||||
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
|
||||
|
||||
/**
|
||||
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
|
||||
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
|
||||
*/
|
||||
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
|
||||
val aac = !isContainerPlayableOn("webm", platform)
|
||||
return AudioFormat(
|
||||
codec = if (aac) "aac" else "opus",
|
||||
container = if (aac) "mp4" else "webm",
|
||||
qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000)))
|
||||
}
|
||||
|
||||
/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */
|
||||
internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings =
|
||||
if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this
|
||||
else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform))
|
||||
/** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */
|
||||
internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat(
|
||||
codec = "opus",
|
||||
container = "webm",
|
||||
qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000)))
|
||||
|
||||
@ -59,7 +59,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
|
||||
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||
val json = prefs[SETTINGS_KEY]
|
||||
if (json != null) {
|
||||
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
|
||||
settingsJson.decodeFromString<UserSettings>(json as String)
|
||||
} else {
|
||||
UserSettings() // Default value
|
||||
}
|
||||
@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
|
||||
suspend fun getCurrentSettings(): UserSettings {
|
||||
val preferences = database.settingsDataStore.data.first()
|
||||
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
||||
return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
|
||||
return settingsJson.decodeFromString<UserSettings>(storedJson)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
package org.spotube.spotube.modules.settings.sections
|
||||
|
||||
|
||||
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||
import org.spotube.spotube.core.server.currentPlatform
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
@ -58,7 +56,6 @@ private val standardLossyQualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
|
||||
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
|
||||
internal val streamingFormatPresets = listOf(
|
||||
AudioFormat(
|
||||
codec = "opus",
|
||||
@ -72,7 +69,7 @@ internal val streamingFormatPresets = listOf(
|
||||
codec = "vorbis",
|
||||
container = "ogg",
|
||||
qualities = standardLossyQualities)
|
||||
).filter { isContainerPlayableOn(it.container, currentPlatform) }
|
||||
)
|
||||
|
||||
internal val downloadFormatPresets = listOf(
|
||||
AudioFormat(
|
||||
|
||||
@ -5,7 +5,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.modules.settings.UserSettings
|
||||
import co.touchlab.kermit.Logger
|
||||
@ -100,9 +99,7 @@ class StreamingUrlRepositoryBehaviorTest {
|
||||
audioPlayerQueue = queue,
|
||||
matchedTracksRepository = matchedTracksRepository,
|
||||
settingsRepository = settings,
|
||||
streamUrlCacheTtlMs = cacheTtlMs,
|
||||
// Not the host's platform: Windows and iOS don't prefer opus/webm
|
||||
platform = PlatformType.Linux)
|
||||
streamUrlCacheTtlMs = cacheTtlMs)
|
||||
|
||||
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
|
||||
val service = FakeAudioPluginService(pluginId, audioAPI)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package org.spotube.spotube.core.server
|
||||
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
@ -14,7 +13,6 @@ import kotlin.test.assertSame
|
||||
* Tests for the stream preference selection used by [StreamingUrlRepository].
|
||||
*
|
||||
* Ordering contract (best first):
|
||||
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
|
||||
* 1. Fewest codec/container mismatches against the preferred format.
|
||||
* 2. Matching quality family (lossy vs lossless).
|
||||
* 3. Closest bitrate / sample rate.
|
||||
@ -56,8 +54,7 @@ class StreamingUrlRepositoryTest {
|
||||
private fun select(
|
||||
streams: List<AudioStream>,
|
||||
format: AudioFormat = opusWebm(),
|
||||
quality: AudioQuality = AudioQuality.Lossy(256_000),
|
||||
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
|
||||
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
|
||||
|
||||
// ---------- empty / single ----------
|
||||
|
||||
@ -407,33 +404,20 @@ class StreamingUrlRepositoryTest {
|
||||
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
||||
}
|
||||
|
||||
// ---------- device playability ----------
|
||||
// ---------- no platform restrictions ----------
|
||||
|
||||
@Test
|
||||
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
|
||||
fun `the preferred opus webm stream wins over aac`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
|
||||
assertSame(opus, select(listOf(aac, opus)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
|
||||
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
|
||||
fun `a dash manifest closest to the preferences is not skipped`() {
|
||||
val dash = lossy("dash", 256_000, protocol = StreamProtocol.DASH)
|
||||
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `off iOS the preferred opus webm stream still wins`() {
|
||||
val opus = lossy("opus-160", 160_000)
|
||||
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
|
||||
assertSame(dash, select(listOf(progressive, dash)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -17,39 +17,19 @@
|
||||
|
||||
package org.spotube.spotube.modules.settings
|
||||
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import org.spotube.spotube.PlatformType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
|
||||
class StreamingFormatDefaultsTest {
|
||||
|
||||
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
|
||||
|
||||
@Test
|
||||
fun `ios defaults to aac in mp4`() {
|
||||
val format = defaultStreamingMusicFormat(PlatformType.IOS)
|
||||
assertEquals("aac" to "mp4", format.codec to format.container)
|
||||
fun `streaming defaults to opus in webm`() {
|
||||
val format = defaultStreamingMusicFormat()
|
||||
assertEquals("opus" to "webm", format.codec to format.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other platforms keep opus in webm`() {
|
||||
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
|
||||
val format = defaultStreamingMusicFormat(platform)
|
||||
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved opus webm is moved to aac on ios`() {
|
||||
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
|
||||
assertEquals("mp4", migrated.streamingMusicFormat.container)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved opus webm is kept off ios`() {
|
||||
val settings = UserSettings(streamingMusicFormat = opusWebm)
|
||||
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
|
||||
fun `user settings default to the streaming default`() {
|
||||
assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,16 +15,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalForeignApi::class)
|
||||
|
||||
package org.spotube.spotube.core.audioplayer
|
||||
|
||||
import kotlinx.cinterop.CValue
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -32,41 +28,39 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import platform.AVFoundation.AVPlayer
|
||||
import platform.AVFoundation.AVPlayerItem
|
||||
import platform.AVFoundation.AVPlayerTimeControlStatus
|
||||
import platform.AVFoundation.AVPlayerTimeControlStatusPaused
|
||||
import platform.AVFoundation.AVPlayerTimeControlStatusPlaying
|
||||
import platform.AVFoundation.AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate
|
||||
import platform.AVFoundation.addPeriodicTimeObserverForInterval
|
||||
import platform.AVFoundation.currentItem
|
||||
import platform.AVFoundation.duration
|
||||
import platform.AVFoundation.loadedTimeRanges
|
||||
import platform.AVFoundation.pause
|
||||
import platform.AVFoundation.play
|
||||
import platform.AVFoundation.rate
|
||||
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||
import platform.AVFoundation.seekToTime
|
||||
import platform.AVFoundation.timeControlStatus
|
||||
import platform.AVFoundation.volume
|
||||
import platform.CoreMedia.CMTime
|
||||
import platform.CoreMedia.CMTimeGetSeconds
|
||||
import platform.Foundation.NSURL
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* iOS player. Playback is done by VLCKit through [NativePlayerEngine]; AVPlayer misreports the
|
||||
* duration of some fragmented MP4 streams (doubling it, which leaves the second half silent).
|
||||
* The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here.
|
||||
*/
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
||||
|
||||
actual val context: Any = context
|
||||
|
||||
private val avPlayer: AVPlayer = AVPlayer()
|
||||
private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) {
|
||||
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
|
||||
}.create()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val lock = Mutex()
|
||||
private val mediaSession = IosMediaSession()
|
||||
|
||||
private var disposed = false
|
||||
|
||||
private val originalPlaylist = mutableListOf<MediaItem>()
|
||||
private val currentPlaylist = mutableListOf<MediaItem>()
|
||||
private val urlIndexMap = mutableMapOf<String, Int>()
|
||||
private var currentIndex = -1
|
||||
private var shuffleEnabled = false
|
||||
|
||||
// The engine holds media only after the first play(); until then a requested start position
|
||||
// is kept here and handed over with the media.
|
||||
private var engineHasMedia = false
|
||||
private var pendingStartMs = 0L
|
||||
|
||||
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
||||
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
||||
@ -94,97 +88,191 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||
|
||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||
|
||||
init {
|
||||
avPlayer.addPeriodicTimeObserverForInterval(
|
||||
interval = CMTimeMake(1, 4),
|
||||
queue = null
|
||||
) { time ->
|
||||
if (!disposed) {
|
||||
val seconds = CMTimeGetSeconds(time)
|
||||
_position.tryEmit(seconds.seconds)
|
||||
|
||||
avPlayer.currentItem?.let { item ->
|
||||
val durationSeconds = CMTimeGetSeconds(item.duration)
|
||||
if (durationSeconds > 0) {
|
||||
_duration.tryEmit(durationSeconds.seconds)
|
||||
}
|
||||
val loadedRanges = item.loadedTimeRanges
|
||||
// if (loadedRanges.count() > 0u) {
|
||||
// val range = loadedRanges.objectAtIndex(0u)
|
||||
// }
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
while (!disposed) {
|
||||
syncTimeControlStatus()
|
||||
delay(500)
|
||||
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 {
|
||||
engine.setListener(engineListener)
|
||||
mediaSession.configure(
|
||||
onPlay = { scope.launch { play() } },
|
||||
onPause = { scope.launch { pause() } },
|
||||
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
|
||||
onNext = { scope.launch { skipToNext() } },
|
||||
onPrevious = { scope.launch { skipToPrevious() } })
|
||||
}
|
||||
|
||||
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
||||
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
|
||||
val item = currentPlaylist.getOrNull(currentIndex) ?: return
|
||||
val startMs = pendingStartMs
|
||||
pendingStartMs = 0L
|
||||
engineHasMedia = true
|
||||
_position.tryEmit(startMs.milliseconds)
|
||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||
_duration.tryEmit(item.duration)
|
||||
engine.load(item.url, autoPlay, startMs)
|
||||
}
|
||||
|
||||
/** Switches to [index] and starts it. Caller holds [lock]. */
|
||||
private fun playIndexLocked(index: Int) {
|
||||
currentIndex = index
|
||||
_currentMediaItem.tryEmit(currentPlaylist[index])
|
||||
pendingStartMs = 0L
|
||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||
}
|
||||
|
||||
private fun onMediaEndedLocked() {
|
||||
if (disposed || currentPlaylist.isEmpty()) return
|
||||
_completion.tryEmit(Unit)
|
||||
when {
|
||||
_loopState.value == LoopState.ONE -> playIndexLocked(currentIndex.coerceAtLeast(0))
|
||||
currentIndex < currentPlaylist.lastIndex -> playIndexLocked(currentIndex + 1)
|
||||
_loopState.value == LoopState.ALL -> playIndexLocked(0)
|
||||
else -> {
|
||||
_playerState.tryEmit(PlayerState.COMPLETED)
|
||||
engineHasMedia = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun 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
|
||||
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)
|
||||
}
|
||||
_playerState.tryEmit(playerState)
|
||||
}
|
||||
|
||||
if (avPlayer.currentItem == null) {
|
||||
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(
|
||||
_currentMediaItem.value,
|
||||
0.0,
|
||||
_duration.value.inWholeMilliseconds / 1000.0,
|
||||
false)
|
||||
engineHasMedia = false
|
||||
pendingStartMs = 0L
|
||||
_position.tryEmit(Duration.ZERO)
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveState(): PlayerState {
|
||||
return when (avPlayer.timeControlStatus) {
|
||||
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
||||
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
||||
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
||||
else -> PlayerState.IDLE
|
||||
actual override suspend fun seekTo(position: Duration) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val maxMs = _duration.value.inWholeMilliseconds
|
||||
val targetMs = if (maxMs > 0) {
|
||||
position.inWholeMilliseconds.coerceIn(0, maxMs)
|
||||
} else {
|
||||
position.inWholeMilliseconds.coerceAtLeast(0)
|
||||
}
|
||||
if (engineHasMedia) engine.seekTo(targetMs) else pendingStartMs = targetMs
|
||||
_position.tryEmit(targetMs.milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
|
||||
val nsUrl = NSURL.URLWithString(url) ?: return null
|
||||
return AVPlayerItem(nsUrl)
|
||||
}
|
||||
|
||||
actual override suspend fun play() {
|
||||
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)
|
||||
}
|
||||
|
||||
actual override suspend fun seekTo(position: Duration) {
|
||||
val seconds = position.inWholeMilliseconds / 1000.0
|
||||
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
|
||||
avPlayer.seekToTime(cmTime)
|
||||
_position.tryEmit(position)
|
||||
}
|
||||
|
||||
actual override suspend fun loop(state: LoopState) {
|
||||
_loopState.tryEmit(state)
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
_loopState.tryEmit(state)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun shuffle(enabled: Boolean) {
|
||||
_shuffleMode.tryEmit(enabled)
|
||||
lock.withLock {
|
||||
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
|
||||
|
||||
val currentItem = _currentMediaItem.value
|
||||
val rebuilt = if (enabled) {
|
||||
if (currentItem != null) {
|
||||
listOf(currentItem) + originalPlaylist.filterNot { it.url == currentItem.url }.shuffled()
|
||||
} else {
|
||||
originalPlaylist.shuffled()
|
||||
}
|
||||
} else {
|
||||
originalPlaylist.toList()
|
||||
}
|
||||
|
||||
currentPlaylist.clear()
|
||||
currentPlaylist.addAll(rebuilt)
|
||||
shuffleEnabled = enabled
|
||||
_shuffleMode.tryEmit(enabled)
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
|
||||
currentIndex = if (currentItem == null) 0 else {
|
||||
currentPlaylist.indexOfFirst { it.url == currentItem.url }.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun load(
|
||||
@ -192,143 +280,186 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int
|
||||
) {
|
||||
currentPlaylist.clear()
|
||||
currentPlaylist.addAll(playlist)
|
||||
urlIndexMap.clear()
|
||||
playlist.forEachIndexed { index, item ->
|
||||
urlIndexMap[item.url] = index
|
||||
}
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
|
||||
if (playlist.isEmpty()) {
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
_currentMediaItem.tryEmit(null)
|
||||
originalPlaylist.clear()
|
||||
originalPlaylist.addAll(playlist)
|
||||
currentPlaylist.clear()
|
||||
currentPlaylist.addAll(playlist)
|
||||
shuffleEnabled = false
|
||||
_shuffleMode.tryEmit(false)
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
|
||||
engine.stop()
|
||||
engineHasMedia = false
|
||||
pendingStartMs = 0L
|
||||
_position.tryEmit(Duration.ZERO)
|
||||
_duration.tryEmit(Duration.ZERO)
|
||||
return
|
||||
}
|
||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||
|
||||
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
|
||||
val item = playlist[safeIndex]
|
||||
val avItem = buildAVPlayerItem(item.url)
|
||||
if (currentPlaylist.isEmpty()) {
|
||||
currentIndex = -1
|
||||
_currentMediaItem.tryEmit(null)
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
_duration.tryEmit(Duration.ZERO)
|
||||
return
|
||||
}
|
||||
|
||||
if (avItem == null) {
|
||||
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
return
|
||||
}
|
||||
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
||||
mediaSession.update(
|
||||
_currentMediaItem.value,
|
||||
0.0,
|
||||
_duration.value.inWholeMilliseconds / 1000.0,
|
||||
false)
|
||||
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(item)
|
||||
|
||||
if (autoPlay) {
|
||||
avPlayer.play()
|
||||
if (autoPlay) {
|
||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||
} else {
|
||||
_playerState.tryEmit(PlayerState.PAUSED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||
currentPlaylist.add(mediaItem)
|
||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
originalPlaylist.add(mediaItem)
|
||||
currentPlaylist.add(mediaItem)
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
if (currentIndex == -1) {
|
||||
currentIndex = 0
|
||||
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
||||
val currentIndex = currentPlaylist.indexOfFirst {
|
||||
it.url == _currentMediaItem.value?.url
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
|
||||
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem)
|
||||
currentPlaylist.add(insertIndex, mediaItem)
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
if (currentIndex == -1) {
|
||||
currentIndex = 0
|
||||
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
|
||||
}
|
||||
}
|
||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
|
||||
currentPlaylist.add(insertIndex, mediaItem)
|
||||
urlIndexMap.clear()
|
||||
currentPlaylist.forEachIndexed { index, item ->
|
||||
urlIndexMap[item.url] = index
|
||||
}
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
}
|
||||
|
||||
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
|
||||
val index = urlIndexMap[mediaItem.url] ?: return
|
||||
currentPlaylist.removeAt(index)
|
||||
urlIndexMap.clear()
|
||||
currentPlaylist.forEachIndexed { i, item ->
|
||||
urlIndexMap[item.url] = i
|
||||
lock.withLock {
|
||||
if (disposed || currentPlaylist.isEmpty()) return
|
||||
|
||||
originalPlaylist.removeAll { it.url == mediaItem.url }
|
||||
val currentUrl = _currentMediaItem.value?.url
|
||||
val removedCurrent = currentUrl == mediaItem.url
|
||||
val removalIndices = currentPlaylist.withIndex()
|
||||
.filter { it.value.url == mediaItem.url }
|
||||
.map { it.index }
|
||||
if (removalIndices.isEmpty()) return
|
||||
|
||||
// Items before the playing one shift it down, so keep the index on the same item.
|
||||
val removedBefore = removalIndices.count { it < currentIndex }
|
||||
removalIndices.asReversed().forEach { currentPlaylist.removeAt(it) }
|
||||
|
||||
if (currentPlaylist.isEmpty()) {
|
||||
currentIndex = -1
|
||||
_currentMediaItem.tryEmit(null)
|
||||
engine.stop()
|
||||
engineHasMedia = false
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
} else if (removedCurrent) {
|
||||
currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||
engine.stop()
|
||||
engineHasMedia = false
|
||||
} else {
|
||||
currentIndex = (currentIndex - removedBefore).coerceIn(0, currentPlaylist.lastIndex)
|
||||
}
|
||||
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
}
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
}
|
||||
|
||||
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||
val item = currentPlaylist.removeAt(fromIndex)
|
||||
currentPlaylist.add(toIndex, item)
|
||||
urlIndexMap.clear()
|
||||
currentPlaylist.forEachIndexed { i, it ->
|
||||
urlIndexMap[it.url] = i
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||
|
||||
val item = currentPlaylist.removeAt(fromIndex)
|
||||
currentPlaylist.add(toIndex, item)
|
||||
|
||||
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
|
||||
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
|
||||
}
|
||||
|
||||
val currentUrl = _currentMediaItem.value?.url
|
||||
currentIndex = currentUrl?.let { url ->
|
||||
currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 }
|
||||
} ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
||||
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
|
||||
}
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
}
|
||||
|
||||
actual override suspend fun skipToNext() {
|
||||
val currentIndex = currentPlaylist.indexOfFirst {
|
||||
it.url == _currentMediaItem.value?.url
|
||||
}
|
||||
val nextIndex = currentIndex + 1
|
||||
if (nextIndex < currentPlaylist.size) {
|
||||
val nextItem = currentPlaylist[nextIndex]
|
||||
val avItem = buildAVPlayerItem(nextItem.url)
|
||||
if (avItem != null) {
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(nextItem)
|
||||
avPlayer.play()
|
||||
}
|
||||
lock.withLock {
|
||||
if (disposed || currentPlaylist.isEmpty()) return
|
||||
playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun skipToPrevious() {
|
||||
val currentIndex = currentPlaylist.indexOfFirst {
|
||||
it.url == _currentMediaItem.value?.url
|
||||
}
|
||||
val prevIndex = currentIndex - 1
|
||||
if (prevIndex >= 0) {
|
||||
val prevItem = currentPlaylist[prevIndex]
|
||||
val avItem = buildAVPlayerItem(prevItem.url)
|
||||
if (avItem != null) {
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(prevItem)
|
||||
avPlayer.play()
|
||||
}
|
||||
lock.withLock {
|
||||
if (disposed || currentPlaylist.isEmpty()) return
|
||||
playIndexLocked((currentIndex - 1).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun jumpTo(index: Int) {
|
||||
if (index in currentPlaylist.indices) {
|
||||
val item = currentPlaylist[index]
|
||||
val avItem = buildAVPlayerItem(item.url)
|
||||
if (avItem != null) {
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(item)
|
||||
}
|
||||
lock.withLock {
|
||||
if (disposed || index !in currentPlaylist.indices) return
|
||||
playIndexLocked(index)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
avPlayer.volume = clamped
|
||||
_volume.tryEmit(clamped)
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
engine.setVolume(clamped)
|
||||
_volume.tryEmit(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||
val clamped = speed.coerceIn(0.25f, 4f)
|
||||
avPlayer.rate = clamped
|
||||
_playbackSpeed.tryEmit(clamped)
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val clamped = speed.coerceIn(0.25f, 4f)
|
||||
engine.setRate(clamped)
|
||||
_playbackSpeed.tryEmit(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
actual override fun isDisposed(): Boolean = disposed
|
||||
|
||||
actual override fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
avPlayer.pause()
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||
engine.setListener(null)
|
||||
engine.destroy()
|
||||
mediaSession.deactivate()
|
||||
mediaSession.clear()
|
||||
scope.cancel()
|
||||
|
||||
originalPlaylist.clear()
|
||||
currentPlaylist.clear()
|
||||
urlIndexMap.clear()
|
||||
currentIndex = -1
|
||||
engineHasMedia = false
|
||||
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
_currentMediaItem.tryEmit(null)
|
||||
@ -338,11 +469,3 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CMTimeMake(value: Long, timescale: Int): CValue<CMTime> {
|
||||
return platform.CoreMedia.CMTimeMake(value, timescale)
|
||||
}
|
||||
|
||||
private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue<CMTime> {
|
||||
return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@ -41,6 +45,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -85,6 +90,7 @@
|
||||
);
|
||||
name = iosApp;
|
||||
packageProductDependencies = (
|
||||
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
|
||||
);
|
||||
productName = iosApp;
|
||||
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
||||
@ -114,6 +120,9 @@
|
||||
);
|
||||
mainGroup = EFA169D322B90F32841D602F;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
||||
projectDirPath = "";
|
||||
@ -368,6 +377,25 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/videolan/vlckit";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = 6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
9A1B2C3D4E5F60718293A4B3 /* VLCKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */;
|
||||
productName = VLCKit;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = B056E79C138AE849968965F1 /* Project object */;
|
||||
}
|
||||
@ -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>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<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
|
||||
struct iOSApp: App {
|
||||
init() {
|
||||
registerNativePlayerEngine()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user