Skip to content

Commit e8ef165

Browse files
committed
Add loop support to AudioPlayer across all backends; repoint rodio to nucleus.rodio
- Add a `loop` parameter to `play(url, loop)` (expect/actual) and wire it in every backend: Media3 REPEAT_MODE_ONE (android), sink loop via rodio build_looped (jvm, with jar:/resource extraction to temp file), HTML5 audio.loop (web), AVPlayer end-replay (ios). - Switch the JVM backend to the rewritten rodio: coordinate dev.nucleusframework:nucleus.rodio:1.0.0 and package dev.nucleusframework.rodio.* (was io.github.kdroidfilter:rodio 0.1.0). - Add mavenLocal() to dependencyResolutionManagement. - Fix iOS podspec resource path separator (backslash -> forward slash).
1 parent 1db4fb8 commit e8ef165

8 files changed

Lines changed: 55 additions & 14 deletions

File tree

audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.android.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ actual class AudioPlayer actual constructor() {
2525
setup()
2626
}
2727

28-
actual fun play(url: String) {
28+
actual fun play(url: String, loop: Boolean) {
2929
if (mediaPlayer.isPlaying) {
3030
stop()
3131
}
3232

33+
if (mediaPlayer.isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE)) {
34+
mediaPlayer.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
35+
}
36+
3337
if (mediaPlayer.isCommandAvailable(Player.COMMAND_PREPARE)) mediaPlayer.prepare()
3438

3539
val mediaItem = MediaItem.fromUri(url)

audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ expect class AudioPlayer() {
4040
.build().toString()
4141
* ```
4242
*
43+
* @param loop When `true`, playback restarts from the beginning once the media ends.
4344
*/
44-
fun play(url: String)
45+
fun play(url: String, loop: Boolean = false)
4546

4647

4748
/**

audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.ios.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ actual class AudioPlayer actual constructor() {
2525
private var errorListener: ErrorListener? = null
2626
private var lastVolume: Float? = null
2727
private var lastRate: Float? = null
28+
private var loopEnabled: Boolean = false
2829

29-
actual fun play(url: String) {
30+
actual fun play(url: String, loop: Boolean) {
31+
loopEnabled = loop
3032
release()
3133
AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, null)
3234
val nsUrl = NSURL(string = url)
@@ -159,7 +161,13 @@ actual class AudioPlayer actual constructor() {
159161
}
160162
},
161163
onAVPlayerEnded = {
162-
_state = AudioPlayerState.IDLE
164+
if (loopEnabled) {
165+
// AVPlayer has no built-in looping: restart from the beginning manually.
166+
seekTo(0)
167+
player?.play()
168+
} else {
169+
_state = AudioPlayerState.IDLE
170+
}
163171
},
164172
onAVPlayerStalled = {
165173
_state = AudioPlayerState.BUFFERING

audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package io.github.kdroidfilter.composemediaplayer.audio
22

3-
import io.github.kdroidfilter.rodio.PlaybackCallback
4-
import io.github.kdroidfilter.rodio.PlaybackEvent
5-
import io.github.kdroidfilter.rodio.RodioPlayer
3+
import dev.nucleusframework.rodio.PlaybackCallback
4+
import dev.nucleusframework.rodio.PlaybackEvent
5+
import dev.nucleusframework.rodio.RodioPlayer
66
import kotlinx.coroutines.CoroutineScope
77
import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.Job
99
import kotlinx.coroutines.SupervisorJob
1010
import kotlinx.coroutines.launch
1111
import java.net.URI
12+
import java.nio.file.Files
13+
import java.nio.file.Path
1214
import java.nio.file.Paths
15+
import java.nio.file.StandardCopyOption
16+
import java.util.concurrent.ConcurrentHashMap
1317

1418
@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"])
1519
actual class AudioPlayer actual constructor() {
@@ -45,15 +49,15 @@ actual class AudioPlayer actual constructor() {
4549
player?.setCallback(callback)
4650
}
4751

48-
actual fun play(url: String) {
52+
actual fun play(url: String, loop: Boolean) {
4953
val localPlayer = ensurePlayer()
5054
playbackJob?.cancel()
5155
playbackJob = scope.launch {
5256
runCatching {
5357
if (isRemoteUrl(url)) {
54-
localPlayer.playUrl(url, loop = false)
58+
localPlayer.playUrl(url, loop = loop)
5559
} else {
56-
localPlayer.playFile(resolveFilePath(url), loop = false)
60+
localPlayer.playFile(resolveFilePath(url), loop = loop)
5761
}
5862
lastVolume?.let { localPlayer.setVolume(it) }
5963
}.onFailure { error ->
@@ -162,6 +166,7 @@ actual class AudioPlayer actual constructor() {
162166
}
163167

164168
private fun resolveFilePath(url: String): String {
169+
if (url.startsWith("jar:", ignoreCase = true)) return extractToTempFile(url)
165170
if (!url.startsWith("file:", ignoreCase = true)) return url
166171
val uri = runCatching { URI(url) }.getOrNull()
167172
if (uri != null) {
@@ -172,4 +177,25 @@ actual class AudioPlayer actual constructor() {
172177
}
173178
return url.substring(5)
174179
}
180+
181+
/**
182+
* Resources bundled inside a jar (e.g. `Res.getUri(...)` in a packaged desktop app) cannot be
183+
* read by the native backend directly, so they are extracted once to the temp directory.
184+
*/
185+
private fun extractToTempFile(url: String): String {
186+
val cached = extractedResources[url]
187+
if (cached != null && Files.exists(cached)) return cached.toString()
188+
val fileName = url.substringAfterLast('/').ifBlank { "audio" }
189+
val target = Files.createTempFile("composemediaplayer-audio-", "-$fileName")
190+
URI(url).toURL().openStream().use { input ->
191+
Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING)
192+
}
193+
target.toFile().deleteOnExit()
194+
extractedResources[url] = target
195+
return target.toString()
196+
}
197+
198+
private companion object {
199+
val extractedResources = ConcurrentHashMap<String, Path>()
200+
}
175201
}

audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.web.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@ actual class AudioPlayer actual constructor() {
5757
}
5858

5959
@OptIn(ExperimentalWasmJsInterop::class)
60-
actual fun play(url: String) {
60+
actual fun play(url: String, loop: Boolean) {
6161
release()
6262
document.body?.appendElement("audio") {
6363
this as HTMLAudioElement
6464
this.id = htmlId
6565
this.src = url
66+
this.loop = loop
6667
}
6768

6869
val playerEl = getPlayerElement()

gradle/libs.versions.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ ktlint = "14.2.0"
1616
androidx-lifecycle-runtime-ktx = "2.10.0"
1717
jna = "5.18.1"
1818
platformtoolsDarkmodedetector = "0.7.4"
19-
rodio = "0.1.0"
19+
rodio = "1.0.0"
2020
slf4jSimple = "2.0.17"
2121

2222
# minSdk = 21 failed to compile because the project indirectly depends on the library [androidx.navigationevent:navigationevent-android:1.0.1] which requires minSdk = 23
@@ -54,7 +54,7 @@ jna-jpms = { module = "net.java.dev.jna:jna-jpms", version.ref = "jna" }
5454
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
5555
jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna"}
5656
platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtoolsDarkmodedetector" }
57-
rodio = { module = "io.github.kdroidfilter:rodio", version.ref = "rodio" }
57+
rodio = { module = "dev.nucleusframework:nucleus.rodio", version.ref = "rodio" }
5858
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" }
5959

6060
[plugins]

mediaplayer/ComposeMediaPlayer.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ Pod::Spec.new do |spec|
4141
SCRIPT
4242
}
4343
]
44-
spec.resources = ['build\compose\cocoapods\compose-resources']
44+
spec.resources = ['build/compose/cocoapods/compose-resources']
4545
end

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pluginManagement {
1717

1818
dependencyResolutionManagement {
1919
repositories {
20+
mavenLocal()
2021
google {
2122
content {
2223
includeGroupByRegex("com\\.android.*")

0 commit comments

Comments
 (0)