From 9eaa474d1113b32aa02f5781ff368a7af9fc1cd2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 7 Jan 2026 13:38:54 +0200 Subject: [PATCH 1/4] Add multiplatform audio player implementation for Compose applications - Introduced `ComposeAudioPlayer` with platform-specific implementations for Android, iOS, Web, and JVM. - Added `ComposeAudioPlayerState` enum to manage player state transitions. - Provided coroutine-driven updates for playback state, volume, position, and duration via `rememberGadulkaLiveState`. - Configured build scripts with Gradle dependencies for multiplatform support, including Android and WASM targets. - Implemented platform-specific playback features using ExoPlayer (Android), AVFoundation (iOS), and HTMLAudioElement (Web). - Included support for compiling and publishing through Maven. --- audioplayer/build.gradle.kts | 129 +++++++++ .../audio/ComposeAudioPlayer.android.kt | 144 ++++++++++ .../audio/ComposeAudioPlayer.kt | 215 +++++++++++++++ .../audio/ComposeAudioPlayerState.kt | 13 + .../composemediaplayer/audio/ErrorListener.kt | 5 + .../audio/ComposeAudioPlayer.ios.kt | 245 ++++++++++++++++++ .../audio/ComposeAudioPlayer.kt | 47 ++++ .../audio/ComposeAudioPlayer.web.kt | 159 ++++++++++++ gradle/libs.versions.toml | 2 +- settings.gradle.kts | 2 +- 10 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 audioplayer/build.gradle.kts create mode 100644 audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt create mode 100644 audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt create mode 100644 audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt create mode 100644 audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ErrorListener.kt create mode 100644 audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt create mode 100644 audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt create mode 100644 audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt diff --git a/audioplayer/build.gradle.kts b/audioplayer/build.gradle.kts new file mode 100644 index 00000000..40be6bd1 --- /dev/null +++ b/audioplayer/build.gradle.kts @@ -0,0 +1,129 @@ +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) + alias(libs.plugins.vannitktech.maven.publish) +} + +group = "io.github.kdroidfilter.composemediaplayer" + +val ref = System.getenv("GITHUB_REF") ?: "" +val version = if (ref.startsWith("refs/tags/")) { + val tag = ref.removePrefix("refs/tags/") + if (tag.startsWith("v")) tag.substring(1) else tag +} else "dev" + +kotlin { + jvmToolchain(17) + androidTarget { publishLibraryVariants("release") } + jvm() + js { + browser() + binaries.executable() + } + + wasmJs { + browser() + binaries.executable() + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kermit) + } + + commonTest.dependencies { + implementation(kotlin("test")) + } + + androidMain.dependencies { + implementation(libs.androidx.core) + implementation(libs.androidcontextprovider) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + } + + androidUnitTest.dependencies { + implementation(kotlin("test")) + implementation(kotlin("test-junit")) + implementation(libs.kotlinx.coroutines.test) + } + + jvmMain.dependencies { + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.slf4j.simple) + } + + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(kotlin("test-junit")) + implementation(libs.kotlinx.coroutines.test) + } + + iosMain.dependencies { + } + + iosTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + + webMain.dependencies { + implementation(libs.kotlinx.browser) + implementation(compose.ui) + } + } +} + +android { + namespace = "io.github.kdroidfilter.composemediaplayer.audio" + compileSdk = 36 + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } +} + +mavenPublishing { + coordinates( + groupId = "io.github.kdroidfilter", + artifactId = "composemediaplayer-audio", + version = version + ) + + pom { + name.set("Compose Media Player Audio") + description.set("A multiplatform audio player library for Compose applications.") + inceptionYear.set("2025") + url.set("https://github.com/kdroidFilter/Compose-Media-Player") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("kdroidfilter") + name.set("Elyahou Hadass") + email.set("elyahou.hadass@gmail.com") + } + } + + scm { + connection.set("scm:git:git://github.com/kdroidFilter/Compose-Media-Player.git") + developerConnection.set("scm:git:ssh://git@github.com:kdroidFilter/Compose-Media-Player.git") + url.set("https://github.com/kdroidFilter/Compose-Media-Player") + } + } +} diff --git a/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt b/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt new file mode 100644 index 00000000..769bc2c7 --- /dev/null +++ b/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2025 Konstantin . + * Use of this source code is governed by the BSD 3-Clause License that can be found in LICENSE file. + */ + +package io.github.kdroidfilter.composemediaplayer.audio + +import android.content.ContentResolver +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.kdroid.androidcontextprovider.ContextProvider + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class ComposeAudioPlayer actual constructor() { + + private var mediaPlayer = ExoPlayer.Builder(ContextProvider.getContext()).build() + private var errorListener: ErrorListener? = null + + init { + setup() + } + + actual fun play(url: String) { + if (mediaPlayer.isPlaying) { + stop() + } + + if (mediaPlayer.isCommandAvailable(Player.COMMAND_PREPARE)) mediaPlayer.prepare() + + val mediaItem = MediaItem.fromUri(url) + + if (mediaPlayer.isCommandAvailable(Player.COMMAND_SET_MEDIA_ITEM)) mediaPlayer.setMediaItem(mediaItem) + + if(mediaPlayer.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)) mediaPlayer.play() + } + + @OptIn(UnstableApi::class) + actual fun play() { + if(mediaPlayer.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { + if (currentPlayerState() == ComposeAudioPlayerState.IDLE) + seekTo(0) + mediaPlayer.play() + } + } + + + /** + * Android-specific implementation of the [play] method which uses a ContentResolver to calculate the Uri of a raw file resource bundled with the app. + */ + fun play(rawResourceId: Int) { + val uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .appendPath("$rawResourceId") + .build().toString() + play(uri) + } + + actual fun currentPosition(): Long? { + if (mediaPlayer.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) { + return mediaPlayer.currentPosition + } + return null + } + + actual fun currentDuration(): Long? { + if (mediaPlayer.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)) { + if (mediaPlayer.duration >= 0) return mediaPlayer.duration + } + return null + } + + @UnstableApi + actual fun currentPlayerState(): ComposeAudioPlayerState? { + if (mediaPlayer.isReleased) { + return null + } + // https://mofazhe.github.io/ExoPlayer-ffmpeg/listening-to-player-events.html + val state = mediaPlayer.playbackState + val playWhenReady = mediaPlayer.playWhenReady + return when { + state == Player.STATE_READY && playWhenReady -> ComposeAudioPlayerState.PLAYING + state == Player.STATE_READY && !playWhenReady -> ComposeAudioPlayerState.PAUSED + state == Player.STATE_BUFFERING -> ComposeAudioPlayerState.BUFFERING + state == Player.STATE_IDLE -> ComposeAudioPlayerState.IDLE + state == Player.STATE_ENDED -> ComposeAudioPlayerState.IDLE + else -> ComposeAudioPlayerState.IDLE + } + } + + actual fun release() { + mediaPlayer.stop() + mediaPlayer.release() + } + + actual fun stop() { + mediaPlayer.stop() + } + + actual fun pause() { + mediaPlayer.pause() + } + + actual fun currentVolume(): Float? { + if(mediaPlayer.isCommandAvailable(Player.COMMAND_GET_VOLUME)) { + return mediaPlayer.volume + } + return null + } + + actual fun setVolume(volume: Float) { + if (!mediaPlayer.isCommandAvailable(Player.COMMAND_GET_VOLUME)) return + mediaPlayer.volume = volume + } + + actual fun setRate(rate: Float) { + if (!mediaPlayer.isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH)) return + mediaPlayer.playbackParameters = mediaPlayer.playbackParameters.withSpeed(rate) + } + + actual fun seekTo(time: Long) { + if (!mediaPlayer.isCommandAvailable(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)) return + mediaPlayer.seekTo(time) + } + + actual fun setOnErrorListener(listener: ErrorListener){ + errorListener = listener + } + + private fun setup() { + + mediaPlayer.addListener(object :Player.Listener{ + override fun onPlayerError(error: PlaybackException) { + errorListener?.onError(error.errorCodeName) + super.onPlayerError(error) + } + }) + + } +} diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt new file mode 100644 index 00000000..369d5a19 --- /dev/null +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2025 Konstantin . + * Use of this source code is governed by the BSD 3-Clause License that can be found in LICENSE file. + */ + +package io.github.kdroidfilter.composemediaplayer.audio + +import androidx.compose.runtime.* + +/** + * A minimalistic audio player + * + * + * Example: + * + * ```kotlin +val player = ComposeMediaPlayer() +player.play(url = "...") +player.stop() +player.release() +``` + */ +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +expect class ComposeAudioPlayer() { + /** + * Start playback of the audio resource at the provided [url]. + * + * ### Resource URI + * + * Can be a remote HTTP(s) url, or a `files` URI obtained via `Res.getUri("files/sample.mp3")`. + * + * Check the [JetBrains docs on how to store raw](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources-usage.html#raw-files) files as part of multiplatform project resources. + * + * On Android, you can resolve the resource URI using something like: + * + * ```kotlin + * val uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .appendPath("${R.raw.name_of_your_resource}") + .build().toString() + * ``` + * + */ + fun play(url: String) + + + /** + * Resumes audio playback from the current position if it was previously paused. + * + * This function has no effect if the player is already in a playing state. + */ + fun play() + + /** + * Stop playback and return the play position to the beginning of time (position 0). + * + * Note: If the player is currently not playing, this action has no effect. + */ + fun stop() + + + /** + * Pauses the audio playback without resetting the play position. To resume playback, call [play]. + * + * If the player is currently not in a playing state, this method has no effect. + */ + fun pause() + + /** + * Pause and attempts to perform cleanup in order to dispose of any player resources. + * + */ + fun release() + + + /** + * Retrieves the current playback position in milliseconds. + * + * @return The current playback position in milliseconds, or null if it cannot be determined. + */ + fun currentPosition(): Long? + + /** + * Retrieves the total duration of the playback item in milliseconds. + * + * @return The duration of the playing item in milliseconds, or null if it cannot be determined. + */ + fun currentDuration(): Long? + + /** + * Retrieves the current state of the player + * + * @return The current state like [PLAYING], [BUFFERING], [IDLE], [PAUASED]. + */ + fun currentPlayerState(): ComposeAudioPlayerState? + + /** + * Retrieves the current volume level of the player. + * + * A value of 0.0 indicates silence. A value of 1.0 indicates full audio volume for the player instance. + * @return The current volume as a floating-point value, or null if it cannot be determined. + */ + fun currentVolume(): Float? + + + /** + * Adjusts the volume level of the player. + * + * @param volume The desired volume level as a floating-point value, where 0.0 represents silence + * and 1.0 represents the maximum audio volume for the player instance. + * + * Note: this method has no effect on the system/device volume, it only targets the player instance. + */ + fun setVolume(volume: Float) + + + /** + * Adjusts the playback speed of the audio. + * + * @param rate The desired playback speed as a floating-point value, where 1.0 indicates normal speed, + * values greater than 1.0 indicate faster playback, and values less than 1.0 indicate slower playback. + * + * The value must be positive (grater than 0.0). + */ + fun setRate(rate: Float) + + + /** + * Seeks to the specified playback position in the currently playing media. + * + * @param time The desired playback position in milliseconds. Must be within the duration of the media. + */ + fun seekTo(time: Long) + + fun setOnErrorListener(listener: ErrorListener) +} + + +/** + * Checks whether the player is currently in a playing or buffering state. + * + * @return `true` if the player is in the PLAYING or BUFFERING state, otherwise `false`. + */ +fun ComposeAudioPlayer.isPlaying(): Boolean = currentPlayerState() in listOf(ComposeAudioPlayerState.PLAYING, ComposeAudioPlayerState.BUFFERING) + + +/** + * Provides a stateful instance of [ComposeAudioPlayer] that is remembered across recompositions. + * + * The state is managed such that it is automatically released when the composable leaves the composition, + * ensuring proper cleanup of resources. + * + * @return A remembered instance of [ComposeAudioPlayer], which represents a minimalistic audio player. + */ +@Composable +fun rememberAudioPlayerState(): ComposeAudioPlayer { + val player = remember { ComposeAudioPlayer() } + DisposableEffect(Unit) { + onDispose { + player.release() + } + } + return player +} + +/** + * Creates and remembers an instance of [ComposeAudioPlayerLiveState] that monitors and updates the state, + * volume, position, and duration of a [ComposeAudioPlayer]. The player state is updated periodically + * and cleans up resources when no longer needed. + * + * @return A [ComposeAudioPlayerLiveState] object containing the player instance, its current state, volume, + * playback position, and duration. + */ +@Composable +fun rememberGadulkaLiveState(): ComposeAudioPlayerLiveState { + val player = remember { ComposeAudioPlayer() } + var state by remember { mutableStateOf(ComposeAudioPlayerState.IDLE) } + var volume by remember { mutableStateOf(0f) } + var position by remember { mutableStateOf(0L) } + var duration by remember { mutableStateOf(0L) } + + LaunchedEffect(Unit) { + while (true) { + // Fetch or update the data + state = player.currentPlayerState() ?: ComposeAudioPlayerState.IDLE + volume = player.currentVolume() ?: 0f + position = player.currentPosition() ?: 0L + duration = player.currentDuration() ?: 0L + + // Delay for 300 milliseconds + kotlinx.coroutines.delay(300) + } + } + + DisposableEffect(Unit) { + onDispose { + player.release() + } + } + return ComposeAudioPlayerLiveState( + player, state, + volume = volume, + position = position, + duration = duration + ) +} + + +data class ComposeAudioPlayerLiveState( + val player: ComposeAudioPlayer, + val state: ComposeAudioPlayerState, + val volume: Float, + val position: Long, + val duration: Long +) \ No newline at end of file diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt new file mode 100644 index 00000000..fcd6eb01 --- /dev/null +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Konstantin . + * Use of this source code is governed by the BSD 3-Clause License that can be found in LICENSE file. + */ + +package io.github.kdroidfilter.composemediaplayer.audio + +enum class ComposeAudioPlayerState { + PLAYING, + PAUSED, + BUFFERING, + IDLE +} \ No newline at end of file diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ErrorListener.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ErrorListener.kt new file mode 100644 index 00000000..2efd5438 --- /dev/null +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ErrorListener.kt @@ -0,0 +1,5 @@ +package io.github.kdroidfilter.composemediaplayer.audio + +interface ErrorListener { + fun onError(message: String?) +} \ No newline at end of file diff --git a/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt b/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt new file mode 100644 index 00000000..1d687cd1 --- /dev/null +++ b/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt @@ -0,0 +1,245 @@ +/* + * Copyright 2025 Konstantin . + * Use of this source code is governed by the BSD 3-Clause License that can be found in LICENSE file. + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package io.github.kdroidfilter.composemediaplayer.audio + +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayback +import platform.AVFoundation.* +import platform.CoreMedia.* +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSURL +import platform.darwin.NSEC_PER_SEC + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +actual class ComposeAudioPlayer actual constructor() { + private var player: AVPlayer? = null + private var playerObserver: CupertinoAVPlayerObserver? = null + private var errorListener: ErrorListener? = null + private var lastVolume: Float? = null + private var lastRate: Float? = null + + actual fun play(url: String) { + release() + AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, null) + val nsUrl = NSURL(string = url) + val asset = AVURLAsset(uRL = nsUrl, options = mapOf(AVURLAssetPreferPreciseDurationAndTimingKey to true)) + val item = AVPlayerItem(asset = asset, automaticallyLoadedAssetKeys = listOf("duration", "playable")) + + if (player == null) + player = AVPlayer.playerWithPlayerItem(item) + else + player!!.replaceCurrentItemWithPlayerItem(item) + + lastVolume?.let { player?.volume = it } + + setup() + player?.play() + lastRate?.let { player?.rate = it } + } + + actual fun play() { + // https://developer.apple.com/documentation/avfoundation/avplayer/play() + if (player?.currentItem != null) { + if (currentPlayerState() == ComposeAudioPlayerState.IDLE) + seekTo(0) + player?.play() + lastVolume?.let { player?.volume = it } + lastRate?.let { player?.rate = it } + } + } + + + actual fun release() { + playerObserver?.detach() + player?.pause() + player = null + _state = null + } + + actual fun stop() { + player?.pause() + player?.replaceCurrentItemWithPlayerItem(null) + _state = ComposeAudioPlayerState.IDLE + } + + actual fun pause() { + player?.pause() + } + + actual fun currentPosition(): Long? { + try { + val currentTimeSeconds = player?.currentTime()?.useContents { + if (this.timescale == 0) return@useContents null + this.value / this.timescale + } + if (currentTimeSeconds != null && currentTimeSeconds >= 0) { + return (currentTimeSeconds * 1000) + } + } catch (e: Exception) { + e.printStackTrace() + } + + return null + } + + actual fun currentDuration(): Long? { + val item = player?.currentItem ?: return null + val itemSeconds = CMTimeGetSeconds(item.duration) + + // Try player item's duration + if (!itemSeconds.isNaN() && !itemSeconds.isInfinite() && itemSeconds >= 0) + return (itemSeconds * 1000.0).toLong() + + // Fallback to underlying asset's duration + val assetSeconds = CMTimeGetSeconds(item.asset.duration) + if (!assetSeconds.isNaN() && !assetSeconds.isInfinite() && assetSeconds >= 0) + return (assetSeconds * 1000.0).toLong() + + return null + } + + actual fun currentVolume(): Float? { + // https://developer.apple.com/documentation/avfoundation/avplayer/volume + return player?.volume + } + + actual fun setVolume(volume: Float) { + lastVolume = volume + player?.volume = volume + } + + actual fun setRate(rate: Float) { + // https://developer.apple.com/documentation/avfoundation/controlling-the-transport-behavior-of-a-player#Control-the-playback-rate + lastRate = rate + player?.rate = rate + } + + + actual fun seekTo(time: Long) { + // https://developer.apple.com/documentation/avfoundation/avplayer/seek(to:)-87h2r + val duration = CMTimeMake(time, 1000) + player?.seekToTime(duration) + } + + actual fun currentPlayerState(): ComposeAudioPlayerState? { + return _state + } + + private var _state: ComposeAudioPlayerState? = null + + + private fun setup() { + playerObserver?.detach() + + val observer = CupertinoAVPlayerObserver(player) + observer.attach( + onAVPlayerUpdated = { + val rate: Float = player?.rate ?: 0f + val hasItem = player?.currentItem != null + val avStatus = player?.currentItem?.status + val bufferingEnding = player?.currentItem?.isPlaybackLikelyToKeepUp() == true + val bufferIsEmpty = player?.currentItem?.isPlaybackBufferEmpty() == true + // https://developer.apple.com/documentation/avfoundation/avplayer/timecontrolstatus-swift.property/#Discussion + val waitingToPlayAtRate = + player?.timeControlStatus == AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate + _state = when { + rate > 0 -> ComposeAudioPlayerState.PLAYING + !hasItem -> ComposeAudioPlayerState.IDLE + avStatus == AVPlayerItemStatusFailed -> ComposeAudioPlayerState.IDLE + !bufferingEnding || bufferIsEmpty || waitingToPlayAtRate -> ComposeAudioPlayerState.BUFFERING + else -> ComposeAudioPlayerState.PAUSED + } + }, + onAVPlayerEnded = { + _state = ComposeAudioPlayerState.IDLE + }, + onAVPlayerStalled = { + _state = ComposeAudioPlayerState.BUFFERING + }, + onAVPlayerError = { + errorListener?.onError(it) + } + ) + + playerObserver = observer + } + + actual fun setOnErrorListener(listener: ErrorListener) { + errorListener = listener + } +} + +class CupertinoAVPlayerObserver(private val player: AVPlayer?) { + // based on https://developer.apple.com/documentation/avfoundation/monitoring-playback-progress-in-your-app + private var timeObserver: Any? = null + private var endObserver: Any? = null + private var stallObserver: Any? = null + private var errorObserver: Any? = null + + @OptIn(ExperimentalForeignApi::class) + fun attach( + onAVPlayerUpdated: () -> Unit, + onAVPlayerEnded: () -> Unit, + onAVPlayerStalled: () -> Unit, + onAVPlayerError: (message: String) -> Unit + ) { + detach() + if (player == null) return + + // Buffering state + onAVPlayerUpdated() + + val interval = CMTimeMakeWithSeconds(0.5, NSEC_PER_SEC.toInt()) // update every ~0.5 seconds + timeObserver = player.addPeriodicTimeObserverForInterval(interval, null) { _: CValue -> + onAVPlayerUpdated() + } + + player.currentItem?.let { item -> + endObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVPlayerItemDidPlayToEndTimeNotification, + `object` = item, + queue = null, + ) { _ -> + onAVPlayerEnded() + } + + stallObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVPlayerItemPlaybackStalledNotification, + `object` = item, + queue = null, + ) { _ -> + onAVPlayerStalled() + } + + errorObserver = NSNotificationCenter.defaultCenter.addObserverForName( + name = AVPlayerItemFailedToPlayToEndTimeNotification, + `object` = item, + queue = null, + ) { notification -> + val error = notification?.userInfo?.get(AVPlayerItemFailedToPlayToEndTimeErrorKey) as? String + onAVPlayerError(error?:"Not available") + } + + + } + } + + fun detach() { + timeObserver?.let { player?.removeTimeObserver(it) } + timeObserver = null + endObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + endObserver = null + stallObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + stallObserver = null + errorObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + errorObserver = null + } +} diff --git a/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt new file mode 100644 index 00000000..95206189 --- /dev/null +++ b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt @@ -0,0 +1,47 @@ +package io.github.kdroidfilter.composemediaplayer.audio + +@Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) +actual class ComposeAudioPlayer actual constructor() { + actual fun play(url: String) { + } + + actual fun play() { + } + + actual fun stop() { + } + + actual fun pause() { + } + + actual fun release() { + } + + actual fun currentPosition(): Long? { + TODO("Not yet implemented") + } + + actual fun currentDuration(): Long? { + TODO("Not yet implemented") + } + + actual fun currentPlayerState(): ComposeAudioPlayerState? { + TODO("Not yet implemented") + } + + actual fun currentVolume(): Float? { + TODO("Not yet implemented") + } + + actual fun setVolume(volume: Float) { + } + + actual fun setRate(rate: Float) { + } + + actual fun seekTo(time: Long) { + } + + actual fun setOnErrorListener(listener: ErrorListener) { + } +} \ No newline at end of file diff --git a/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt b/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt new file mode 100644 index 00000000..090be5d6 --- /dev/null +++ b/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2025 Konstantin . + * Use of this source code is governed by the BSD 3-Clause License that can be found in LICENSE file. + */ + +package io.github.kdroidfilter.composemediaplayer.audio + +import kotlinx.browser.document +import kotlinx.dom.appendElement +import org.w3c.dom.HTMLAudioElement +import org.w3c.dom.events.Event +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@OptIn(ExperimentalUuidApi::class) +actual class ComposeAudioPlayer actual constructor() { + + private val htmlId = Uuid.random().toString() + private var _state: ComposeAudioPlayerState? = ComposeAudioPlayerState.IDLE + private val events = mutableListOf<() -> Unit>() + private var lastVolume: Double? = null + private var lastRate: Double? = null + private var errorListener: ErrorListener? = null + + private fun attachEventListeners(el: HTMLAudioElement) { + detachEventListeners() + + val onPlaying: (Event) -> Unit = { _state = ComposeAudioPlayerState.PLAYING } + val onPlay: (Event) -> Unit = { _state = ComposeAudioPlayerState.PLAYING } + val onPause: (Event) -> Unit = { _state = ComposeAudioPlayerState.PAUSED } + val onEnded: (Event) -> Unit = { _state = ComposeAudioPlayerState.IDLE } + val onWaiting: (Event) -> Unit = { _state = ComposeAudioPlayerState.BUFFERING } + val onStalled: (Event) -> Unit = { _state = ComposeAudioPlayerState.BUFFERING } + val onError: (Event) -> Unit = { errorListener?.onError(null) } + + el.addEventListener("playing", onPlaying) + events += { el.removeEventListener("playing", onPlaying) } + el.addEventListener("play", onPlay) + events += { el.removeEventListener("play", onPlay) } + el.addEventListener("pause", onPause) + events += { el.removeEventListener("pause", onPause) } + el.addEventListener("ended", onEnded) + events += { el.removeEventListener("ended", onEnded) } + el.addEventListener("waiting", onWaiting) + events += { el.removeEventListener("waiting", onWaiting) } + el.addEventListener("stalled", onStalled) + events += { el.removeEventListener("stalled", onStalled) } + el.addEventListener("error", onError) + events += { el.removeEventListener("error", onError) } + } + + private fun detachEventListeners() { + events.forEach { it.invoke() } + events.clear() + } + + @OptIn(ExperimentalWasmJsInterop::class) + actual fun play(url: String) { + release() + document.body?.appendElement("audio") { + this as HTMLAudioElement + this.id = htmlId + this.src = url + } + + val playerEl = getPlayerElement() + playerEl?.let { attachEventListeners(it) } + playerEl?.play() + lastVolume?.let { getPlayerElement()?.volume = it } + lastRate?.let { getPlayerElement()?.playbackRate = it } + } + + @OptIn(ExperimentalWasmJsInterop::class) + actual fun play() { + getPlayerElement()?.play() + lastVolume?.let { getPlayerElement()?.volume = it } + lastRate?.let { getPlayerElement()?.playbackRate = it } + } + + actual fun stop() { + getPlayerElement()?.pause() + getPlayerElement()?.currentTime = 0.0 + _state = ComposeAudioPlayerState.IDLE + } + + actual fun pause() { + getPlayerElement()?.pause() + _state = ComposeAudioPlayerState.PAUSED + } + + /** + * Stops playback and removes the player element from the DOM. + */ + actual fun release() { + val playerEl = getPlayerElement() + playerEl?.pause() + playerEl?.remove() + detachEventListeners() + _state = null + } + + private fun getPlayerElement(): HTMLAudioElement? { + return document.getElementById(htmlId) as? HTMLAudioElement + } + + actual fun currentPosition(): Long? { + // https://www.w3schools.com/jsref/prop_audio_currenttime.asp + val currentTimeSeconds = getPlayerElement()?.currentTime?.toLong() + if (currentTimeSeconds != null && currentTimeSeconds >= 0) { + return currentTimeSeconds * 1000 + } + return null + } + + actual fun currentDuration(): Long? { + // https://www.w3schools.com/jsref/prop_audio_duration.asp + val currentTimeSeconds = getPlayerElement()?.duration?.toLong() + if (currentTimeSeconds != null && currentTimeSeconds >= 0) { + return currentTimeSeconds * 1000 + } + return null + } + + actual fun currentPlayerState(): ComposeAudioPlayerState? { + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio#events + return _state + } + + actual fun currentVolume(): Float? { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volume + return getPlayerElement()?.volume?.toFloat() + } + + + actual fun setVolume(volume: Float) { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volume + lastVolume = volume.toDouble() + getPlayerElement()?.volume = lastVolume!! + } + + actual fun setRate(rate: Float) { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate + // Web browsers may choose to mute playback if rate is outside the useful range + // Acceptable values are 0.25 to 4.0 + lastRate = rate.toDouble() + getPlayerElement()?.playbackRate = lastRate!! + } + + actual fun seekTo(time: Long) { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentTime + getPlayerElement()?.currentTime = time.toDouble() / 1000.0 + } + + actual fun setOnErrorListener(listener: ErrorListener) { + errorListener = listener + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 781d7418..4a4daa60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ filekit = "0.12.0" gst1JavaCore = "1.4.0" kermit = "2.0.8" kotlin = "2.3.0" -agp = "8.13.2" +agp = "8.12.3" kotlinx-coroutines = "1.10.2" kotlinxBrowserWasmJs = "0.5.0" kotlinxDatetime = "0.7.1-0.6.x-compat" diff --git a/settings.gradle.kts b/settings.gradle.kts index e45b71b4..1774a889 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,5 +30,5 @@ dependencyResolutionManagement { } } include(":mediaplayer") +include(":audioplayer") include(":sample:composeApp") - From f3c29755c1da0b306f862fafa69c24034ff4fed2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 7 Jan 2026 14:20:02 +0200 Subject: [PATCH 2/4] Add AudioPlayerScreen and integrate ComposeAudioPlayer into sample app - Added `AudioPlayerScreen` showcasing the use of `ComposeAudioPlayer` with stream and local audio playback. - Updated navigation to include `AudioPlayer` as a new screen. - Enhanced build configuration to add dependencies for the audio player module. - Renamed `rememberGadulkaLiveState` to `rememberAudioPlayerLiveState` for consistency. --- audioplayer/build.gradle.kts | 1 + .../audio/ComposeAudioPlayer.kt | 21 +- sample/composeApp/build.gradle.kts | 2 +- .../src/commonMain/kotlin/sample/app/App.kt | 8 + .../kotlin/sample/app/AudioPlayerScreen.kt | 261 ++++++++++++++++++ .../commonMain/kotlin/sample/app/Screen.kt | 4 +- 6 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt diff --git a/audioplayer/build.gradle.kts b/audioplayer/build.gradle.kts index 40be6bd1..fc8699ab 100644 --- a/audioplayer/build.gradle.kts +++ b/audioplayer/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(compose.runtime) implementation(libs.kotlinx.coroutines.core) implementation(libs.kermit) + implementation(libs.filekit.core) } commonTest.dependencies { diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt index 369d5a19..aae5a247 100644 --- a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt @@ -144,25 +144,6 @@ expect class ComposeAudioPlayer() { fun ComposeAudioPlayer.isPlaying(): Boolean = currentPlayerState() in listOf(ComposeAudioPlayerState.PLAYING, ComposeAudioPlayerState.BUFFERING) -/** - * Provides a stateful instance of [ComposeAudioPlayer] that is remembered across recompositions. - * - * The state is managed such that it is automatically released when the composable leaves the composition, - * ensuring proper cleanup of resources. - * - * @return A remembered instance of [ComposeAudioPlayer], which represents a minimalistic audio player. - */ -@Composable -fun rememberAudioPlayerState(): ComposeAudioPlayer { - val player = remember { ComposeAudioPlayer() } - DisposableEffect(Unit) { - onDispose { - player.release() - } - } - return player -} - /** * Creates and remembers an instance of [ComposeAudioPlayerLiveState] that monitors and updates the state, * volume, position, and duration of a [ComposeAudioPlayer]. The player state is updated periodically @@ -172,7 +153,7 @@ fun rememberAudioPlayerState(): ComposeAudioPlayer { * playback position, and duration. */ @Composable -fun rememberGadulkaLiveState(): ComposeAudioPlayerLiveState { +fun rememberAudioPlayerLiveState(): ComposeAudioPlayerLiveState { val player = remember { ComposeAudioPlayer() } var state by remember { mutableStateOf(ComposeAudioPlayerState.IDLE) } var volume by remember { mutableStateOf(0f) } diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 08c92fce..ae1f7542 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -67,6 +67,7 @@ kotlin { implementation(compose.material3) implementation(compose.components.uiToolingPreview) implementation(project(":mediaplayer")) + implementation(project(":audioplayer")) implementation(compose.materialIconsExtended) implementation(libs.filekit.dialogs.compose) implementation(libs.platformtools.darkmodedetector) @@ -147,4 +148,3 @@ tasks.register("runIos") { dependencies { debugImplementation(compose.uiTooling) } - diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt index 40e2f622..eae194fa 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.* import androidx.compose.runtime.* @@ -41,6 +42,12 @@ fun App() { selected = currentScreen == Screen.VideoAttachmentPlayer, onClick = { currentScreen = Screen.VideoAttachmentPlayer } ) + NavigationBarItem( + icon = { Icon(Icons.Default.MusicNote, contentDescription = "Audio Player") }, + label = { Text("Audio Player") }, + selected = currentScreen == Screen.AudioPlayer, + onClick = { currentScreen = Screen.AudioPlayer } + ) } } ) { paddingValues -> @@ -54,6 +61,7 @@ fun App() { Screen.SinglePlayer -> SinglePlayerScreen() Screen.MultiPlayer -> MultiPlayerScreen() Screen.VideoAttachmentPlayer -> VideoAttachmentPlayerScreen() + Screen.AudioPlayer -> AudioPlayerScreen() } } } diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt new file mode 100644 index 00000000..a7fcc167 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt @@ -0,0 +1,261 @@ +package sample.app + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.composemediaplayer.audio.ComposeAudioPlayerState +import io.github.kdroidfilter.composemediaplayer.audio.ErrorListener +import io.github.kdroidfilter.composemediaplayer.audio.rememberAudioPlayerLiveState +import io.github.kdroidfilter.composemediaplayer.util.getUri +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.name + +@Composable +fun AudioPlayerScreen() { + AudioPlayerScreenCore() +} + +@Composable +private fun AudioPlayerScreenCore() { + val audioState = rememberAudioPlayerLiveState() + var streamUrl by remember { mutableStateOf("https://download.samplelib.com/wav/sample-12s.wav") } + var selectedSource by remember { mutableStateOf(AudioSource.Stream) } + var selectedFile by remember { mutableStateOf(null) } + var selectedFileName by remember { mutableStateOf(null) } + var lastOpenedUri by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + val audioFileLauncher = rememberFilePickerLauncher( + type = FileKitType.File("mp3", "wav", "ogg", "m4a", "aac", "flac") + ) { file -> + if (file != null) { + selectedFile = file + selectedFileName = file.name + selectedSource = AudioSource.Local + val uri = file.getUri() + audioState.player.play(uri) + lastOpenedUri = uri + } + } + + val stateLabel = audioState.state.name + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Audio Player", style = MaterialTheme.typography.headlineMedium) + Text("State: $stateLabel", style = MaterialTheme.typography.bodyMedium) + Text( + "Position: ${formatMillis(audioState.position)} / ${formatMillis(audioState.duration)}", + style = MaterialTheme.typography.bodyMedium + ) + + errorMessage?.let { message -> + Text("Error: $message", color = MaterialTheme.colorScheme.error) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (selectedSource == AudioSource.Stream) { + Button(onClick = { selectedSource = AudioSource.Stream }) { Text("Stream") } + } else { + OutlinedButton(onClick = { selectedSource = AudioSource.Stream }) { Text("Stream") } + } + + if (selectedSource == AudioSource.Local) { + Button(onClick = { selectedSource = AudioSource.Local }) { Text("Local") } + } else { + OutlinedButton(onClick = { selectedSource = AudioSource.Local }) { Text("Local") } + } + } + + if (selectedSource == AudioSource.Stream) { + TextField( + value = streamUrl, + onValueChange = { streamUrl = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Stream URL") }, + singleLine = true + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { audioFileLauncher.launch() }) { + Text("Select file") + } + Text(selectedFileName ?: "No file selected") + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + val isIdle = audioState.state == ComposeAudioPlayerState.IDLE + when (selectedSource) { + AudioSource.Local -> { + val uri = selectedFile?.getUri() + if (!uri.isNullOrBlank()) { + if (uri != lastOpenedUri || isIdle) { + audioState.player.play(uri) + lastOpenedUri = uri + } else { + audioState.player.play() + } + } + } + AudioSource.Stream -> { + if (streamUrl.isNotBlank()) { + if (streamUrl != lastOpenedUri || isIdle) { + audioState.player.play(streamUrl) + lastOpenedUri = streamUrl + } else { + audioState.player.play() + } + } + } + } + } + ) { + Text("Play") + } + Button( + onClick = { audioState.player.pause() }, + enabled = audioState.state == ComposeAudioPlayerState.PLAYING + ) { + Text("Pause") + } + Button( + onClick = { audioState.player.stop() }, + enabled = audioState.state != ComposeAudioPlayerState.IDLE + ) { + Text("Stop") + } + } + + val durationMs = audioState.duration + val positionFraction = if (durationMs > 0L) { + (audioState.position.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + Slider( + value = positionFraction, + onValueChange = { fraction -> + if (durationMs > 0L) { + val target = (durationMs * fraction).toLong() + audioState.player.seekTo(target) + } + }, + enabled = durationMs > 0L, + modifier = Modifier.fillMaxWidth() + ) + + VolumeSlider( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + initialVolume = audioState.volume + ) { newVolume -> + audioState.player.setVolume(newVolume) + } + } + + DisposableEffect(audioState.player) { + val listener = object : ErrorListener { + override fun onError(message: String?) { + errorMessage = message ?: "Unknown error" + } + } + audioState.player.setOnErrorListener(listener) + onDispose { } + } +} + +@Composable +private fun VolumeSlider( + modifier: Modifier = Modifier, + initialVolume: Float = 0.5f, + onVolumeChange: (Float) -> Unit = {} +) { + var volume by remember { mutableStateOf(initialVolume) } + + LaunchedEffect(initialVolume) { + volume = initialVolume + } + + Column(modifier = modifier) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + val clampedVolume = volume.coerceIn(0f, 1f) + val path = Path().apply { + moveTo(0f, size.height) + lineTo(size.width * clampedVolume, size.height) + lineTo(0f, 0f) + close() + } + drawPath(path, color = Color.Blue) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Slider( + value = volume, + onValueChange = { + volume = it + onVolumeChange(it) + }, + valueRange = 0f..1f, + modifier = Modifier.fillMaxWidth() + ) + } +} + +private enum class AudioSource { + Stream, + Local +} + +private fun formatMillis(value: Long): String { + if (value <= 0L) return "00:00" + val totalSeconds = value / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + val mm = minutes.toString().padStart(2, '0') + val ss = seconds.toString().padStart(2, '0') + return "$mm:$ss" +} diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/Screen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/Screen.kt index a7f2e8f0..1ea3875e 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/Screen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/Screen.kt @@ -2,5 +2,5 @@ package sample.app // Define screens for navigation enum class Screen { - SinglePlayer, MultiPlayer, VideoAttachmentPlayer -} \ No newline at end of file + SinglePlayer, MultiPlayer, VideoAttachmentPlayer, AudioPlayer +} From ea19d7d0eab6d77cd158c76d11e4eb9ded6954ec Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 7 Jan 2026 20:01:06 +0200 Subject: [PATCH 3/4] Integrate RodioPlayer into JVM ComposeAudioPlayer implementation - Added `RodioPlayer` for handling audio playback in the JVM target of `ComposeAudioPlayer`. - Implemented methods for playback, pause, stop, seek, and volume control using `RodioPlayer`. - Refactored state management with `ComposeAudioPlayerState` updates based on playback events. - Updated dependencies to include `Rodio` library. --- audioplayer/build.gradle.kts | 2 + .../audio/ComposeAudioPlayer.kt | 138 +++++++++++++++++- gradle/libs.versions.toml | 2 + 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/audioplayer/build.gradle.kts b/audioplayer/build.gradle.kts index fc8699ab..ef1858c8 100644 --- a/audioplayer/build.gradle.kts +++ b/audioplayer/build.gradle.kts @@ -61,6 +61,8 @@ kotlin { jvmMain.dependencies { implementation(libs.kotlinx.coroutines.swing) implementation(libs.slf4j.simple) + implementation(libs.rodio) + } jvmTest.dependencies { diff --git a/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt index 95206189..9b33116c 100644 --- a/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt +++ b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt @@ -1,47 +1,175 @@ package io.github.kdroidfilter.composemediaplayer.audio +import io.github.kdroidfilter.rodio.PlaybackCallback +import io.github.kdroidfilter.rodio.PlaybackEvent +import io.github.kdroidfilter.rodio.RodioPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.net.URI +import java.nio.file.Paths + @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) actual class ComposeAudioPlayer actual constructor() { + private var player: RodioPlayer? = RodioPlayer() + private var errorListener: ErrorListener? = null + private var lastVolume: Float? = null + @Volatile + private var state: ComposeAudioPlayerState? = ComposeAudioPlayerState.IDLE + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var playbackJob: Job? = null + + private val callback = object : PlaybackCallback { + override fun onEvent(event: PlaybackEvent) { + state = when (event) { + PlaybackEvent.CONNECTING -> ComposeAudioPlayerState.BUFFERING + PlaybackEvent.PLAYING -> ComposeAudioPlayerState.PLAYING + PlaybackEvent.PAUSED -> ComposeAudioPlayerState.PAUSED + PlaybackEvent.STOPPED -> ComposeAudioPlayerState.IDLE + } + } + + override fun onMetadata(key: String, value: String) { + } + + override fun onError(message: String) { + state = ComposeAudioPlayerState.IDLE + errorListener?.onError(message) + } + } + + init { + player?.setCallback(callback) + } + actual fun play(url: String) { + val localPlayer = ensurePlayer() + playbackJob?.cancel() + playbackJob = scope.launch { + runCatching { + if (isRemoteUrl(url)) { + localPlayer.playUrl(url, loop = false) + } else { + localPlayer.playFile(resolveFilePath(url), loop = false) + } + lastVolume?.let { localPlayer.setVolume(it) } + }.onFailure { error -> + state = ComposeAudioPlayerState.IDLE + errorListener?.onError(error.message) + } + } } actual fun play() { + val localPlayer = player ?: return + val current = currentPlayerState() + if (current == null || current == ComposeAudioPlayerState.IDLE) return + runCatching { localPlayer.play() } + .onFailure { errorListener?.onError(it.message) } } actual fun stop() { + val localPlayer = player ?: return + runCatching { localPlayer.stop() } + .onFailure { errorListener?.onError(it.message) } + state = ComposeAudioPlayerState.IDLE } actual fun pause() { + val localPlayer = player ?: return + runCatching { localPlayer.pause() } + .onFailure { errorListener?.onError(it.message) } + state = ComposeAudioPlayerState.PAUSED } actual fun release() { + playbackJob?.cancel() + playbackJob = null + val localPlayer = player + if (localPlayer != null) { + runCatching { localPlayer.clearCallback() } + runCatching { localPlayer.close() } + } + player = null + state = null } actual fun currentPosition(): Long? { - TODO("Not yet implemented") + val localPlayer = player ?: return null + return runCatching { localPlayer.getPositionMs() }.getOrNull() } actual fun currentDuration(): Long? { - TODO("Not yet implemented") + val localPlayer = player ?: return null + return runCatching { localPlayer.getDurationMs() }.getOrNull() } actual fun currentPlayerState(): ComposeAudioPlayerState? { - TODO("Not yet implemented") + val localPlayer = player ?: return null + val snapshot = state ?: return null + if (snapshot == ComposeAudioPlayerState.BUFFERING) return snapshot + val isEmpty = runCatching { localPlayer.isEmpty() }.getOrDefault(true) + if (isEmpty) return ComposeAudioPlayerState.IDLE + val isPaused = runCatching { localPlayer.isPaused() }.getOrDefault(false) + if (isPaused) return ComposeAudioPlayerState.PAUSED + return ComposeAudioPlayerState.PLAYING } actual fun currentVolume(): Float? { - TODO("Not yet implemented") + if (player == null) return null + return lastVolume ?: 1f } actual fun setVolume(volume: Float) { + lastVolume = volume + val localPlayer = player ?: return + runCatching { localPlayer.setVolume(volume) } + .onFailure { errorListener?.onError(it.message) } } actual fun setRate(rate: Float) { } actual fun seekTo(time: Long) { + val localPlayer = player ?: return + runCatching { localPlayer.seekToMs(time) } + .onFailure { errorListener?.onError(it.message) } } actual fun setOnErrorListener(listener: ErrorListener) { + errorListener = listener + } + + private fun ensurePlayer(): RodioPlayer { + val existing = player + if (existing != null) return existing + val newPlayer = RodioPlayer() + newPlayer.setCallback(callback) + lastVolume?.let { volume -> + runCatching { newPlayer.setVolume(volume) } + } + player = newPlayer + state = ComposeAudioPlayerState.IDLE + return newPlayer + } + + private fun isRemoteUrl(url: String): Boolean { + return url.startsWith("http://", ignoreCase = true) || + url.startsWith("https://", ignoreCase = true) + } + + private fun resolveFilePath(url: String): String { + if (!url.startsWith("file:", ignoreCase = true)) return url + val uri = runCatching { URI(url) }.getOrNull() + if (uri != null) { + val path = runCatching { Paths.get(uri).toString() }.getOrNull() + if (!path.isNullOrBlank()) return path + val uriPath = uri.path + if (!uriPath.isNullOrBlank()) return uriPath + } + return url.substring(5) } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a4daa60..d50116f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ androidx-core = "1.17.0" media3Exoplayer = "1.9.0" jna = "5.18.1" platformtoolsDarkmodedetector = "0.7.4" +rodio = "0.1.0" slf4jSimple = "2.0.17" # minSdk = 21 failed to compile because the project indirectly depends on the library [androidx.navigationevent:navigationevent-android:1.0.1] which requires minSdk = 23 @@ -44,6 +45,7 @@ jna-jpms = { module = "net.java.dev.jna:jna-jpms", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna"} platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtoolsDarkmodedetector" } +rodio = { module = "io.github.kdroidfilter:rodio", version.ref = "rodio" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } [plugins] From 9268cca8c78b53c5f88e544f3c77f61044e5a4da Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 7 Jan 2026 20:38:00 +0200 Subject: [PATCH 4/4] Refactor `ComposeAudioPlayer` to `AudioPlayer` and update related references - Renamed `ComposeAudioPlayer` to `AudioPlayer` across all platform-specific implementations (Android, iOS, JVM). - Replaced `ComposeAudioPlayerState` with `AudioPlayerState` to align with updated naming. - Updated `AudioPlayerScreen` in the sample app to use the new `AudioPlayer` class and state. - Revised `README_AUDIO.MD` and sample code to reflect the renaming. - Improved consistency and clarity in state management logic. --- README.MD | 554 +---------------- README_AUDIO.MD | 146 +++++ README_VIDEO.MD | 575 ++++++++++++++++++ ...ayer.android.kt => AudioPlayer.android.kt} | 18 +- .../{ComposeAudioPlayer.kt => AudioPlayer.kt} | 18 +- ...udioPlayerState.kt => AudioPlayerState.kt} | 2 +- ...eAudioPlayer.ios.kt => AudioPlayer.ios.kt} | 24 +- .../{ComposeAudioPlayer.kt => AudioPlayer.kt} | 34 +- ...eAudioPlayer.web.kt => AudioPlayer.web.kt} | 22 +- .../kotlin/sample/app/AudioPlayerScreen.kt | 8 +- 10 files changed, 811 insertions(+), 590 deletions(-) create mode 100644 README_AUDIO.MD create mode 100644 README_VIDEO.MD rename audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayer.android.kt => AudioPlayer.android.kt} (86%) rename audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayer.kt => AudioPlayer.kt} (89%) rename audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayerState.kt => AudioPlayerState.kt} (87%) rename audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayer.ios.kt => AudioPlayer.ios.kt} (91%) rename audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayer.kt => AudioPlayer.kt} (82%) rename audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/{ComposeAudioPlayer.web.kt => AudioPlayer.web.kt} (86%) diff --git a/README.MD b/README.MD index fc31c71b..70933237 100644 --- a/README.MD +++ b/README.MD @@ -3,6 +3,7 @@ [![Maven Central](https://img.shields.io/maven-central/v/io.github.kdroidfilter/composemediaplayer.svg)](https://search.maven.org/artifact/io.github.kdroidfilter/composemediaplayer) +[![Maven Central - Audio](https://img.shields.io/maven-central/v/io.github.kdroidfilter/composemediaplayer-audio.svg?label=audio)](https://search.maven.org/artifact/io.github.kdroidfilter/composemediaplayer-audio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-blue.svg?logo=kotlin)](https://kotlinlang.org/docs/multiplatform.html) [![Platforms](https://img.shields.io/badge/platforms-Android%20|%20iOS%20|%20macOS%20JVM%20|%20Windows%20JVM%20|%20Linux%20JVM%20|%20Web-lightgrey.svg)](https://github.com/kdroidfilter/ComposeMediaPlayer) @@ -11,78 +12,28 @@ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/kdroidfilter/ComposeMediaPlayer/publish-documentation-and-sample.yml?branch=master)](https://github.com/kdroidfilter/ComposeMediaPlayer/actions/workflows/publish-documentation-and-sample.yml) [![GitHub last commit](https://img.shields.io/github/last-commit/kdroidfilter/ComposeMediaPlayer)](https://github.com/kdroidfilter/ComposeMediaPlayer/commits/master) -**Compose Media Player** is a video player library designed for Compose Multiplatform, supporting multiple platforms including Android, macOS, Windows, and Linux. It is the first fully functional multiplatform video player for Compose for Desktop that requires no additional software installations. The library leverages: +Compose Media Player is a media playback library for Compose Multiplatform. It provides two modules with distinct APIs: -- **GStreamer** for Linux -- **Media Foundation** for Windows -- **AVPlayer** for macOS and iOS -- **Media3** for Android -- **HTML5 Player** for WASMJS +- **Video player**: `composemediaplayer` for full video playback with Compose UI, subtitles, metadata, and platform-native backends. +- **Audio player**: `composemediaplayer-audio` for lightweight audio-only playback (play, pause, stop, seek, volume). ## Table of Contents -- [Live Demo](#-live-demo) -- [Features](#-features) -- [Supported Video Formats](#-supported-video-formats) -- [Installation](#-installation) -- [Compatibility Table](#-compatibility-table) -- [Getting Started](#-getting-started) - - [Initialization](#initialization) - - [Displaying the Video Surface](#displaying-the-video-surface) - - [Content Scaling](#content-scaling) - - [Surface type](#surface-type) - - [Custom Overlay UI](#custom-overlay-ui) - - [Video Playback via URL or Local Files](#video-playback-via-url-or-local-files) - - [Full Controls](#full-controls) - - [Progress Indicators](#progress-indicators) - - [Display Left and Right Volume Levels](#display-left-and-right-volume-levels) - - [Error Handling](#error-handling) - - [Loading Indicator](#loading-indicator) - - [Using Subtitles](#using-subtitles) - - [Supported Formats](#-supported-formats) - - [Adding Subtitles from URL or Local File](#-adding-subtitles-from-url-or-local-file) - - [Customizing Subtitle Appearance](#-customizing-subtitle-appearance) - - [Disabling Subtitles](#-disabling-subtitles) - - [Fullscreen Mode](#️-fullscreen-mode) -- [Metadata Support](#-metadata-support) - - [Example Usage](#example-usage) - - [Basic Example](#-basic-example) -- [License](#-license) -- [Roadmap](#-roadmap) -- [Applications Using This Library](#-applications-using-this-library) -- [Star History](#-star-history) +- [Documentation](#documentation) +- [Installation](#installation) +- [Live Demo](#live-demo) +- [Roadmap](#roadmap) +- [License](#license) +- [Applications Using This Library](#applications-using-this-library) +- [Star History](#star-history) -## 🚀 Live Demo +## Documentation -Try the online demo here : [🎥 Live Demo](https://kdroidfilter.github.io/ComposeMediaPlayer/sample/) +- Video player docs: [README_VIDEO.MD](README_VIDEO.MD) +- Audio player docs: [README_AUDIO.MD](README_AUDIO.MD) -## ✨ Features +## Installation -- **Multiplatform Support**: Works seamlessly on Android, macOS, Windows, Linux and Compose Web (Wasm). -- **File and URL Support**: Play videos from local files or directly from URLs. -- **Media Controls**: Includes play, pause, loop toggle, volume control, playback speed, loop playback and timeline slider. -- **Initial Playback Control**: Choose whether videos automatically play or remain paused after opening. -- **Custom Video Player UI**: Fully customizable using Compose Multiplatform, with support for custom overlays that display even in fullscreen mode. -- **Audio Levels**: Displays left and right audio levels in real time. -- **Fullscreen Mode**: Toggle between windowed and fullscreen playback modes. -- **Error handling** Simple error handling for network or playback issues. - -## ✨ Supported Video Formats -| Format | Windows | Linux | macOS & iOS | Android | WasmJS | -|--------|-------------------------------------------------------------------------------------------------------------------|-------------------|-----------------------------------------------------------------------------|-----------------|-------------------| -| **Player** | [MediaFoundation](https://learn.microsoft.com/en-us/windows/win32/medfound/microsoft-media-foundation-sdk) | [GStreamer](https://gstreamer.freedesktop.org/) | [AVPlayer](https://developer.apple.com/documentation/avfoundation/avplayer) | [Media 3](https://developer.android.com/media/media3) | [HTML5 Video](https://www.w3schools.com/html/html5_video.asp) | -| MP4 (H.264) | ✅ | ✅ | ✅ | ✅ | ✅ | -| AVI | ❌ | ✅ | ❌ | ❌ | ❌ | -| MKV | ❌ | ✅ | ❌ | ✅ | ❌ | -| MOV | ✅ | ✅ | ✅ | ❌ | ✅ | -| FLV | ❌ | ✅ | ❌ | ❌ | ❌ | -| WEBM | ❌ | ✅ | ❌ | ✅ | ✅ | -| WMV | ✅ | ✅ | ❌ | ❌ | ❌ | -| 3GP | ✅ | ✅ | ✅ | ✅ | ❌ | - - -## 🔧 Installation - -To add Compose Media Player to your project, include the following dependency in your `build.gradle.kts` file: +Video player: ```kotlin dependencies { @@ -90,485 +41,34 @@ dependencies { } ``` -## 📊 Compatibility Table - -| Library Version | Kotlin Version | Compose Version | -|-----------------|----------------|-----------------| -| 0.8.6 | 2.3.0 | 1.9.3 | -| 0.8.3 | 2.2.20 | 1.9.0 | -| 0.7.11 | 2.2.0 | 1.8.2 | -| 0.7.10 | 2.1.21 | 1.8.2 | - -## 🚀 Getting Started - -### Initialization - -Before using Compose Media Player, you need to create a state for the video player using the `rememberVideoPlayerState` function: - -```kotlin -val playerState = rememberVideoPlayerState() -``` - -### Displaying the Video Surface - -After initializing the player state, you can display the surface of the video using `VideoPlayerSurface`: - -```kotlin -// Video Surface -Box( - modifier = Modifier.weight(1f).fillMaxWidth(), - contentAlignment = Alignment.Center -) { - VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize() - ) -} -``` - -#### Content Scaling - -> [!WARNING] -> Content scaling support is experimental. The behavior may vary across different platforms. - -You can control how the video content is scaled inside the surface using the `contentScale` parameter: - -```kotlin -VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop // Default is ContentScale.Fit -) -``` - -Available content scale options: -- `ContentScale.Fit` (default): Scales the video to fit within the surface while maintaining aspect ratio -- `ContentScale.Crop`: Scales the video to fill the surface while maintaining aspect ratio, potentially cropping parts -- `ContentScale.FillBounds`: Stretches the video to fill the surface, may distort the aspect ratio -- `ContentScale.Inside`: Similar to Fit, but won't scale up if the video is smaller than the surface -- `ContentScale.None`: No scaling applied - -#### Surface type - -> [!WARNING] -> Surface type parameter is supported only for Android target. - -Available surface type options: -- `SurfaceType.SurfaceView`: uses SurfaceView for the player view, which is more performant for video playback but has limitations in terms of composability and animations. -- `SurfaceType.TextureView` (default): uses TextureView for the player view, which allows for more complex composable layouts and animations. - -```kotlin -VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize(), - surfaceType = SurfaceType.SurfaceView // Default is SurfaceType.TextureView -) -``` - -#### Custom Overlay UI - -You can add a custom overlay UI that will always be visible, even in fullscreen mode, by using the `overlay` parameter: - -```kotlin -VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize()) { - // This overlay will always be visible - Box(modifier = Modifier.fillMaxSize()) { - // You can customize the UI based on fullscreen state - if (playerState.isFullscreen) { - // Fullscreen UI - IconButton( - onClick = { playerState.toggleFullscreen() }, - modifier = Modifier.align(Alignment.TopEnd).padding(16.dp) - ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = "Exit Fullscreen", - tint = Color.White - ) - } - } else { - // Regular UI - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.5f)) - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Your custom controls here - IconButton(onClick = { - if (playerState.isPlaying) playerState.pause() else playerState.play() - }) { - Icon( - imageVector = if (playerState.isPlaying) - Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = "Play/Pause", - tint = Color.White - ) - } - } - } - } - } -``` - -### Video Playback via URL or Local Files - -You can play a video by providing a direct URL: - -```kotlin -// Open a video and automatically start playing (default behavior) -playerState.openUri("http://example.com/video.mp4") - -// Open a video but keep it paused initially -playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE) -``` - -To play a local video file you can use [PlatformFile](https://filekit.mintlify.app/core/platform-file) from [FileKit](https://github.com/vinceglb/FileKit). - -```kotlin -val file = FileKit.openFilePicker(type = FileKitType.Video) - -// Open a file and automatically start playing (default behavior) -file?.let { playerState.openFile(it) } - -// Open a file but keep it paused initially -file?.let { playerState.openFile(it, InitialPlayerState.PAUSE) } -``` - -The `initializeplayerState` parameter controls whether the video automatically starts playing after opening: -- `InitialPlayerState.PLAY` (default): The video will automatically start playing after opening -- `InitialPlayerState.PAUSE`: The video will be loaded but remain paused until you call `play()` - -Check the [sample project](sample/composeApp/src/commonMain/kotlin/sample/app/App.kt) for a complete example. - -### Full Controls - -- **Play and Pause**: - -You can detect the current playback state via `playerState.isPlaying` and configure a Play/Pause button as follows: - -```kotlin -Button(onClick = { - if (playerState.isPlaying) { - playerState.pause() - println("Playback paused") - } else { - playerState.play() - println("Playback started") - } -}) { - Text(if (playerState.isPlaying) "Pause" else "Play") -} -``` - -- **Stop**: - -```kotlin -playerState.stop() -println("Playback stopped") -``` - -- **Volume**: - -```kotlin -playerState.volume = 0.5f // Set volume to 50% -println("Volume set to 50%") -``` - -- **Loop Playback**: - -```kotlin -playerState.loop = true // Enable loop playback -println("Loop playback enabled") -``` - -- **Playback Speed**: - -```kotlin -playerState.playbackSpeed = 1.5f // Set playback speed to 1.5x -println("Playback speed set to 1.5x") -``` - -You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The default value is 1.0x (normal speed). - -### Progress Indicators - -To display and control playback progress: - -```kotlin -Slider( - value = playerState.sliderPos, - onValueChange = { - playerState.sliderPos = it - playerState.userDragging = true - println("Position changed: $it") - }, - onValueChangeFinished = { - playerState.userDragging = false - playerState.seekTo(playerState.sliderPos) - println("Position finalized: ${playerState.sliderPos}") - }, - valueRange = 0f..1000f -) -``` - -### Display Left and Right Volume Levels - -To display audio levels: - -```kotlin -println("Left level: ${playerState.leftLevel.toInt()}%, Right level: ${playerState.rightLevel.toInt()}%") -``` - -> [!IMPORTANT] -> This feature is not working on iOS. - - -### Error Handling - -In case of an error, you can display it using `println`: - -```kotlin -playerState.error?.let { error -> - println("Error detected: ${error.message}") - playerState.clearError() -} -``` - -### Loading Indicator - -To detect if the video is buffering: - -```kotlin -if (playerState.isLoading) { - CircularProgressIndicator() -} -```` - -### Using Subtitles - -Compose Media Player supports adding subtitles from both URLs and local files. Subtitles are now rendered using Compose, providing a uniform appearance across all platforms. - -#### 🎯 Supported Formats - -The player supports both SRT and VTT subtitle formats with automatic format detection. - -#### 🎯 Adding Subtitles from URL or Local File - -You can add subtitles by specifying a URL: - -```kotlin -val track = SubtitleTrack( - label = "English Subtitles", - language = "en", - src = "https://example.com/subtitles.vtt" // Works with both .srt and .vtt files -) -playerState.selectSubtitleTrack(track) -``` - -#### 🎨 Customizing Subtitle Appearance - -You can customize the appearance of subtitles using the following properties: - -```kotlin -// Customize subtitle text style -playerState.subtitleTextStyle = TextStyle( - color = Color.White, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center -) - -// Customize subtitle background color -playerState.subtitleBackgroundColor = Color.Black.copy(alpha = 0.7f) -``` - -#### ❌ Disabling Subtitles - -To disable subtitles: - -```kotlin -playerState.disableSubtitles() -``` - -### 🖥️ Fullscreen Mode - -> [!WARNING] -> Fullscreen support is experimental. The behavior may vary across different platforms. - -You can toggle between windowed and fullscreen modes using the `toggleFullscreen()` method: - -```kotlin -// Toggle fullscreen mode -playerState.toggleFullscreen() - -// Check current fullscreen state -if (playerState.isFullscreen) { - println("Player is in fullscreen mode") -} else { - println("Player is in windowed mode") -} -``` - -The player doesn't display any UI by default in fullscreen mode - you need to create your own custom UI using the `overlay` parameter of `VideoPlayerSurface`. The overlay will be displayed even in fullscreen mode, and you can customize it based on the fullscreen state: - -```kotlin -VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize(), - overlay = { - Box(modifier = Modifier.fillMaxSize()) { - // Customize UI based on fullscreen state - if (playerState.isFullscreen) { - // Fullscreen UI - // ... - } else { - // Regular UI - // ... - } - } - } -) -``` - -See the "Custom Overlay UI" section under "Displaying the Video Surface" for a complete example. - -## 🔍 Metadata Support - -> [!WARNING] -> Metadata support is experimental. There may be inconsistencies between platforms, and on WASM it's currently limited to width and height only. - -The player can extract the following metadata: -- Title -- Duration (in milliseconds) -- Video resolution (width and height) -- Bitrate (in bits per second) -- Frame rate -- MIME type -- Audio channels -- Audio sample rate - -### Example Usage - -You can access video metadata through the `metadata` property of the player state: +Audio player: ```kotlin -// Access metadata after loading a video -playerState.openUri("http://example.com/video.mp4") // Auto-plays by default -// Or load without auto-playing: -// playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE) - -// Display metadata information -val metadata = playerState.metadata - -println("Video Metadata:") -metadata.title?.let { println("Title: $it") } -metadata.duration?.let { println("Duration: ${it}ms") } -metadata.width?.let { width -> - metadata.height?.let { height -> - println("Resolution: ${width}x${height}") - } +dependencies { + implementation("io.github.kdroidfilter:composemediaplayer-audio:") } -metadata.bitrate?.let { println("Bitrate: ${it}bps") } -metadata.frameRate?.let { println("Frame Rate: ${it}fps") } -metadata.mimeType?.let { println("MIME Type: $it") } -metadata.audioChannels?.let { println("Audio Channels: $it") } -metadata.audioSampleRate?.let { println("Audio Sample Rate: ${it}Hz") } - ``` +## Live Demo -### 📋 Basic Example - -Here is a minimal example of how to integrate the Compose Media Player into your Compose application with a hardcoded URL: - -```kotlin -@Composable -fun App() { - val playerState = rememberVideoPlayerState() - - MaterialTheme { - Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { - - // Video Surface - Box( - modifier = Modifier.weight(1f).fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - VideoPlayerSurface( - playerState = playerState, - modifier = Modifier.fillMaxSize() - ) - } - - Spacer(modifier = Modifier.height(8.dp)) +Try the online demo here: [Live Demo](https://kdroidfilter.github.io/ComposeMediaPlayer/sample/) - // Playback Controls - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Button(onClick = { playerState.play() }) { Text("Play") } - Button(onClick = { playerState.pause() }) { Text("Pause") } - } +## Roadmap - Spacer(modifier = Modifier.height(8.dp)) - - // Open Video URL buttons - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Button( - onClick = { - val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" - playerState.openUri(url) // Default: auto-play - } - ) { - Text("Play Video") - } - - Button( - onClick = { - val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" - playerState.openUri(url, InitialPlayerState.PAUSE) // Open paused - } - ) { - Text("Load Video Paused") - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Volume Control - Text("Volume: ${(playerState.volume * 100).toInt()}%") - Slider( - value = playerState.volume, - onValueChange = { playerState.volume = it }, - valueRange = 0f..1f - ) - } - } -} -``` +- **Audio Player Enhancements**: Add metadata surfacing, playback rate on JVM, and richer stream diagnostics. +- **Player with Separate Audio and Video Streams**: Add functionality to support different audio and video streams for advanced playback scenarios. -## 📄 License +## License Compose Media Player is licensed under the MIT License. See [LICENSE](LICENSE) for details. -## 📊 Roadmap - -- **Audio Player**: Introduce a standalone audio player for handling audio-only content. -- **Player with Separate Audio and Video Streams**: Add functionality to support different audio and video streams for advanced playback scenarios. - -## 🚀 Applications Using This Library +## Applications Using This Library - [Pushscroll](https://pushscroll.com) - Pushscroll: Screen-Time Gym - [Pixelix](https://github.com/ghostbyte-dev/pixelix) - Pixelfed client for Android and iOS If you're using this library in your project, please let us know and we'll add it to this list! -## ⭐ Star History +## Star History [![Star History Chart](https://api.star-history.com/svg?repos=kdroidfilter/ComposeMediaPlayer&type=Date)](https://www.star-history.com/#kdroidfilter/ComposeMediaPlayer&Date) diff --git a/README_AUDIO.MD b/README_AUDIO.MD new file mode 100644 index 00000000..df34d550 --- /dev/null +++ b/README_AUDIO.MD @@ -0,0 +1,146 @@ +# Compose Media Player Audio + +[![Maven Central - Audio](https://img.shields.io/maven-central/v/io.github.kdroidfilter/composemediaplayer-audio.svg?label=audio)](https://search.maven.org/artifact/io.github.kdroidfilter/composemediaplayer-audio) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-blue.svg?logo=kotlin)](https://kotlinlang.org/docs/multiplatform.html) +[![Platforms](https://img.shields.io/badge/platforms-Android%20|%20iOS%20|%20macOS%20JVM%20|%20Windows%20JVM%20|%20Linux%20JVM%20|%20Web-lightgrey.svg)](https://github.com/kdroidfilter/ComposeMediaPlayer) + +Compose Media Player audio module is a lightweight audio-only player for Compose Multiplatform. It supports Android, iOS, JVM Desktop (macOS/Windows/Linux), and Web (Wasm). + +For the video module, see [README_VIDEO.MD](README_VIDEO.MD). + +## Table of Contents +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) +- [State and Controls](#state-and-controls) +- [Error Handling](#error-handling) +- [Platform Notes](#platform-notes) +- [Audio Format Support](#audio-format-support) +- [Sample](#sample) + +## Features + +- **Multiplatform Support**: Works on Android, iOS, JVM Desktop, and Web (Wasm). +- **Local and Remote Playback**: Play files from disk or stream from HTTP(S) URLs. +- **Simple Controls**: Play, pause, stop, seek, and volume. +- **Live State Helper**: `rememberAudioPlayerLiveState()` exposes state, position, duration, and volume. +- **Error Callbacks**: Hook into playback errors via `setOnErrorListener`. + +## Installation + +Add the audio module dependency in your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("io.github.kdroidfilter:composemediaplayer-audio:") +} +``` + +## Usage + +```kotlin +@Composable +fun AudioSample() { + val audioState = rememberAudioPlayerLiveState() + val url = "https://example.com/stream.mp3" + + Row { + Button(onClick = { audioState.player.play(url) }) { Text("Play") } + Button(onClick = { audioState.player.pause() }) { Text("Pause") } + Button(onClick = { audioState.player.stop() }) { Text("Stop") } + } +} +``` + +For local files, pass a file path or file URI: + +```kotlin +val player = AudioPlayer() +player.play("/path/to/file.mp3") +// or: player.play("file:///path/to/file.mp3") +``` + +If you are using FileKit, you can pass `PlatformFile.getUri()`: + +```kotlin +val file = FileKit.openFilePicker(type = FileKitType.File("mp3", "wav")) +file?.let { audioState.player.play(it.getUri()) } +``` + +## State and Controls + +- `currentPosition()` and `currentDuration()` return milliseconds. Duration may be null for live streams. +- `currentPlayerState()` returns `AudioPlayerState.PLAYING`, `PAUSED`, `BUFFERING`, or `IDLE`. +- `seekTo(timeMs)` works only when the source is seekable. +- `setVolume(volume)` expects a value between 0.0 and 1.0. +- `setRate(rate)` is supported on Android/iOS/Web; on JVM it is currently a no-op. + +## Error Handling + +```kotlin +audioState.player.setOnErrorListener(object : ErrorListener { + override fun onError(message: String?) { + println("Audio error: ${message ?: "Unknown error"}") + } +}) +``` + +## Platform Notes + +- Android uses Media3, iOS uses AVFoundation, Web uses HTML5 Audio, JVM uses Rodio. +- Seeking and duration availability depend on the source (stream vs file). + +## Audio Format Support + +Audio format support depends on OS/browser decoders. The tables below show typical baseline support per backend. +Legend: Yes supported, No not supported, Partial device/browser-dependent. + +### JVM (Rodio) + +| Format | Local | HTTP | HLS | Seeking | +|--------|-------|------|-----|---------| +| MP3 | Yes | Yes | Yes | Yes | +| AAC | Yes | Yes | Yes | Yes | +| FLAC | Yes | Yes | No | Yes | +| OGG | Yes | Yes | No | Yes | +| WAV | Yes | Yes | No | Yes | + +### Android (Media3) + +| Format | Local | HTTP | HLS | Seeking | +|--------|-------|------|-----|---------| +| MP3 | Yes | Yes | Yes | Yes | +| AAC | Yes | Yes | Yes | Yes | +| FLAC | Yes | Yes | No | Yes | +| OGG | Yes | Yes | No | Yes | +| WAV | Yes | Yes | No | Yes | + +### iOS (AVFoundation) + +| Format | Local | HTTP | HLS | Seeking | +|--------|-------|------|-----|---------| +| MP3 | Yes | Yes | Partial | Yes | +| AAC | Yes | Yes | Yes | Yes | +| FLAC | Partial | Partial | No | Partial | +| OGG | No | No | No | No | +| WAV | Yes | Yes | No | Yes | + +### Web (HTML5 Audio) + +| Format | Local | HTTP | HLS | Seeking | +|--------|-------|------|-----|---------| +| MP3 | Yes | Yes | Partial | Yes | +| AAC | Yes | Yes | Partial | Yes | +| FLAC | Partial | Partial | No | Partial | +| OGG | Partial | Partial | No | Partial | +| WAV | Yes | Yes | No | Yes | + +Notes: +- Web "Local" assumes a `blob:` or `file:` URL from a file picker; browser security rules apply. +- Web HLS is supported only in Safari unless you provide a custom HLS loader. +- iOS FLAC support varies by OS version and device. + +## Sample + +See `sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt` for a complete example. diff --git a/README_VIDEO.MD b/README_VIDEO.MD new file mode 100644 index 00000000..1410dd11 --- /dev/null +++ b/README_VIDEO.MD @@ -0,0 +1,575 @@ +# Compose Media Player Video + + + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.kdroidfilter/composemediaplayer.svg)](https://search.maven.org/artifact/io.github.kdroidfilter/composemediaplayer) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-blue.svg?logo=kotlin)](https://kotlinlang.org/docs/multiplatform.html) +[![Platforms](https://img.shields.io/badge/platforms-Android%20|%20iOS%20|%20macOS%20JVM%20|%20Windows%20JVM%20|%20Linux%20JVM%20|%20Web-lightgrey.svg)](https://github.com/kdroidfilter/ComposeMediaPlayer) +[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/kdroidfilter/ComposeMediaPlayer/issues) +[![Documentation](https://img.shields.io/badge/docs-Dokka-blue.svg)](https://kdroidfilter.github.io/ComposeMediaPlayer/) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/kdroidfilter/ComposeMediaPlayer/publish-documentation-and-sample.yml?branch=master)](https://github.com/kdroidfilter/ComposeMediaPlayer/actions/workflows/publish-documentation-and-sample.yml) +[![GitHub last commit](https://img.shields.io/github/last-commit/kdroidfilter/ComposeMediaPlayer)](https://github.com/kdroidfilter/ComposeMediaPlayer/commits/master) + +Compose Media Player video module is a video player library designed for Compose Multiplatform, supporting Android, iOS, JVM Desktop (macOS/Windows/Linux), and Web (Wasm). It is the first fully functional multiplatform video player for Compose for Desktop that requires no additional software installations. + +For the audio module, see [README_AUDIO.MD](README_AUDIO.MD). + +The video player leverages: + +- **GStreamer** for Linux +- **Media Foundation** for Windows +- **AVPlayer** for macOS and iOS +- **Media3** for Android +- **HTML5 Video** for WASMJS + +## Table of Contents +- [Live Demo](#live-demo) +- [Features](#features) +- [Supported Video Formats](#supported-video-formats) +- [Installation](#installation) +- [Compatibility Table](#compatibility-table) +- [Getting Started](#getting-started) + - [Initialization](#initialization) + - [Displaying the Video Surface](#displaying-the-video-surface) + - [Content Scaling](#content-scaling) + - [Surface type](#surface-type) + - [Custom Overlay UI](#custom-overlay-ui) + - [Video Playback via URL or Local Files](#video-playback-via-url-or-local-files) + - [Full Controls](#full-controls) + - [Progress Indicators](#progress-indicators) + - [Display Left and Right Volume Levels](#display-left-and-right-volume-levels) + - [Error Handling](#error-handling) + - [Loading Indicator](#loading-indicator) + - [Using Subtitles](#using-subtitles) + - [Supported Formats](#supported-formats) + - [Adding Subtitles from URL or Local File](#adding-subtitles-from-url-or-local-file) + - [Customizing Subtitle Appearance](#customizing-subtitle-appearance) + - [Disabling Subtitles](#disabling-subtitles) + - [Fullscreen Mode](#fullscreen-mode) +- [Metadata Support](#metadata-support) + - [Example Usage](#example-usage) + - [Basic Example](#basic-example) +- [License](#license) +- [Roadmap](#roadmap) +- [Applications Using This Library](#applications-using-this-library) +- [Star History](#star-history) + +## Live Demo + +Try the online demo here: [Live Demo](https://kdroidfilter.github.io/ComposeMediaPlayer/sample/) + +## Features + +- **Multiplatform Support**: Works seamlessly on Android, iOS, macOS, Windows, Linux and Compose Web (Wasm). +- **File and URL Support**: Play videos from local files or directly from URLs. +- **Media Controls**: Includes play, pause, loop toggle, volume control, playback speed, loop playback and timeline slider. +- **Initial Playback Control**: Choose whether videos automatically play or remain paused after opening. +- **Custom Video Player UI**: Fully customizable using Compose Multiplatform, with support for custom overlays that display even in fullscreen mode. +- **Audio Levels**: Displays left and right audio levels in real time. +- **Fullscreen Mode**: Toggle between windowed and fullscreen playback modes. +- **Error handling** Simple error handling for network or playback issues. + +## Supported Video Formats + +| Format | Windows | Linux | macOS & iOS | Android | WasmJS | +|--------|---------|-------|-------------|---------|--------| +| **Player** | [MediaFoundation](https://learn.microsoft.com/en-us/windows/win32/medfound/microsoft-media-foundation-sdk) | [GStreamer](https://gstreamer.freedesktop.org/) | [AVPlayer](https://developer.apple.com/documentation/avfoundation/avplayer) | [Media 3](https://developer.android.com/media/media3) | [HTML5 Video](https://www.w3schools.com/html/html5_video.asp) | +| MP4 (H.264) | Yes | Yes | Yes | Yes | Yes | +| AVI | No | Yes | No | No | No | +| MKV | No | Yes | No | Yes | No | +| MOV | Yes | Yes | Yes | No | Yes | +| FLV | No | Yes | No | No | No | +| WEBM | No | Yes | No | Yes | Yes | +| WMV | Yes | Yes | No | No | No | +| 3GP | Yes | Yes | Yes | Yes | No | + +## Installation + +To add Compose Media Player video module to your project, include the following dependency in your `build.gradle.kts` file: + +```kotlin +dependencies { + implementation("io.github.kdroidfilter:composemediaplayer:") +} +``` + +## Compatibility Table + +| Library Version | Kotlin Version | Compose Version | +|-----------------|----------------|-----------------| +| 0.8.6 | 2.3.0 | 1.9.3 | +| 0.8.3 | 2.2.20 | 1.9.0 | +| 0.7.11 | 2.2.0 | 1.8.2 | +| 0.7.10 | 2.1.21 | 1.8.2 | + +## Getting Started + +### Initialization + +Before using Compose Media Player, you need to create a state for the video player using the `rememberVideoPlayerState` function: + +```kotlin +val playerState = rememberVideoPlayerState() +``` + +### Displaying the Video Surface + +After initializing the player state, you can display the surface of the video using `VideoPlayerSurface`: + +```kotlin +// Video Surface +Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center +) { + VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize() + ) +} +``` + +#### Content Scaling + +> [!WARNING] +> Content scaling support is experimental. The behavior may vary across different platforms. + +You can control how the video content is scaled inside the surface using the `contentScale` parameter: + +```kotlin +VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop // Default is ContentScale.Fit +) +``` + +Available content scale options: +- `ContentScale.Fit` (default): Scales the video to fit within the surface while maintaining aspect ratio +- `ContentScale.Crop`: Scales the video to fill the surface while maintaining aspect ratio, potentially cropping parts +- `ContentScale.FillBounds`: Stretches the video to fill the surface, may distort the aspect ratio +- `ContentScale.Inside`: Similar to Fit, but won't scale up if the video is smaller than the surface +- `ContentScale.None`: No scaling applied + +#### Surface type + +> [!WARNING] +> Surface type parameter is supported only for Android target. + +Available surface type options: +- `SurfaceType.SurfaceView`: uses SurfaceView for the player view, which is more performant for video playback but has limitations in terms of composability and animations. +- `SurfaceType.TextureView` (default): uses TextureView for the player view, which allows for more complex composable layouts and animations. + +```kotlin +VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize(), + surfaceType = SurfaceType.SurfaceView // Default is SurfaceType.TextureView +) +``` + +#### Custom Overlay UI + +You can add a custom overlay UI that will always be visible, even in fullscreen mode, by using the `overlay` parameter: + +```kotlin +VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize()) { + // This overlay will always be visible + Box(modifier = Modifier.fillMaxSize()) { + // You can customize the UI based on fullscreen state + if (playerState.isFullscreen) { + // Fullscreen UI + IconButton( + onClick = { playerState.toggleFullscreen() }, + modifier = Modifier.align(Alignment.TopEnd).padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = "Exit Fullscreen", + tint = Color.White + ) + } + } else { + // Regular UI + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.5f)) + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Your custom controls here + IconButton(onClick = { + if (playerState.isPlaying) playerState.pause() else playerState.play() + }) { + Icon( + imageVector = if (playerState.isPlaying) + Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = "Play/Pause", + tint = Color.White + ) + } + } + } + } + } +``` + +### Video Playback via URL or Local Files + +You can play a video by providing a direct URL: + +```kotlin +// Open a video and automatically start playing (default behavior) +playerState.openUri("http://example.com/video.mp4") + +// Open a video but keep it paused initially +playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE) +``` + +To play a local video file you can use [PlatformFile](https://filekit.mintlify.app/core/platform-file) from [FileKit](https://github.com/vinceglb/FileKit). + +```kotlin +val file = FileKit.openFilePicker(type = FileKitType.Video) + +// Open a file and automatically start playing (default behavior) +file?.let { playerState.openFile(it) } + +// Open a file but keep it paused initially +file?.let { playerState.openFile(it, InitialPlayerState.PAUSE) } +``` + +The `initializeplayerState` parameter controls whether the video automatically starts playing after opening: +- `InitialPlayerState.PLAY` (default): The video will automatically start playing after opening +- `InitialPlayerState.PAUSE`: The video will be loaded but remain paused until you call `play()` + +Check the [sample project](sample/composeApp/src/commonMain/kotlin/sample/app/App.kt) for a complete example. + +### Full Controls + +- **Play and Pause**: + +You can detect the current playback state via `playerState.isPlaying` and configure a Play/Pause button as follows: + +```kotlin +Button(onClick = { + if (playerState.isPlaying) { + playerState.pause() + println("Playback paused") + } else { + playerState.play() + println("Playback started") + } +}) { + Text(if (playerState.isPlaying) "Pause" else "Play") +} +``` + +- **Stop**: + +```kotlin +playerState.stop() +println("Playback stopped") +``` + +- **Volume**: + +```kotlin +playerState.volume = 0.5f // Set volume to 50% +println("Volume set to 50%") +``` + +- **Loop Playback**: + +```kotlin +playerState.loop = true // Enable loop playback +println("Loop playback enabled") +``` + +- **Playback Speed**: + +```kotlin +playerState.playbackSpeed = 1.5f // Set playback speed to 1.5x +println("Playback speed set to 1.5x") +``` + +You can adjust the playback speed between 0.5x (slower) and 2.0x (faster). The default value is 1.0x (normal speed). + +### Progress Indicators + +To display and control playback progress: + +```kotlin +Slider( + value = playerState.sliderPos, + onValueChange = { + playerState.sliderPos = it + playerState.userDragging = true + println("Position changed: $it") + }, + onValueChangeFinished = { + playerState.userDragging = false + playerState.seekTo(playerState.sliderPos) + println("Position finalized: ${playerState.sliderPos}") + }, + valueRange = 0f..1000f +) +``` + +### Display Left and Right Volume Levels + +To display audio levels: + +```kotlin +println("Left level: ${playerState.leftLevel.toInt()}%, Right level: ${playerState.rightLevel.toInt()}%") +``` + +> [!IMPORTANT] +> This feature is not working on iOS. + +### Error Handling + +In case of an error, you can display it using `println`: + +```kotlin +playerState.error?.let { error -> + println("Error detected: ${error.message}") + playerState.clearError() +} +``` + +### Loading Indicator + +To detect if the video is buffering: + +```kotlin +if (playerState.isLoading) { + CircularProgressIndicator() +} +``` + +### Using Subtitles + +Compose Media Player supports adding subtitles from both URLs and local files. Subtitles are now rendered using Compose, providing a uniform appearance across all platforms. + +#### Supported Formats + +The player supports both SRT and VTT subtitle formats with automatic format detection. + +#### Adding Subtitles from URL or Local File + +You can add subtitles by specifying a URL: + +```kotlin +val track = SubtitleTrack( + label = "English Subtitles", + language = "en", + src = "https://example.com/subtitles.vtt" // Works with both .srt and .vtt files +) +playerState.selectSubtitleTrack(track) +``` + +#### Customizing Subtitle Appearance + +You can customize the appearance of subtitles using the following properties: + +```kotlin +// Customize subtitle text style +playerState.subtitleTextStyle = TextStyle( + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center +) + +// Customize subtitle background color +playerState.subtitleBackgroundColor = Color.Black.copy(alpha = 0.7f) +``` + +#### Disabling Subtitles + +To disable subtitles: + +```kotlin +playerState.disableSubtitles() +``` + +### Fullscreen Mode + +> [!WARNING] +> Fullscreen support is experimental. The behavior may vary across different platforms. + +You can toggle between windowed and fullscreen modes using the `toggleFullscreen()` method: + +```kotlin +// Toggle fullscreen mode +playerState.toggleFullscreen() + +// Check current fullscreen state +if (playerState.isFullscreen) { + println("Player is in fullscreen mode") +} else { + println("Player is in windowed mode") +} +``` + +The player doesn't display any UI by default in fullscreen mode - you need to create your own custom UI using the `overlay` parameter of `VideoPlayerSurface`. The overlay will be displayed even in fullscreen mode, and you can customize it based on the fullscreen state: + +```kotlin +VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize(), + overlay = { + Box(modifier = Modifier.fillMaxSize()) { + // Customize UI based on fullscreen state + if (playerState.isFullscreen) { + // Fullscreen UI + // ... + } else { + // Regular UI + // ... + } + } + } +) +``` + +See the "Custom Overlay UI" section under "Displaying the Video Surface" for a complete example. + +## Metadata Support + +> [!WARNING] +> Metadata support is experimental. There may be inconsistencies between platforms, and on WASM it's currently limited to width and height only. + +The player can extract the following metadata: +- Title +- Duration (in milliseconds) +- Video resolution (width and height) +- Bitrate (in bits per second) +- Frame rate +- MIME type +- Audio channels +- Audio sample rate + +### Example Usage + +You can access video metadata through the `metadata` property of the player state: + +```kotlin +// Access metadata after loading a video +playerState.openUri("http://example.com/video.mp4") // Auto-plays by default +// Or load without auto-playing: +// playerState.openUri("http://example.com/video.mp4", InitialPlayerState.PAUSE) + +// Display metadata information +val metadata = playerState.metadata + +println("Video Metadata:") +metadata.title?.let { println("Title: $it") } +metadata.duration?.let { println("Duration: ${it}ms") } +metadata.width?.let { width -> + metadata.height?.let { height -> + println("Resolution: ${width}x${height}") + } +} +metadata.bitrate?.let { println("Bitrate: ${it}bps") } +metadata.frameRate?.let { println("Frame Rate: ${it}fps") } +metadata.mimeType?.let { println("MIME Type: $it") } +metadata.audioChannels?.let { println("Audio Channels: $it") } +metadata.audioSampleRate?.let { println("Audio Sample Rate: ${it}Hz") } + +``` + +### Basic Example + +Here is a minimal example of how to integrate the Compose Media Player into your Compose application with a hardcoded URL: + +```kotlin +@Composable +fun App() { + val playerState = rememberVideoPlayerState() + + MaterialTheme { + Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { + + // Video Surface + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + VideoPlayerSurface( + playerState = playerState, + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Playback Controls + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Button(onClick = { playerState.play() }) { Text("Play") } + Button(onClick = { playerState.pause() }) { Text("Pause") } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Open Video URL buttons + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { + val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + playerState.openUri(url) // Default: auto-play + } + ) { + Text("Play Video") + } + + Button( + onClick = { + val url = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + playerState.openUri(url, InitialPlayerState.PAUSE) // Open paused + } + ) { + Text("Load Video Paused") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Volume Control + Text("Volume: ${(playerState.volume * 100).toInt()}%") + Slider( + value = playerState.volume, + onValueChange = { playerState.volume = it }, + valueRange = 0f..1f + ) + } + } +} +``` + +## License + +Compose Media Player is licensed under the MIT License. See [LICENSE](LICENSE) for details. + +## Roadmap + +- **Player with Separate Audio and Video Streams**: Add functionality to support different audio and video streams for advanced playback scenarios. + +## Applications Using This Library + +- [Pushscroll](https://pushscroll.com) - Pushscroll: Screen-Time Gym +- [Pixelix](https://github.com/ghostbyte-dev/pixelix) - Pixelfed client for Android and iOS + +If you're using this library in your project, please let us know and we'll add it to this list! + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=kdroidfilter/ComposeMediaPlayer&type=Date)](https://www.star-history.com/#kdroidfilter/ComposeMediaPlayer&Date) diff --git a/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt b/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.android.kt similarity index 86% rename from audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt rename to audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.android.kt index 769bc2c7..94d00d30 100644 --- a/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.android.kt +++ b/audioplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.android.kt @@ -16,7 +16,7 @@ import androidx.media3.exoplayer.ExoPlayer import com.kdroid.androidcontextprovider.ContextProvider @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class ComposeAudioPlayer actual constructor() { +actual class AudioPlayer actual constructor() { private var mediaPlayer = ExoPlayer.Builder(ContextProvider.getContext()).build() private var errorListener: ErrorListener? = null @@ -42,7 +42,7 @@ actual class ComposeAudioPlayer actual constructor() { @OptIn(UnstableApi::class) actual fun play() { if(mediaPlayer.isCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { - if (currentPlayerState() == ComposeAudioPlayerState.IDLE) + if (currentPlayerState() == AudioPlayerState.IDLE) seekTo(0) mediaPlayer.play() } @@ -75,7 +75,7 @@ actual class ComposeAudioPlayer actual constructor() { } @UnstableApi - actual fun currentPlayerState(): ComposeAudioPlayerState? { + actual fun currentPlayerState(): AudioPlayerState? { if (mediaPlayer.isReleased) { return null } @@ -83,12 +83,12 @@ actual class ComposeAudioPlayer actual constructor() { val state = mediaPlayer.playbackState val playWhenReady = mediaPlayer.playWhenReady return when { - state == Player.STATE_READY && playWhenReady -> ComposeAudioPlayerState.PLAYING - state == Player.STATE_READY && !playWhenReady -> ComposeAudioPlayerState.PAUSED - state == Player.STATE_BUFFERING -> ComposeAudioPlayerState.BUFFERING - state == Player.STATE_IDLE -> ComposeAudioPlayerState.IDLE - state == Player.STATE_ENDED -> ComposeAudioPlayerState.IDLE - else -> ComposeAudioPlayerState.IDLE + state == Player.STATE_READY && playWhenReady -> AudioPlayerState.PLAYING + state == Player.STATE_READY && !playWhenReady -> AudioPlayerState.PAUSED + state == Player.STATE_BUFFERING -> AudioPlayerState.BUFFERING + state == Player.STATE_IDLE -> AudioPlayerState.IDLE + state == Player.STATE_ENDED -> AudioPlayerState.IDLE + else -> AudioPlayerState.IDLE } } diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt similarity index 89% rename from audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt rename to audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt index aae5a247..c7b8fda4 100644 --- a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt @@ -21,7 +21,7 @@ player.release() ``` */ @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -expect class ComposeAudioPlayer() { +expect class AudioPlayer() { /** * Start playback of the audio resource at the provided [url]. * @@ -92,7 +92,7 @@ expect class ComposeAudioPlayer() { * * @return The current state like [PLAYING], [BUFFERING], [IDLE], [PAUASED]. */ - fun currentPlayerState(): ComposeAudioPlayerState? + fun currentPlayerState(): AudioPlayerState? /** * Retrieves the current volume level of the player. @@ -141,12 +141,12 @@ expect class ComposeAudioPlayer() { * * @return `true` if the player is in the PLAYING or BUFFERING state, otherwise `false`. */ -fun ComposeAudioPlayer.isPlaying(): Boolean = currentPlayerState() in listOf(ComposeAudioPlayerState.PLAYING, ComposeAudioPlayerState.BUFFERING) +fun AudioPlayer.isPlaying(): Boolean = currentPlayerState() in listOf(AudioPlayerState.PLAYING, AudioPlayerState.BUFFERING) /** * Creates and remembers an instance of [ComposeAudioPlayerLiveState] that monitors and updates the state, - * volume, position, and duration of a [ComposeAudioPlayer]. The player state is updated periodically + * volume, position, and duration of a [AudioPlayer]. The player state is updated periodically * and cleans up resources when no longer needed. * * @return A [ComposeAudioPlayerLiveState] object containing the player instance, its current state, volume, @@ -154,8 +154,8 @@ fun ComposeAudioPlayer.isPlaying(): Boolean = currentPlayerState() in listOf(Com */ @Composable fun rememberAudioPlayerLiveState(): ComposeAudioPlayerLiveState { - val player = remember { ComposeAudioPlayer() } - var state by remember { mutableStateOf(ComposeAudioPlayerState.IDLE) } + val player = remember { AudioPlayer() } + var state by remember { mutableStateOf(AudioPlayerState.IDLE) } var volume by remember { mutableStateOf(0f) } var position by remember { mutableStateOf(0L) } var duration by remember { mutableStateOf(0L) } @@ -163,7 +163,7 @@ fun rememberAudioPlayerLiveState(): ComposeAudioPlayerLiveState { LaunchedEffect(Unit) { while (true) { // Fetch or update the data - state = player.currentPlayerState() ?: ComposeAudioPlayerState.IDLE + state = player.currentPlayerState() ?: AudioPlayerState.IDLE volume = player.currentVolume() ?: 0f position = player.currentPosition() ?: 0L duration = player.currentDuration() ?: 0L @@ -188,8 +188,8 @@ fun rememberAudioPlayerLiveState(): ComposeAudioPlayerLiveState { data class ComposeAudioPlayerLiveState( - val player: ComposeAudioPlayer, - val state: ComposeAudioPlayerState, + val player: AudioPlayer, + val state: AudioPlayerState, val volume: Float, val position: Long, val duration: Long diff --git a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayerState.kt similarity index 87% rename from audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt rename to audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayerState.kt index fcd6eb01..02082060 100644 --- a/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayerState.kt +++ b/audioplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayerState.kt @@ -5,7 +5,7 @@ package io.github.kdroidfilter.composemediaplayer.audio -enum class ComposeAudioPlayerState { +enum class AudioPlayerState { PLAYING, PAUSED, BUFFERING, diff --git a/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt b/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.ios.kt similarity index 91% rename from audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt rename to audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.ios.kt index 1d687cd1..10fb6955 100644 --- a/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.ios.kt +++ b/audioplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.ios.kt @@ -19,7 +19,7 @@ import platform.Foundation.NSURL import platform.darwin.NSEC_PER_SEC @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") -actual class ComposeAudioPlayer actual constructor() { +actual class AudioPlayer actual constructor() { private var player: AVPlayer? = null private var playerObserver: CupertinoAVPlayerObserver? = null private var errorListener: ErrorListener? = null @@ -48,7 +48,7 @@ actual class ComposeAudioPlayer actual constructor() { actual fun play() { // https://developer.apple.com/documentation/avfoundation/avplayer/play() if (player?.currentItem != null) { - if (currentPlayerState() == ComposeAudioPlayerState.IDLE) + if (currentPlayerState() == AudioPlayerState.IDLE) seekTo(0) player?.play() lastVolume?.let { player?.volume = it } @@ -67,7 +67,7 @@ actual class ComposeAudioPlayer actual constructor() { actual fun stop() { player?.pause() player?.replaceCurrentItemWithPlayerItem(null) - _state = ComposeAudioPlayerState.IDLE + _state = AudioPlayerState.IDLE } actual fun pause() { @@ -129,11 +129,11 @@ actual class ComposeAudioPlayer actual constructor() { player?.seekToTime(duration) } - actual fun currentPlayerState(): ComposeAudioPlayerState? { + actual fun currentPlayerState(): AudioPlayerState? { return _state } - private var _state: ComposeAudioPlayerState? = null + private var _state: AudioPlayerState? = null private fun setup() { @@ -151,18 +151,18 @@ actual class ComposeAudioPlayer actual constructor() { val waitingToPlayAtRate = player?.timeControlStatus == AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate _state = when { - rate > 0 -> ComposeAudioPlayerState.PLAYING - !hasItem -> ComposeAudioPlayerState.IDLE - avStatus == AVPlayerItemStatusFailed -> ComposeAudioPlayerState.IDLE - !bufferingEnding || bufferIsEmpty || waitingToPlayAtRate -> ComposeAudioPlayerState.BUFFERING - else -> ComposeAudioPlayerState.PAUSED + rate > 0 -> AudioPlayerState.PLAYING + !hasItem -> AudioPlayerState.IDLE + avStatus == AVPlayerItemStatusFailed -> AudioPlayerState.IDLE + !bufferingEnding || bufferIsEmpty || waitingToPlayAtRate -> AudioPlayerState.BUFFERING + else -> AudioPlayerState.PAUSED } }, onAVPlayerEnded = { - _state = ComposeAudioPlayerState.IDLE + _state = AudioPlayerState.IDLE }, onAVPlayerStalled = { - _state = ComposeAudioPlayerState.BUFFERING + _state = AudioPlayerState.BUFFERING }, onAVPlayerError = { errorListener?.onError(it) diff --git a/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt similarity index 82% rename from audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt rename to audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt index 9b33116c..4adba1c1 100644 --- a/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.kt +++ b/audioplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.kt @@ -12,12 +12,12 @@ import java.net.URI import java.nio.file.Paths @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) -actual class ComposeAudioPlayer actual constructor() { +actual class AudioPlayer actual constructor() { private var player: RodioPlayer? = RodioPlayer() private var errorListener: ErrorListener? = null private var lastVolume: Float? = null @Volatile - private var state: ComposeAudioPlayerState? = ComposeAudioPlayerState.IDLE + private var state: AudioPlayerState? = AudioPlayerState.IDLE private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var playbackJob: Job? = null @@ -25,10 +25,10 @@ actual class ComposeAudioPlayer actual constructor() { private val callback = object : PlaybackCallback { override fun onEvent(event: PlaybackEvent) { state = when (event) { - PlaybackEvent.CONNECTING -> ComposeAudioPlayerState.BUFFERING - PlaybackEvent.PLAYING -> ComposeAudioPlayerState.PLAYING - PlaybackEvent.PAUSED -> ComposeAudioPlayerState.PAUSED - PlaybackEvent.STOPPED -> ComposeAudioPlayerState.IDLE + PlaybackEvent.CONNECTING -> AudioPlayerState.BUFFERING + PlaybackEvent.PLAYING -> AudioPlayerState.PLAYING + PlaybackEvent.PAUSED -> AudioPlayerState.PAUSED + PlaybackEvent.STOPPED -> AudioPlayerState.IDLE } } @@ -36,7 +36,7 @@ actual class ComposeAudioPlayer actual constructor() { } override fun onError(message: String) { - state = ComposeAudioPlayerState.IDLE + state = AudioPlayerState.IDLE errorListener?.onError(message) } } @@ -57,7 +57,7 @@ actual class ComposeAudioPlayer actual constructor() { } lastVolume?.let { localPlayer.setVolume(it) } }.onFailure { error -> - state = ComposeAudioPlayerState.IDLE + state = AudioPlayerState.IDLE errorListener?.onError(error.message) } } @@ -66,7 +66,7 @@ actual class ComposeAudioPlayer actual constructor() { actual fun play() { val localPlayer = player ?: return val current = currentPlayerState() - if (current == null || current == ComposeAudioPlayerState.IDLE) return + if (current == null || current == AudioPlayerState.IDLE) return runCatching { localPlayer.play() } .onFailure { errorListener?.onError(it.message) } } @@ -75,14 +75,14 @@ actual class ComposeAudioPlayer actual constructor() { val localPlayer = player ?: return runCatching { localPlayer.stop() } .onFailure { errorListener?.onError(it.message) } - state = ComposeAudioPlayerState.IDLE + state = AudioPlayerState.IDLE } actual fun pause() { val localPlayer = player ?: return runCatching { localPlayer.pause() } .onFailure { errorListener?.onError(it.message) } - state = ComposeAudioPlayerState.PAUSED + state = AudioPlayerState.PAUSED } actual fun release() { @@ -107,15 +107,15 @@ actual class ComposeAudioPlayer actual constructor() { return runCatching { localPlayer.getDurationMs() }.getOrNull() } - actual fun currentPlayerState(): ComposeAudioPlayerState? { + actual fun currentPlayerState(): AudioPlayerState? { val localPlayer = player ?: return null val snapshot = state ?: return null - if (snapshot == ComposeAudioPlayerState.BUFFERING) return snapshot + if (snapshot == AudioPlayerState.BUFFERING) return snapshot val isEmpty = runCatching { localPlayer.isEmpty() }.getOrDefault(true) - if (isEmpty) return ComposeAudioPlayerState.IDLE + if (isEmpty) return AudioPlayerState.IDLE val isPaused = runCatching { localPlayer.isPaused() }.getOrDefault(false) - if (isPaused) return ComposeAudioPlayerState.PAUSED - return ComposeAudioPlayerState.PLAYING + if (isPaused) return AudioPlayerState.PAUSED + return AudioPlayerState.PLAYING } actual fun currentVolume(): Float? { @@ -152,7 +152,7 @@ actual class ComposeAudioPlayer actual constructor() { runCatching { newPlayer.setVolume(volume) } } player = newPlayer - state = ComposeAudioPlayerState.IDLE + state = AudioPlayerState.IDLE return newPlayer } diff --git a/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt b/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.web.kt similarity index 86% rename from audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt rename to audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.web.kt index 090be5d6..9663dae4 100644 --- a/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/ComposeAudioPlayer.web.kt +++ b/audioplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/audio/AudioPlayer.web.kt @@ -15,10 +15,10 @@ import kotlin.uuid.Uuid @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @OptIn(ExperimentalUuidApi::class) -actual class ComposeAudioPlayer actual constructor() { +actual class AudioPlayer actual constructor() { private val htmlId = Uuid.random().toString() - private var _state: ComposeAudioPlayerState? = ComposeAudioPlayerState.IDLE + private var _state: AudioPlayerState? = AudioPlayerState.IDLE private val events = mutableListOf<() -> Unit>() private var lastVolume: Double? = null private var lastRate: Double? = null @@ -27,12 +27,12 @@ actual class ComposeAudioPlayer actual constructor() { private fun attachEventListeners(el: HTMLAudioElement) { detachEventListeners() - val onPlaying: (Event) -> Unit = { _state = ComposeAudioPlayerState.PLAYING } - val onPlay: (Event) -> Unit = { _state = ComposeAudioPlayerState.PLAYING } - val onPause: (Event) -> Unit = { _state = ComposeAudioPlayerState.PAUSED } - val onEnded: (Event) -> Unit = { _state = ComposeAudioPlayerState.IDLE } - val onWaiting: (Event) -> Unit = { _state = ComposeAudioPlayerState.BUFFERING } - val onStalled: (Event) -> Unit = { _state = ComposeAudioPlayerState.BUFFERING } + val onPlaying: (Event) -> Unit = { _state = AudioPlayerState.PLAYING } + val onPlay: (Event) -> Unit = { _state = AudioPlayerState.PLAYING } + val onPause: (Event) -> Unit = { _state = AudioPlayerState.PAUSED } + val onEnded: (Event) -> Unit = { _state = AudioPlayerState.IDLE } + val onWaiting: (Event) -> Unit = { _state = AudioPlayerState.BUFFERING } + val onStalled: (Event) -> Unit = { _state = AudioPlayerState.BUFFERING } val onError: (Event) -> Unit = { errorListener?.onError(null) } el.addEventListener("playing", onPlaying) @@ -82,12 +82,12 @@ actual class ComposeAudioPlayer actual constructor() { actual fun stop() { getPlayerElement()?.pause() getPlayerElement()?.currentTime = 0.0 - _state = ComposeAudioPlayerState.IDLE + _state = AudioPlayerState.IDLE } actual fun pause() { getPlayerElement()?.pause() - _state = ComposeAudioPlayerState.PAUSED + _state = AudioPlayerState.PAUSED } /** @@ -123,7 +123,7 @@ actual class ComposeAudioPlayer actual constructor() { return null } - actual fun currentPlayerState(): ComposeAudioPlayerState? { + actual fun currentPlayerState(): AudioPlayerState? { // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio#events return _state } diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt index a7fcc167..28e9a5bb 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/AudioPlayerScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.unit.dp -import io.github.kdroidfilter.composemediaplayer.audio.ComposeAudioPlayerState +import io.github.kdroidfilter.composemediaplayer.audio.AudioPlayerState import io.github.kdroidfilter.composemediaplayer.audio.ErrorListener import io.github.kdroidfilter.composemediaplayer.audio.rememberAudioPlayerLiveState import io.github.kdroidfilter.composemediaplayer.util.getUri @@ -122,7 +122,7 @@ private fun AudioPlayerScreenCore() { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { - val isIdle = audioState.state == ComposeAudioPlayerState.IDLE + val isIdle = audioState.state == AudioPlayerState.IDLE when (selectedSource) { AudioSource.Local -> { val uri = selectedFile?.getUri() @@ -152,13 +152,13 @@ private fun AudioPlayerScreenCore() { } Button( onClick = { audioState.player.pause() }, - enabled = audioState.state == ComposeAudioPlayerState.PLAYING + enabled = audioState.state == AudioPlayerState.PLAYING ) { Text("Pause") } Button( onClick = { audioState.player.stop() }, - enabled = audioState.state != ComposeAudioPlayerState.IDLE + enabled = audioState.state != AudioPlayerState.IDLE ) { Text("Stop") }