mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
No commits in common. "6223c438cfe3ac77c5f5611d87a222c877f2d10b" and "784d9260d11259bb32d27acd329fa815bc20cd3d" have entirely different histories.
6223c438cf
...
784d9260d1
12
.github/workflows/spotube-release-binary.yml
vendored
12
.github/workflows/spotube-release-binary.yml
vendored
@ -659,16 +659,6 @@ jobs:
|
|||||||
- name: Make gradlew executable
|
- name: Make gradlew executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
# MobileVLCKit comes from CocoaPods; iosApp/Pods/ is gitignored and the workspace needs it.
|
|
||||||
- name: Cache CocoaPods
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: iosApp/Pods
|
|
||||||
key: pods-${{ runner.os }}-${{ hashFiles('iosApp/Podfile.lock') }}
|
|
||||||
|
|
||||||
- name: Install CocoaPods dependencies
|
|
||||||
run: pod install --deployment --project-directory=iosApp
|
|
||||||
|
|
||||||
# Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading
|
# Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading
|
||||||
# (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate.
|
# (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate.
|
||||||
# The Gradle build phase inside Xcode reads these as project properties.
|
# The Gradle build phase inside Xcode reads these as project properties.
|
||||||
@ -678,7 +668,7 @@ jobs:
|
|||||||
ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }}
|
ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }}
|
||||||
run: >
|
run: >
|
||||||
xcodebuild
|
xcodebuild
|
||||||
-workspace iosApp/iosApp.xcworkspace
|
-project iosApp/iosApp.xcodeproj
|
||||||
-scheme iosApp
|
-scheme iosApp
|
||||||
-configuration Release
|
-configuration Release
|
||||||
-sdk iphoneos
|
-sdk iphoneos
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,7 +16,6 @@ captures
|
|||||||
!*.xcodeproj/project.xcworkspace/
|
!*.xcodeproj/project.xcworkspace/
|
||||||
!*.xcworkspace/contents.xcworkspacedata
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
iosApp/Pods/
|
|
||||||
node_modules/
|
node_modules/
|
||||||
composeApp/release/
|
composeApp/release/
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`).
|
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`).
|
||||||
- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`.
|
- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`.
|
||||||
- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
|
- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
|
||||||
- iOS audio: MobileVLCKit from CocoaPods (`iosApp/Podfile`), driven by `iosApp/iosApp/VlcPlayerEngine.swift` through `NativePlayerEngine` (iosMain). Run `pod install` in `iosApp/` first, then build `iosApp/iosApp.xcworkspace` (not the `.xcodeproj`, which can't build the Pods). Android Studio's iOS run config must point at the workspace too.
|
|
||||||
|
|
||||||
## High-value commands
|
## High-value commands
|
||||||
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.
|
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.
|
||||||
|
|||||||
@ -23,9 +23,11 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import org.spotube.spotube.core.di.injectLogger
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
|
import org.spotube.spotube.getPlatform
|
||||||
import org.spotube.spotube.modules.plugin.AudioPluginSource
|
import org.spotube.spotube.modules.plugin.AudioPluginSource
|
||||||
import org.spotube.spotube.modules.settings.UserSettingsSource
|
import org.spotube.spotube.modules.settings.UserSettingsSource
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@ -78,7 +80,8 @@ class StreamingUrlRepository(
|
|||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: TrackSourceRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val settingsRepository: UserSettingsSource,
|
private val settingsRepository: UserSettingsSource,
|
||||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||||
|
private val platform: PlatformType = currentPlatform) : KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
@ -143,7 +146,7 @@ class StreamingUrlRepository(
|
|||||||
} else {
|
} else {
|
||||||
stream.streams
|
stream.streams
|
||||||
}
|
}
|
||||||
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
|
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
|
||||||
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,11 +256,12 @@ class StreamingUrlRepository(
|
|||||||
logger.i {
|
logger.i {
|
||||||
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
|
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
|
||||||
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
|
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
|
||||||
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
"(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
|
||||||
"lowest wins):\n" +
|
"lowest wins):\n" +
|
||||||
streams.joinToString("\n") { stream ->
|
streams.joinToString("\n") { stream ->
|
||||||
val marker = if (stream === selected) " -> " else " "
|
val marker = if (stream === selected) " -> " else " "
|
||||||
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
|
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
|
||||||
|
"${formatMismatchCount(stream, preferredFormat)}, " +
|
||||||
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
|
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
|
||||||
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
|
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
|
||||||
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
|
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
|
||||||
@ -265,6 +269,10 @@ class StreamingUrlRepository(
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
|
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
|
||||||
|
!selected.isPlayableOn(platform) -> logger.w {
|
||||||
|
"[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
|
||||||
|
"${selected.describeForLog()}, which the player will likely reject"
|
||||||
|
}
|
||||||
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
|
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
|
||||||
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
|
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
|
||||||
"got ${selected.codec}/${selected.container} (${selected.protocol})"
|
"got ${selected.codec}/${selected.container} (${selected.protocol})"
|
||||||
@ -392,16 +400,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the available stream closest to the user's preferred format and quality. Every
|
* Selects the available stream closest to the user's preferred format and quality.
|
||||||
* platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container,
|
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
|
||||||
* codec and protocol plugins return, so nothing is ruled out up front.
|
* preferences say: e.g. opus/webm is unplayable on iOS.
|
||||||
*/
|
*/
|
||||||
internal fun selectPreferredAudioStream(
|
internal fun selectPreferredAudioStream(
|
||||||
streams: List<AudioStream>,
|
streams: List<AudioStream>,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
preferredQuality: AudioQuality,
|
||||||
|
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
|
||||||
compareBy<AudioStream> { stream ->
|
compareBy<AudioStream> { stream ->
|
||||||
|
if (stream.isPlayableOn(platform)) 0 else 1
|
||||||
|
}.thenBy { stream ->
|
||||||
formatMismatchCount(stream, preferredFormat)
|
formatMismatchCount(stream, preferredFormat)
|
||||||
}.thenBy { stream ->
|
}.thenBy { stream ->
|
||||||
if (stream.matchesQualityType(preferredQuality)) 0 else 1
|
if (stream.matchesQualityType(preferredQuality)) 0 else 1
|
||||||
@ -412,6 +425,25 @@ internal fun selectPreferredAudioStream(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
|
||||||
|
|
||||||
|
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
|
||||||
|
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
|
||||||
|
// AVPlayer has no WebM/Ogg/Matroska demuxer.
|
||||||
|
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
|
||||||
|
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
|
||||||
|
*/
|
||||||
|
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
|
||||||
|
isContainerPlayableOn(container, platform) &&
|
||||||
|
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
|
||||||
|
|
||||||
|
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
|
||||||
|
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
|
||||||
|
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
|
||||||
|
|
||||||
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
|
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
|
||||||
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
|
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
|
||||||
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
|
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
|
||||||
|
|||||||
@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
package org.spotube.spotube.modules.settings
|
package org.spotube.spotube.modules.settings
|
||||||
|
|
||||||
|
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||||
|
import org.spotube.spotube.core.server.currentPlatform
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
@ -92,18 +95,29 @@ data class UserSettings(
|
|||||||
val lastSupportReminderEpochMs: Long = 0L)
|
val lastSupportReminderEpochMs: Long = 0L)
|
||||||
|
|
||||||
/** The public Group Jam room server. */
|
/** The public Group Jam room server. */
|
||||||
const val DEFAULT_JAM_SERVER_URL = "https://jam.spotube.cc"
|
const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
|
||||||
|
|
||||||
/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
|
/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
|
||||||
internal fun isJamServerUrl(url: String): Boolean =
|
internal fun isJamServerUrl(url: String): Boolean =
|
||||||
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
|
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
|
||||||
|
|
||||||
/** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */
|
/**
|
||||||
internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat(
|
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
|
||||||
codec = "opus",
|
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
|
||||||
container = "webm",
|
*/
|
||||||
qualities = listOf(
|
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
|
||||||
AudioQuality.Lossy(bitrate = 44_000),
|
val aac = !isContainerPlayableOn("webm", platform)
|
||||||
AudioQuality.Lossy(bitrate = 96_000),
|
return AudioFormat(
|
||||||
AudioQuality.Lossy(bitrate = 128_000),
|
codec = if (aac) "aac" else "opus",
|
||||||
AudioQuality.Lossy(bitrate = 256_000)))
|
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))
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
|
|||||||
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
|
||||||
val json = prefs[SETTINGS_KEY]
|
val json = prefs[SETTINGS_KEY]
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
settingsJson.decodeFromString<UserSettings>(json as String)
|
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
|
||||||
} else {
|
} else {
|
||||||
UserSettings() // Default value
|
UserSettings() // Default value
|
||||||
}
|
}
|
||||||
@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
|
|||||||
suspend fun getCurrentSettings(): UserSettings {
|
suspend fun getCurrentSettings(): UserSettings {
|
||||||
val preferences = database.settingsDataStore.data.first()
|
val preferences = database.settingsDataStore.data.first()
|
||||||
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
|
||||||
return settingsJson.decodeFromString<UserSettings>(storedJson)
|
return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
package org.spotube.spotube.modules.settings.sections
|
package org.spotube.spotube.modules.settings.sections
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.spotube.core.server.isContainerPlayableOn
|
||||||
|
import org.spotube.spotube.core.server.currentPlatform
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import org.spotube.millennium.composables.MillenniumSeparator
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import org.spotube.millennium.theme.MillenniumTheme
|
import org.spotube.millennium.theme.MillenniumTheme
|
||||||
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
|
|||||||
AudioQuality.Lossy(bitrate = 128_000),
|
AudioQuality.Lossy(bitrate = 128_000),
|
||||||
AudioQuality.Lossy(bitrate = 256_000))
|
AudioQuality.Lossy(bitrate = 256_000))
|
||||||
|
|
||||||
|
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
|
||||||
internal val streamingFormatPresets = listOf(
|
internal val streamingFormatPresets = listOf(
|
||||||
AudioFormat(
|
AudioFormat(
|
||||||
codec = "opus",
|
codec = "opus",
|
||||||
@ -69,7 +72,7 @@ internal val streamingFormatPresets = listOf(
|
|||||||
codec = "vorbis",
|
codec = "vorbis",
|
||||||
container = "ogg",
|
container = "ogg",
|
||||||
qualities = standardLossyQualities)
|
qualities = standardLossyQualities)
|
||||||
)
|
).filter { isContainerPlayableOn(it.container, currentPlatform) }
|
||||||
|
|
||||||
internal val downloadFormatPresets = listOf(
|
internal val downloadFormatPresets = listOf(
|
||||||
AudioFormat(
|
AudioFormat(
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import org.spotube.spotube.modules.settings.UserSettings
|
import org.spotube.spotube.modules.settings.UserSettings
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
@ -99,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest {
|
|||||||
audioPlayerQueue = queue,
|
audioPlayerQueue = queue,
|
||||||
matchedTracksRepository = matchedTracksRepository,
|
matchedTracksRepository = matchedTracksRepository,
|
||||||
settingsRepository = settings,
|
settingsRepository = settings,
|
||||||
streamUrlCacheTtlMs = cacheTtlMs)
|
streamUrlCacheTtlMs = cacheTtlMs,
|
||||||
|
// Not the host's platform: Windows and iOS don't prefer opus/webm
|
||||||
|
platform = PlatformType.Linux)
|
||||||
|
|
||||||
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
|
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
|
||||||
val service = FakeAudioPluginService(pluginId, audioAPI)
|
val service = FakeAudioPluginService(pluginId, audioAPI)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package org.spotube.spotube.core.server
|
package org.spotube.spotube.core.server
|
||||||
|
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||||
@ -13,6 +14,7 @@ import kotlin.test.assertSame
|
|||||||
* Tests for the stream preference selection used by [StreamingUrlRepository].
|
* Tests for the stream preference selection used by [StreamingUrlRepository].
|
||||||
*
|
*
|
||||||
* Ordering contract (best first):
|
* Ordering contract (best first):
|
||||||
|
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
|
||||||
* 1. Fewest codec/container mismatches against the preferred format.
|
* 1. Fewest codec/container mismatches against the preferred format.
|
||||||
* 2. Matching quality family (lossy vs lossless).
|
* 2. Matching quality family (lossy vs lossless).
|
||||||
* 3. Closest bitrate / sample rate.
|
* 3. Closest bitrate / sample rate.
|
||||||
@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest {
|
|||||||
private fun select(
|
private fun select(
|
||||||
streams: List<AudioStream>,
|
streams: List<AudioStream>,
|
||||||
format: AudioFormat = opusWebm(),
|
format: AudioFormat = opusWebm(),
|
||||||
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
|
quality: AudioQuality = AudioQuality.Lossy(256_000),
|
||||||
|
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
|
||||||
|
|
||||||
// ---------- empty / single ----------
|
// ---------- empty / single ----------
|
||||||
|
|
||||||
@ -404,20 +407,33 @@ class StreamingUrlRepositoryTest {
|
|||||||
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- no platform restrictions ----------
|
// ---------- device playability ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the preferred opus webm stream wins over aac`() {
|
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
|
||||||
val opus = lossy("opus-160", 160_000)
|
val opus = lossy("opus-160", 160_000)
|
||||||
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||||
assertSame(opus, select(listOf(aac, opus)))
|
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a dash manifest closest to the preferences is not skipped`() {
|
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
|
||||||
val dash = lossy("dash", 256_000, protocol = StreamProtocol.DASH)
|
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
|
||||||
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||||
assertSame(dash, select(listOf(progressive, dash)))
|
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
|
||||||
|
val opus = lossy("opus-160", 160_000)
|
||||||
|
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `off iOS the preferred opus webm stream still wins`() {
|
||||||
|
val opus = lossy("opus-160", 160_000)
|
||||||
|
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
|
||||||
|
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,19 +17,39 @@
|
|||||||
|
|
||||||
package org.spotube.spotube.modules.settings
|
package org.spotube.spotube.modules.settings
|
||||||
|
|
||||||
|
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||||
|
import org.spotube.spotube.PlatformType
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
class StreamingFormatDefaultsTest {
|
class StreamingFormatDefaultsTest {
|
||||||
|
|
||||||
|
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `streaming defaults to opus in webm`() {
|
fun `ios defaults to aac in mp4`() {
|
||||||
val format = defaultStreamingMusicFormat()
|
val format = defaultStreamingMusicFormat(PlatformType.IOS)
|
||||||
assertEquals("opus" to "webm", format.codec to format.container)
|
assertEquals("aac" to "mp4", format.codec to format.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `user settings default to the streaming default`() {
|
fun `other platforms keep opus in webm`() {
|
||||||
assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat)
|
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
|
||||||
|
val format = defaultStreamingMusicFormat(platform)
|
||||||
|
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saved opus webm is moved to aac on ios`() {
|
||||||
|
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
|
||||||
|
assertEquals("mp4", migrated.streamingMusicFormat.container)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saved opus webm is kept off ios`() {
|
||||||
|
val settings = UserSettings(streamingMusicFormat = opusWebm)
|
||||||
|
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,12 +15,16 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalForeignApi::class)
|
||||||
|
|
||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
|
import kotlinx.cinterop.CValue
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -28,39 +32,41 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import platform.AVFoundation.AVPlayer
|
||||||
import kotlinx.coroutines.sync.withLock
|
import platform.AVFoundation.AVPlayerItem
|
||||||
|
import platform.AVFoundation.AVPlayerTimeControlStatus
|
||||||
|
import platform.AVFoundation.AVPlayerTimeControlStatusPaused
|
||||||
|
import platform.AVFoundation.AVPlayerTimeControlStatusPlaying
|
||||||
|
import platform.AVFoundation.AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate
|
||||||
|
import platform.AVFoundation.addPeriodicTimeObserverForInterval
|
||||||
|
import platform.AVFoundation.currentItem
|
||||||
|
import platform.AVFoundation.duration
|
||||||
|
import platform.AVFoundation.loadedTimeRanges
|
||||||
|
import platform.AVFoundation.pause
|
||||||
|
import platform.AVFoundation.play
|
||||||
|
import platform.AVFoundation.rate
|
||||||
|
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||||
|
import platform.AVFoundation.seekToTime
|
||||||
|
import platform.AVFoundation.timeControlStatus
|
||||||
|
import platform.AVFoundation.volume
|
||||||
|
import platform.CoreMedia.CMTime
|
||||||
|
import platform.CoreMedia.CMTimeGetSeconds
|
||||||
|
import platform.Foundation.NSURL
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
|
||||||
* iOS player. Playback is done by MobileVLCKit through [NativePlayerEngine]; AVPlayer misreports the
|
|
||||||
* duration of some fragmented MP4 streams (doubling it, which leaves the second half silent).
|
|
||||||
* The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here.
|
|
||||||
*/
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
||||||
|
|
||||||
actual val context: Any = context
|
actual val context: Any = context
|
||||||
|
|
||||||
private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) {
|
private val avPlayer: AVPlayer = AVPlayer()
|
||||||
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
|
|
||||||
}.create()
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val lock = Mutex()
|
|
||||||
private val mediaSession = IosMediaSession()
|
|
||||||
|
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
private val originalPlaylist = mutableListOf<MediaItem>()
|
|
||||||
private val currentPlaylist = mutableListOf<MediaItem>()
|
private val currentPlaylist = mutableListOf<MediaItem>()
|
||||||
private var currentIndex = -1
|
private val urlIndexMap = mutableMapOf<String, Int>()
|
||||||
private var shuffleEnabled = false
|
|
||||||
|
|
||||||
// The engine holds media only after the first play(); until then a requested start position
|
|
||||||
// is kept here and handed over with the media.
|
|
||||||
private var engineHasMedia = false
|
|
||||||
private var pendingStartMs = 0L
|
|
||||||
|
|
||||||
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
private val _playerState = MutableStateFlow(PlayerState.IDLE)
|
||||||
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
||||||
@ -88,182 +94,97 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
private val engineListener = object : NativePlayerListener {
|
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||||
override fun onState(state: NativePlayerState) {
|
|
||||||
if (disposed) return
|
|
||||||
when (state) {
|
|
||||||
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
|
|
||||||
NativePlayerState.OPENING,
|
|
||||||
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
|
|
||||||
NativePlayerState.PLAYING -> {
|
|
||||||
_playerState.tryEmit(PlayerState.PLAYING)
|
|
||||||
mediaSession.update(
|
|
||||||
_currentMediaItem.value,
|
|
||||||
_position.value.inWholeMilliseconds / 1000.0,
|
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
|
||||||
true)
|
|
||||||
}
|
|
||||||
NativePlayerState.PAUSED -> {
|
|
||||||
_playerState.tryEmit(PlayerState.PAUSED)
|
|
||||||
mediaSession.update(
|
|
||||||
_currentMediaItem.value,
|
|
||||||
_position.value.inWholeMilliseconds / 1000.0,
|
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
|
||||||
false)
|
|
||||||
}
|
|
||||||
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTime(positionMs: Long) {
|
|
||||||
if (disposed || positionMs < 0) return
|
|
||||||
_position.tryEmit(positionMs.milliseconds)
|
|
||||||
mediaSession.update(
|
|
||||||
_currentMediaItem.value,
|
|
||||||
positionMs / 1000.0,
|
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
|
||||||
_playerState.value == PlayerState.PLAYING)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDuration(durationMs: Long) {
|
|
||||||
if (disposed || durationMs <= 0) return
|
|
||||||
_duration.tryEmit(durationMs.milliseconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(message: String) {
|
|
||||||
if (disposed) return
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
|
||||||
_error.tryEmit(IllegalStateException("VLC playback error: $message"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
engine.setListener(engineListener)
|
avPlayer.addPeriodicTimeObserverForInterval(
|
||||||
mediaSession.configure(
|
interval = CMTimeMake(1, 4),
|
||||||
onPlay = { scope.launch { play() } },
|
queue = null
|
||||||
onPause = { scope.launch { pause() } },
|
) { time ->
|
||||||
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
|
if (!disposed) {
|
||||||
onNext = { scope.launch { skipToNext() } },
|
val seconds = CMTimeGetSeconds(time)
|
||||||
onPrevious = { scope.launch { skipToPrevious() } })
|
_position.tryEmit(seconds.seconds)
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
|
avPlayer.currentItem?.let { item ->
|
||||||
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
|
val durationSeconds = CMTimeGetSeconds(item.duration)
|
||||||
val item = currentPlaylist.getOrNull(currentIndex) ?: return
|
if (durationSeconds > 0) {
|
||||||
val startMs = pendingStartMs
|
_duration.tryEmit(durationSeconds.seconds)
|
||||||
pendingStartMs = 0L
|
}
|
||||||
engineHasMedia = true
|
val loadedRanges = item.loadedTimeRanges
|
||||||
_position.tryEmit(startMs.milliseconds)
|
// if (loadedRanges.count() > 0u) {
|
||||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
// val range = loadedRanges.objectAtIndex(0u)
|
||||||
_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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
while (!disposed) {
|
||||||
|
syncTimeControlStatus()
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncTimeControlStatus() {
|
||||||
|
val status = avPlayer.timeControlStatus
|
||||||
|
if (status == lastTimeControlStatus) return
|
||||||
|
lastTimeControlStatus = status
|
||||||
|
|
||||||
|
val playerState = when (status) {
|
||||||
|
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
||||||
|
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
||||||
|
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
||||||
|
else -> PlayerState.IDLE
|
||||||
|
}
|
||||||
|
_playerState.tryEmit(playerState)
|
||||||
|
|
||||||
|
if (avPlayer.currentItem == null) {
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveState(): PlayerState {
|
||||||
|
return when (avPlayer.timeControlStatus) {
|
||||||
|
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
||||||
|
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
||||||
|
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
||||||
|
else -> PlayerState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
|
||||||
|
val nsUrl = NSURL.URLWithString(url) ?: return null
|
||||||
|
return AVPlayerItem(nsUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun play() {
|
actual override suspend fun play() {
|
||||||
lock.withLock {
|
avPlayer.play()
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
|
||||||
mediaSession.activate()
|
|
||||||
if (currentIndex !in currentPlaylist.indices) {
|
|
||||||
currentIndex = 0
|
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[0])
|
|
||||||
}
|
|
||||||
if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun pause() {
|
actual override suspend fun pause() {
|
||||||
lock.withLock {
|
avPlayer.pause()
|
||||||
if (disposed) return
|
|
||||||
engine.pause()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun stop() {
|
actual override suspend fun stop() {
|
||||||
lock.withLock {
|
avPlayer.pause()
|
||||||
if (disposed) return
|
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||||
engine.stop()
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
mediaSession.deactivate()
|
|
||||||
mediaSession.update(
|
|
||||||
_currentMediaItem.value,
|
|
||||||
0.0,
|
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
|
||||||
false)
|
|
||||||
engineHasMedia = false
|
|
||||||
pendingStartMs = 0L
|
|
||||||
_position.tryEmit(Duration.ZERO)
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
lock.withLock {
|
val seconds = position.inWholeMilliseconds / 1000.0
|
||||||
if (disposed) return
|
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
|
||||||
val maxMs = _duration.value.inWholeMilliseconds
|
avPlayer.seekToTime(cmTime)
|
||||||
val targetMs = if (maxMs > 0) {
|
_position.tryEmit(position)
|
||||||
position.inWholeMilliseconds.coerceIn(0, maxMs)
|
|
||||||
} else {
|
|
||||||
position.inWholeMilliseconds.coerceAtLeast(0)
|
|
||||||
}
|
|
||||||
if (engineHasMedia) engine.seekTo(targetMs) else pendingStartMs = targetMs
|
|
||||||
_position.tryEmit(targetMs.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun loop(state: LoopState) {
|
actual override suspend fun loop(state: LoopState) {
|
||||||
lock.withLock {
|
_loopState.tryEmit(state)
|
||||||
if (disposed) return
|
|
||||||
_loopState.tryEmit(state)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun shuffle(enabled: Boolean) {
|
actual override suspend fun shuffle(enabled: Boolean) {
|
||||||
lock.withLock {
|
_shuffleMode.tryEmit(enabled)
|
||||||
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(
|
actual override suspend fun load(
|
||||||
@ -271,186 +192,143 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int
|
startPosition: Int
|
||||||
) {
|
) {
|
||||||
lock.withLock {
|
currentPlaylist.clear()
|
||||||
if (disposed) return
|
currentPlaylist.addAll(playlist)
|
||||||
|
urlIndexMap.clear()
|
||||||
|
playlist.forEachIndexed { index, item ->
|
||||||
|
urlIndexMap[item.url] = index
|
||||||
|
}
|
||||||
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
|
|
||||||
originalPlaylist.clear()
|
if (playlist.isEmpty()) {
|
||||||
originalPlaylist.addAll(playlist)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
currentPlaylist.clear()
|
_currentMediaItem.tryEmit(null)
|
||||||
currentPlaylist.addAll(playlist)
|
|
||||||
shuffleEnabled = false
|
|
||||||
_shuffleMode.tryEmit(false)
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
|
||||||
|
|
||||||
engine.stop()
|
|
||||||
engineHasMedia = false
|
|
||||||
pendingStartMs = 0L
|
|
||||||
_position.tryEmit(Duration.ZERO)
|
_position.tryEmit(Duration.ZERO)
|
||||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
_duration.tryEmit(Duration.ZERO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (currentPlaylist.isEmpty()) {
|
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
|
||||||
currentIndex = -1
|
val item = playlist[safeIndex]
|
||||||
_currentMediaItem.tryEmit(null)
|
val avItem = buildAVPlayerItem(item.url)
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
|
||||||
_duration.tryEmit(Duration.ZERO)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
if (avItem == null) {
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
||||||
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
mediaSession.update(
|
return
|
||||||
_currentMediaItem.value,
|
}
|
||||||
0.0,
|
|
||||||
_duration.value.inWholeMilliseconds / 1000.0,
|
|
||||||
false)
|
|
||||||
|
|
||||||
if (autoPlay) {
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
_currentMediaItem.tryEmit(item)
|
||||||
} else {
|
|
||||||
_playerState.tryEmit(PlayerState.PAUSED)
|
if (autoPlay) {
|
||||||
}
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
lock.withLock {
|
currentPlaylist.add(mediaItem)
|
||||||
if (disposed) return
|
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||||
originalPlaylist.add(mediaItem)
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
currentPlaylist.add(mediaItem)
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
|
||||||
if (currentIndex == -1) {
|
|
||||||
currentIndex = 0
|
|
||||||
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
||||||
lock.withLock {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
if (disposed) return
|
it.url == _currentMediaItem.value?.url
|
||||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
|
|
||||||
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem)
|
|
||||||
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) {
|
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
|
||||||
lock.withLock {
|
val index = urlIndexMap[mediaItem.url] ?: return
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
currentPlaylist.removeAt(index)
|
||||||
|
urlIndexMap.clear()
|
||||||
originalPlaylist.removeAll { it.url == mediaItem.url }
|
currentPlaylist.forEachIndexed { i, item ->
|
||||||
val currentUrl = _currentMediaItem.value?.url
|
urlIndexMap[item.url] = i
|
||||||
val removedCurrent = currentUrl == mediaItem.url
|
|
||||||
val removalIndices = currentPlaylist.withIndex()
|
|
||||||
.filter { it.value.url == mediaItem.url }
|
|
||||||
.map { it.index }
|
|
||||||
if (removalIndices.isEmpty()) return
|
|
||||||
|
|
||||||
// Items before the playing one shift it down, so keep the index on the same item.
|
|
||||||
val removedBefore = removalIndices.count { it < currentIndex }
|
|
||||||
removalIndices.asReversed().forEach { currentPlaylist.removeAt(it) }
|
|
||||||
|
|
||||||
if (currentPlaylist.isEmpty()) {
|
|
||||||
currentIndex = -1
|
|
||||||
_currentMediaItem.tryEmit(null)
|
|
||||||
engine.stop()
|
|
||||||
engineHasMedia = false
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
|
||||||
} else if (removedCurrent) {
|
|
||||||
currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
|
||||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
|
||||||
engine.stop()
|
|
||||||
engineHasMedia = false
|
|
||||||
} else {
|
|
||||||
currentIndex = (currentIndex - removedBefore).coerceIn(0, currentPlaylist.lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
|
||||||
}
|
}
|
||||||
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
||||||
lock.withLock {
|
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||||
if (disposed) return
|
val item = currentPlaylist.removeAt(fromIndex)
|
||||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
currentPlaylist.add(toIndex, item)
|
||||||
|
urlIndexMap.clear()
|
||||||
val item = currentPlaylist.removeAt(fromIndex)
|
currentPlaylist.forEachIndexed { i, it ->
|
||||||
currentPlaylist.add(toIndex, item)
|
urlIndexMap[it.url] = i
|
||||||
|
|
||||||
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
|
|
||||||
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentUrl = _currentMediaItem.value?.url
|
|
||||||
currentIndex = currentUrl?.let { url ->
|
|
||||||
currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 }
|
|
||||||
} ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex)
|
|
||||||
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
|
||||||
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
|
|
||||||
}
|
}
|
||||||
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun skipToNext() {
|
actual override suspend fun skipToNext() {
|
||||||
lock.withLock {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
it.url == _currentMediaItem.value?.url
|
||||||
playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex))
|
}
|
||||||
|
val nextIndex = currentIndex + 1
|
||||||
|
if (nextIndex < currentPlaylist.size) {
|
||||||
|
val nextItem = currentPlaylist[nextIndex]
|
||||||
|
val avItem = buildAVPlayerItem(nextItem.url)
|
||||||
|
if (avItem != null) {
|
||||||
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
|
_currentMediaItem.tryEmit(nextItem)
|
||||||
|
avPlayer.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun skipToPrevious() {
|
actual override suspend fun skipToPrevious() {
|
||||||
lock.withLock {
|
val currentIndex = currentPlaylist.indexOfFirst {
|
||||||
if (disposed || currentPlaylist.isEmpty()) return
|
it.url == _currentMediaItem.value?.url
|
||||||
playIndexLocked((currentIndex - 1).coerceAtLeast(0))
|
}
|
||||||
|
val prevIndex = currentIndex - 1
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
val prevItem = currentPlaylist[prevIndex]
|
||||||
|
val avItem = buildAVPlayerItem(prevItem.url)
|
||||||
|
if (avItem != null) {
|
||||||
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
|
_currentMediaItem.tryEmit(prevItem)
|
||||||
|
avPlayer.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun jumpTo(index: Int) {
|
actual override suspend fun jumpTo(index: Int) {
|
||||||
lock.withLock {
|
if (index in currentPlaylist.indices) {
|
||||||
if (disposed || index !in currentPlaylist.indices) return
|
val item = currentPlaylist[index]
|
||||||
playIndexLocked(index)
|
val avItem = buildAVPlayerItem(item.url)
|
||||||
|
if (avItem != null) {
|
||||||
|
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||||
|
_currentMediaItem.tryEmit(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun setVolume(volume: Float) {
|
actual override suspend fun setVolume(volume: Float) {
|
||||||
lock.withLock {
|
val clamped = volume.coerceIn(0f, 1f)
|
||||||
if (disposed) return
|
avPlayer.volume = clamped
|
||||||
val clamped = volume.coerceIn(0f, 1f)
|
_volume.tryEmit(clamped)
|
||||||
engine.setVolume(clamped)
|
|
||||||
_volume.tryEmit(clamped)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||||
lock.withLock {
|
val clamped = speed.coerceIn(0.25f, 4f)
|
||||||
if (disposed) return
|
avPlayer.rate = clamped
|
||||||
val clamped = speed.coerceIn(0.25f, 4f)
|
_playbackSpeed.tryEmit(clamped)
|
||||||
engine.setRate(clamped)
|
|
||||||
_playbackSpeed.tryEmit(clamped)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override fun isDisposed(): Boolean = disposed
|
actual override fun isDisposed(): Boolean = disposed
|
||||||
|
|
||||||
actual override fun dispose() {
|
actual override fun dispose() {
|
||||||
if (disposed) return
|
|
||||||
disposed = true
|
disposed = true
|
||||||
engine.setListener(null)
|
avPlayer.pause()
|
||||||
engine.destroy()
|
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
||||||
mediaSession.deactivate()
|
|
||||||
mediaSession.clear()
|
|
||||||
scope.cancel()
|
|
||||||
|
|
||||||
originalPlaylist.clear()
|
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
currentIndex = -1
|
urlIndexMap.clear()
|
||||||
engineHasMedia = false
|
|
||||||
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
_currentMediaItem.tryEmit(null)
|
_currentMediaItem.tryEmit(null)
|
||||||
@ -460,3 +338,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CMTimeMake(value: Long, timescale: Int): CValue<CMTime> {
|
||||||
|
return platform.CoreMedia.CMTimeMake(value, timescale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue<CMTime> {
|
||||||
|
return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,187 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalForeignApi::class)
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.audioplayer
|
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import platform.AVFAudio.AVAudioSession
|
|
||||||
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
|
||||||
import platform.AVFAudio.AVAudioSessionModeDefault
|
|
||||||
import platform.AVFAudio.setActive
|
|
||||||
import platform.Foundation.NSNotificationCenter
|
|
||||||
import platform.Foundation.NSNumber
|
|
||||||
import platform.Foundation.NSOperationQueue
|
|
||||||
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
|
|
||||||
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
|
|
||||||
import platform.MediaPlayer.MPMediaItemPropertyArtist
|
|
||||||
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
|
|
||||||
import platform.MediaPlayer.MPMediaItemPropertyTitle
|
|
||||||
import platform.MediaPlayer.MPNowPlayingInfoCenter
|
|
||||||
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
|
|
||||||
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
|
|
||||||
import platform.MediaPlayer.MPRemoteCommand
|
|
||||||
import platform.MediaPlayer.MPRemoteCommandCenter
|
|
||||||
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
|
|
||||||
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
|
|
||||||
|
|
||||||
internal class IosMediaSession {
|
|
||||||
private val audioSession = AVAudioSession.sharedInstance()
|
|
||||||
private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter()
|
|
||||||
private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter()
|
|
||||||
|
|
||||||
private var configured = false
|
|
||||||
private val notificationCenter = NSNotificationCenter.defaultCenter()
|
|
||||||
private val notificationTokens = mutableListOf<Any>()
|
|
||||||
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
|
|
||||||
private var onPlay: (() -> Unit)? = null
|
|
||||||
private var onPause: (() -> Unit)? = null
|
|
||||||
private var onSeek: ((Double) -> Unit)? = null
|
|
||||||
private var onNext: (() -> Unit)? = null
|
|
||||||
private var onPrevious: (() -> Unit)? = null
|
|
||||||
|
|
||||||
fun configure(
|
|
||||||
onPlay: () -> Unit,
|
|
||||||
onPause: () -> Unit,
|
|
||||||
onSeek: (Double) -> Unit,
|
|
||||||
onNext: () -> Unit,
|
|
||||||
onPrevious: () -> Unit
|
|
||||||
) {
|
|
||||||
this.onPlay = onPlay
|
|
||||||
this.onPause = onPause
|
|
||||||
this.onSeek = onSeek
|
|
||||||
this.onNext = onNext
|
|
||||||
this.onPrevious = onPrevious
|
|
||||||
|
|
||||||
if (configured) return
|
|
||||||
configured = true
|
|
||||||
|
|
||||||
audioSession.setCategory(
|
|
||||||
category = AVAudioSessionCategoryPlayback,
|
|
||||||
mode = AVAudioSessionModeDefault,
|
|
||||||
options = 0u,
|
|
||||||
error = null)
|
|
||||||
registerAudioSessionNotifications()
|
|
||||||
registerRemoteCommands()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun activate() {
|
|
||||||
audioSession.setActive(true, error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deactivate() {
|
|
||||||
audioSession.setActive(false, error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(
|
|
||||||
item: MediaItem?,
|
|
||||||
positionSeconds: Double,
|
|
||||||
durationSeconds: Double,
|
|
||||||
isPlaying: Boolean
|
|
||||||
) {
|
|
||||||
if (item == null) {
|
|
||||||
nowPlayingInfoCenter.nowPlayingInfo = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val info = buildMap<Any?, Any?> {
|
|
||||||
put(MPMediaItemPropertyTitle, item.title)
|
|
||||||
put(MPMediaItemPropertyArtist, item.artist)
|
|
||||||
item.album.takeIf { it.isNotBlank() }?.let {
|
|
||||||
put(MPMediaItemPropertyAlbumTitle, it)
|
|
||||||
}
|
|
||||||
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
|
||||||
put(MPMediaItemPropertyPlaybackDuration, durationSeconds)
|
|
||||||
}
|
|
||||||
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
|
||||||
put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds)
|
|
||||||
}
|
|
||||||
put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0)
|
|
||||||
}
|
|
||||||
nowPlayingInfoCenter.nowPlayingInfo = info
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
nowPlayingInfoCenter.nowPlayingInfo = null
|
|
||||||
notificationTokens.forEach { notificationCenter.removeObserver(it) }
|
|
||||||
notificationTokens.clear()
|
|
||||||
remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) }
|
|
||||||
remoteCommandTokens.clear()
|
|
||||||
configured = false
|
|
||||||
onPlay = null
|
|
||||||
onPause = null
|
|
||||||
onSeek = null
|
|
||||||
onNext = null
|
|
||||||
onPrevious = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerAudioSessionNotifications() {
|
|
||||||
notificationCenter.addObserverForName(
|
|
||||||
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
|
|
||||||
`object` = audioSession,
|
|
||||||
queue = NSOperationQueue.mainQueue()) { notification ->
|
|
||||||
val type = notification?.userInfo?.get(
|
|
||||||
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber
|
|
||||||
if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
|
||||||
onPause?.invoke()
|
|
||||||
} else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
|
||||||
activate()
|
|
||||||
val options = notification?.userInfo?.get(
|
|
||||||
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber
|
|
||||||
val shouldResume = options?.unsignedLongValue
|
|
||||||
?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume)
|
|
||||||
?.let { it != 0UL } == true
|
|
||||||
if (shouldResume) onPlay?.invoke()
|
|
||||||
}
|
|
||||||
}?.let { notificationTokens += it }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerRemoteCommands() {
|
|
||||||
fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) {
|
|
||||||
command.addTargetWithHandler(handler)?.let {
|
|
||||||
remoteCommandTokens += command to it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
register(remoteCommandCenter.playCommand) {
|
|
||||||
onPlay?.invoke()
|
|
||||||
MPRemoteCommandHandlerStatusSuccess
|
|
||||||
}
|
|
||||||
register(remoteCommandCenter.pauseCommand) {
|
|
||||||
onPause?.invoke()
|
|
||||||
MPRemoteCommandHandlerStatusSuccess
|
|
||||||
}
|
|
||||||
register(remoteCommandCenter.changePlaybackPositionCommand) { event ->
|
|
||||||
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
|
|
||||||
if (position != null && position.isFinite() && position >= 0) {
|
|
||||||
onSeek?.invoke(position)
|
|
||||||
MPRemoteCommandHandlerStatusSuccess
|
|
||||||
} else {
|
|
||||||
MPRemoteCommandHandlerStatusCommandFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
register(remoteCommandCenter.nextTrackCommand) {
|
|
||||||
onNext?.invoke()
|
|
||||||
MPRemoteCommandHandlerStatusSuccess
|
|
||||||
}
|
|
||||||
register(remoteCommandCenter.previousTrackCommand) {
|
|
||||||
onPrevious?.invoke()
|
|
||||||
MPRemoteCommandHandlerStatusSuccess
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.audioplayer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-media playback engine implemented in Swift on top of MobileVLCKit
|
|
||||||
* (`iosApp/iosApp/VlcPlayerEngine.swift`).
|
|
||||||
*
|
|
||||||
* MobileVLCKit comes from CocoaPods into the Xcode project, so Gradle can't bind it with cinterop.
|
|
||||||
* Swift implements this contract instead and registers it in [NativePlayerRegistry] before the
|
|
||||||
* Compose view controller is created. The playlist, loop and shuffle logic stays in Kotlin
|
|
||||||
* ([AudioPlayer]); the engine only ever plays one URL at a time.
|
|
||||||
*
|
|
||||||
* Implementations may be called from any thread and must hop to the thread VLC needs
|
|
||||||
* themselves. [NativePlayerListener] callbacks may arrive on any thread.
|
|
||||||
*/
|
|
||||||
interface NativePlayerEngine {
|
|
||||||
fun setListener(listener: NativePlayerListener?)
|
|
||||||
|
|
||||||
/** Replaces the current media. Starts playing right away when [autoPlay] is set. */
|
|
||||||
fun load(url: String, autoPlay: Boolean, startPositionMs: Long)
|
|
||||||
fun play()
|
|
||||||
fun pause()
|
|
||||||
fun stop()
|
|
||||||
fun seekTo(positionMs: Long)
|
|
||||||
|
|
||||||
/** [volume] is 0..1. */
|
|
||||||
fun setVolume(volume: Float)
|
|
||||||
fun setRate(rate: Float)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Releases the native player. The engine must not be used afterwards. Not named `release`:
|
|
||||||
* that would export to Objective-C as `-release` and clash with NSObject.
|
|
||||||
*/
|
|
||||||
fun destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NativePlayerListener {
|
|
||||||
fun onState(state: NativePlayerState)
|
|
||||||
fun onTime(positionMs: Long)
|
|
||||||
fun onDuration(durationMs: Long)
|
|
||||||
fun onError(message: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class NativePlayerState {
|
|
||||||
/** Nothing loaded, or playback was stopped. */
|
|
||||||
IDLE,
|
|
||||||
OPENING,
|
|
||||||
BUFFERING,
|
|
||||||
PLAYING,
|
|
||||||
PAUSED,
|
|
||||||
|
|
||||||
/** The media played to its end. Sent once per loaded media. */
|
|
||||||
ENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NativePlayerEngineFactory {
|
|
||||||
fun create(): NativePlayerEngine
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set from Swift (`iOSApp.init`) before the first [AudioPlayer] is created. */
|
|
||||||
object NativePlayerRegistry {
|
|
||||||
var factory: NativePlayerEngineFactory? = null
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# MobileVLCKit plays audio on iOS (iosApp/iosApp/VlcPlayerEngine.swift).
|
|
||||||
# After `pod install`, open and build iosApp.xcworkspace, not iosApp.xcodeproj.
|
|
||||||
platform :ios, '18.2'
|
|
||||||
|
|
||||||
target 'iosApp' do
|
|
||||||
use_frameworks!
|
|
||||||
|
|
||||||
pod 'MobileVLCKit', '3.7.5'
|
|
||||||
end
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
PODS:
|
|
||||||
- MobileVLCKit (3.7.5)
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
|
||||||
- MobileVLCKit (= 3.7.5)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
|
||||||
trunk:
|
|
||||||
- MobileVLCKit
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
|
||||||
MobileVLCKit: 9fb4475cd820b2d271ac394d5d7ba18c525332ab
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 6dfb07dbc8a64d0c6fe7231a81687c56f08e8250
|
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
|
||||||
@ -6,15 +6,8 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -38,8 +31,6 @@
|
|||||||
};
|
};
|
||||||
E265D0F6D10F52F5C1374EEB /* Configuration */ = {
|
E265D0F6D10F52F5C1374EEB /* Configuration */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Configuration;
|
path = Configuration;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -50,50 +41,29 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
835615636B5FE86AA850B7DB /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
11DF02E4B44E5BCF61736473 /* Spotube.app */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
85BADDD666F59900213B22FE /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */,
|
|
||||||
);
|
|
||||||
name = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
92A495F3BC0A87A606BB8FD4 /* Pods */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */,
|
|
||||||
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */,
|
|
||||||
);
|
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
EFA169D322B90F32841D602F = {
|
EFA169D322B90F32841D602F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E265D0F6D10F52F5C1374EEB /* Configuration */,
|
E265D0F6D10F52F5C1374EEB /* Configuration */,
|
||||||
5BB730BF90365122B74925FF /* iosApp */,
|
5BB730BF90365122B74925FF /* iosApp */,
|
||||||
835615636B5FE86AA850B7DB /* Products */,
|
835615636B5FE86AA850B7DB /* Products */,
|
||||||
92A495F3BC0A87A606BB8FD4 /* Pods */,
|
|
||||||
85BADDD666F59900213B22FE /* Frameworks */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
835615636B5FE86AA850B7DB /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
11DF02E4B44E5BCF61736473 /* Spotube.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -101,12 +71,10 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */;
|
buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */,
|
|
||||||
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */,
|
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */,
|
||||||
314349EF3C4149F4A4A72378 /* Sources */,
|
314349EF3C4149F4A4A72378 /* Sources */,
|
||||||
EFCF243BC7D8246E008EF23D /* Frameworks */,
|
EFCF243BC7D8246E008EF23D /* Frameworks */,
|
||||||
ED9557BFD7438D37A5E9513D /* Resources */,
|
ED9557BFD7438D37A5E9513D /* Resources */,
|
||||||
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@ -116,6 +84,8 @@
|
|||||||
5BB730BF90365122B74925FF /* iosApp */,
|
5BB730BF90365122B74925FF /* iosApp */,
|
||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@ -165,23 +135,6 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
|
||||||
outputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = {
|
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@ -201,28 +154,6 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
|
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
|
||||||
};
|
};
|
||||||
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
|
||||||
"${PODS_ROOT}/Manifest.lock",
|
|
||||||
);
|
|
||||||
name = "[CP] Check Pods Manifest.lock";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
"$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@ -236,122 +167,6 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
1C42200356BD14712FAE1CFA /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
|
|
||||||
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
4FB276594B17F1A0D71D2462 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
ARCHS = arm64;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
|
||||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
AC996E9458BD3DAE9610EA49 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
ARCHS = arm64;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
|
||||||
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
AF5828948B37FEEF5EF7F345 /* Debug */ = {
|
AF5828948B37FEEF5EF7F345 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
|
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
|
||||||
@ -417,18 +232,123 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
1C42200356BD14712FAE1CFA /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
|
||||||
|
baseConfigurationReferenceRelativePath = Config.xcconfig;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
AC996E9458BD3DAE9610EA49 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ARCHS = arm64;
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
|
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
4FB276594B17F1A0D71D2462 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ARCHS = arm64;
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
|
DEVELOPMENT_TEAM = "${TEAM_ID}";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
AC996E9458BD3DAE9610EA49 /* Debug */,
|
|
||||||
4FB276594B17F1A0D71D2462 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
|
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@ -438,7 +358,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
AC996E9458BD3DAE9610EA49 /* Debug */,
|
||||||
|
4FB276594B17F1A0D71D2462 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = B056E79C138AE849968965F1 /* Project object */;
|
rootObject = B056E79C138AE849968965F1 /* Project object */;
|
||||||
}
|
}
|
||||||
10
iosApp/iosApp.xcworkspace/contents.xcworkspacedata
generated
10
iosApp/iosApp.xcworkspace/contents.xcworkspacedata
generated
@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "group:iosApp.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Pods/Pods.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@ -4,10 +4,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>audio</string>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@ -1,205 +0,0 @@
|
|||||||
// Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import ComposeApp
|
|
||||||
import Foundation
|
|
||||||
import MobileVLCKit
|
|
||||||
|
|
||||||
/// Registers the MobileVLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first
|
|
||||||
/// `ComposeView` is created.
|
|
||||||
func registerNativePlayerEngine() {
|
|
||||||
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory()
|
|
||||||
}
|
|
||||||
|
|
||||||
final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory {
|
|
||||||
func create() -> NativePlayerEngine {
|
|
||||||
VlcPlayerEngine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plays one URL at a time with MobileVLCKit. The Kotlin `AudioPlayer` owns the playlist; see
|
|
||||||
/// `NativePlayerEngine.kt` for the contract.
|
|
||||||
///
|
|
||||||
/// Every VLC call is made on the main queue, in the order the Kotlin side issued it.
|
|
||||||
final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
|
|
||||||
private let player = VLCMediaPlayer()
|
|
||||||
private var listener: NativePlayerListener?
|
|
||||||
private var released = false
|
|
||||||
|
|
||||||
private var rate: Float = 1
|
|
||||||
private var volumePercent: Int32 = 100
|
|
||||||
private var lastReportedDurationMs: Int64 = 0
|
|
||||||
private var lastReportedState: NativePlayerState?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
player.delegate = self
|
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: NativePlayerEngine
|
|
||||||
|
|
||||||
func setListener(listener: NativePlayerListener?) {
|
|
||||||
onMain { self.listener = listener }
|
|
||||||
}
|
|
||||||
|
|
||||||
func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
guard let mediaUrl = URL(string: url) else {
|
|
||||||
self.listener?.onError(message: "Invalid URL: \(url)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let media = VLCMedia(url: mediaUrl)
|
|
||||||
if startPositionMs > 0 {
|
|
||||||
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
|
|
||||||
}
|
|
||||||
self.lastReportedDurationMs = 0
|
|
||||||
self.player.media = media
|
|
||||||
self.report(.opening)
|
|
||||||
if autoPlay { self.startPlayback() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
self.startPlayback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
onMain {
|
|
||||||
guard !self.released, self.player.isPlaying else { return }
|
|
||||||
self.player.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
self.player.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seekTo(positionMs: Int64) {
|
|
||||||
onMain {
|
|
||||||
guard !self.released, self.player.isSeekable else { return }
|
|
||||||
self.player.time = VLCTime(number: NSNumber(value: positionMs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setVolume(volume: Float) {
|
|
||||||
onMain {
|
|
||||||
self.volumePercent = Int32((min(max(volume, 0), 1) * 100).rounded())
|
|
||||||
self.player.audio?.volume = self.volumePercent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRate(rate: Float) {
|
|
||||||
onMain {
|
|
||||||
self.rate = rate
|
|
||||||
self.player.rate = rate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func destroy() {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
self.released = true
|
|
||||||
self.listener = nil
|
|
||||||
self.player.delegate = nil
|
|
||||||
self.player.stop()
|
|
||||||
self.player.media = nil
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: VLCMediaPlayerDelegate
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
switch self.player.state {
|
|
||||||
case .opening:
|
|
||||||
self.report(.opening)
|
|
||||||
case .buffering, .esAdded:
|
|
||||||
// MobileVLCKit keeps sending these while it plays; only a stall counts as buffering.
|
|
||||||
self.report(self.player.isPlaying ? .playing : .buffering)
|
|
||||||
case .playing:
|
|
||||||
self.report(.playing)
|
|
||||||
case .paused:
|
|
||||||
self.report(.paused)
|
|
||||||
case .ended:
|
|
||||||
self.report(.ended)
|
|
||||||
case .stopped:
|
|
||||||
self.report(.idle)
|
|
||||||
case .error:
|
|
||||||
self.listener?.onError(message: "VLC could not play this media")
|
|
||||||
self.report(.idle)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
onMain {
|
|
||||||
guard !self.released else { return }
|
|
||||||
// Time only advances while playing, whatever the last state notification said.
|
|
||||||
if self.player.isPlaying { self.report(.playing) }
|
|
||||||
self.reportDuration()
|
|
||||||
if let ms = self.player.time.value?.int64Value, ms >= 0 {
|
|
||||||
self.listener?.onTime(positionMs: ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Internals
|
|
||||||
|
|
||||||
private func startPlayback() {
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends [state] to Kotlin unless it was the last one sent.
|
|
||||||
private func report(_ state: NativePlayerState) {
|
|
||||||
guard state != lastReportedState else { return }
|
|
||||||
lastReportedState = state
|
|
||||||
if state == .playing {
|
|
||||||
applyPlaybackSettings()
|
|
||||||
reportDuration()
|
|
||||||
}
|
|
||||||
listener?.onState(state: state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyPlaybackSettings() {
|
|
||||||
player.audio?.volume = volumePercent
|
|
||||||
if player.rate != rate { player.rate = rate }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MobileVLCKit has no length callback, so the length is read from the media as it plays.
|
|
||||||
private func reportDuration() {
|
|
||||||
guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return }
|
|
||||||
lastReportedDurationMs = ms
|
|
||||||
listener?.onDuration(durationMs: ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func onMain(_ work: @escaping () -> Void) {
|
|
||||||
DispatchQueue.main.async(execute: work)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,10 +18,6 @@ import ComposeApp
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
init() {
|
|
||||||
registerNativePlayerEngine()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user