From b8fcdafcab609006535708dd3c504f3acbe98c1f Mon Sep 17 00:00:00 2001 From: DUONG Phu-Hiep Date: Tue, 23 Dec 2025 21:54:48 +0100 Subject: [PATCH 1/3] turn VideoPlayerState to interface in order to mock it in Preview (or in test) --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 4 +- mediaplayer/build.gradle.kts | 2 +- .../VideoPlayerState.android.kt | 308 +++++++----------- .../VideoPlayerSurface.android.kt | 42 ++- .../VideoPlayerStatePreviewTest.kt | 2 +- .../VideoPlayerStateTest.kt | 18 +- .../composemediaplayer/VideoPlayerState.kt | 123 ++++++- .../VideoPlayerState.ios.kt | 80 +++-- .../VideoPlayerSurface.ios.kt | 22 +- .../PlatformVideoPlayerState.kt | 69 ---- .../VideoPlayerState.jvm.kt | 198 +++-------- .../composemediaplayer/VideoPlayerSurface.kt | 19 +- .../common/FullscreenVideoPlayerWindow.kt | 16 +- .../linux/LinuxVideoPlayerState.kt | 24 +- .../mac/MacVideoPlayerState.kt | 12 +- .../util/VideoPlayerStateRegistry.kt | 8 +- .../windows/WindowsVideoPlayerState.kt | 30 +- .../linux/LinuxVideoPlayerStateTest.kt | 18 +- .../mac/MacVideoPlayerStateTest.kt | 2 +- .../windows/WindowsVideoPlayerStateTest.kt | 9 +- .../VideoPlayerState.web.kt | 84 ++--- .../VideoPlayerSurfaceImpl.kt | 155 +++++---- sample/composeApp/build.gradle.kts | 7 +- .../app/singleplayer/SinglePlayerScreen.kt | 49 ++- 25 files changed, 634 insertions(+), 669 deletions(-) delete mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PlatformVideoPlayerState.kt diff --git a/build.gradle.kts b/build.gradle.kts index 02e5e0c2..144d22aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.multiplatform).apply(false) alias(libs.plugins.android.library).apply(false) alias(libs.plugins.android.application).apply(false) - alias(libs.plugins.kotlinCocoapods) apply false + alias(libs.plugins.kotlinCocoapods).apply(false) alias(libs.plugins.dokka).apply(false) alias(libs.plugins.vannitktech.maven.publish).apply(false) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19525e56..781d7418 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.12.3" +agp = "8.13.2" kotlinx-coroutines = "1.10.2" kotlinxBrowserWasmJs = "0.5.0" kotlinxDatetime = "0.7.1-0.6.x-compat" @@ -18,6 +18,8 @@ jna = "5.18.1" platformtoolsDarkmodedetector = "0.7.4" 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 +android-minSdk="23" [libraries] diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index c0b7697f..dd697a02 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -148,7 +148,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 23 + minSdk = libs.versions.android.minSdk.get().toInt() } } diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index c1fe610c..422c3498 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -34,6 +34,9 @@ import io.github.vinceglb.filekit.AndroidFile import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* +@OptIn(UnstableApi::class) +actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() + /** * Logger for WebAssembly video player surface */ @@ -42,11 +45,8 @@ internal val androidVideoLogger = Logger.withTag("AndroidVideoPlayerSurface") @UnstableApi @Stable -actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { - actual constructor() : this(false) - - private var appContext: Context? = null - internal var previewMode: Boolean = isInPreview +open class DefaultVideoPlayerState: VideoPlayerState { + private val context: Context = ContextProvider.getContext() internal var exoPlayer: ExoPlayer? = null private var updateJob: Job? = null private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -62,26 +62,26 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { private var wasPlayingBeforeScreenLock: Boolean = false private var _hasMedia by mutableStateOf(false) - actual val hasMedia: Boolean get() = _hasMedia + override val hasMedia: Boolean get() = _hasMedia // State properties private var _isPlaying by mutableStateOf(false) - actual val isPlaying: Boolean get() = _isPlaying + override val isPlaying: Boolean get() = _isPlaying private var _isLoading by mutableStateOf(false) - actual val isLoading: Boolean get() = _isLoading + override val isLoading: Boolean get() = _isLoading private var _error by mutableStateOf(null) - actual val error: VideoPlayerError? get() = _error + override val error: VideoPlayerError? get() = _error private var _metadata = VideoMetadata() - actual val metadata: VideoMetadata get() = _metadata + override val metadata: VideoMetadata get() = _metadata // Subtitle state - actual var subtitlesEnabled by mutableStateOf(false) - actual var currentSubtitleTrack by mutableStateOf(null) - actual val availableSubtitleTracks = mutableListOf() - actual var subtitleTextStyle by mutableStateOf( + override var subtitlesEnabled by mutableStateOf(false) + override var currentSubtitleTrack by mutableStateOf(null) + override val availableSubtitleTracks = mutableListOf() + override var subtitleTextStyle by mutableStateOf( TextStyle( color = Color.White, fontSize = 18.sp, @@ -90,12 +90,12 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { ) ) - actual var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f)) + override var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f)) private var playerView: PlayerView? = null // Select an external subtitle track - actual fun selectSubtitleTrack(track: SubtitleTrack?) { + override fun selectSubtitleTrack(track: SubtitleTrack?) { if (track == null) { disableSubtitles() return @@ -114,7 +114,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } - actual fun disableSubtitles() { + override fun disableSubtitles() { currentSubtitleTrack = null subtitlesEnabled = false @@ -150,7 +150,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { // Volume control private var _volume by mutableFloatStateOf(1f) - actual var volume: Float + override var volume: Float get() = _volume set(value) { _volume = value.coerceIn(0f, 1f) @@ -159,7 +159,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { // Slider position private var _sliderPos by mutableFloatStateOf(0f) - actual var sliderPos: Float + override var sliderPos: Float get() = _sliderPos set(value) { _sliderPos = value.coerceIn(0f, 1000f) @@ -169,11 +169,11 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } // User interaction states - actual var userDragging by mutableStateOf(false) + override var userDragging by mutableStateOf(false) // Loop control private var _loop by mutableStateOf(false) - actual var loop: Boolean + override var loop: Boolean get() = _loop set(value) { _loop = value @@ -182,7 +182,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { // Playback speed control private var _playbackSpeed by mutableFloatStateOf(1.0f) - actual var playbackSpeed: Float + override var playbackSpeed: Float get() = _playbackSpeed set(value) { _playbackSpeed = value.coerceIn(0.5f, 2.0f) @@ -194,16 +194,16 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { // Audio levels private var _leftLevel by mutableFloatStateOf(0f) private var _rightLevel by mutableFloatStateOf(0f) - actual val leftLevel: Float get() = _leftLevel - actual val rightLevel: Float get() = _rightLevel + override val leftLevel: Float get() = _leftLevel + override val rightLevel: Float get() = _rightLevel // Aspect ratio private var _aspectRatio by mutableFloatStateOf(16f / 9f) - actual val aspectRatio: Float get() = _aspectRatio + override val aspectRatio: Float get() = _aspectRatio // Fullscreen state private var _isFullscreen by mutableStateOf(false) - actual var isFullscreen: Boolean + override var isFullscreen: Boolean get() = _isFullscreen set(value) { _isFullscreen = value @@ -212,35 +212,18 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { // Time tracking private var _currentTime by mutableDoubleStateOf(0.0) private var _duration by mutableDoubleStateOf(0.0) - actual val positionText: String get() = formatTime(_currentTime) - actual val durationText: String get() = formatTime(_duration) - actual val currentTime: Double get() = _currentTime + override val positionText: String get() = formatTime(_currentTime) + override val durationText: String get() = formatTime(_duration) + override val currentTime: Double get() = _currentTime init { - if (!previewMode) { - audioProcessor.setOnAudioLevelUpdateListener { left, right -> - _leftLevel = left - _rightLevel = right - } - ensureInitialized() - } - } - - private fun ensureInitialized(): Boolean { - synchronized(playerInitializationLock) { - if (isPlayerReleased) return false - if (exoPlayer != null) return true - - val context = appContext ?: runCatching { ContextProvider.getContext().applicationContext } - .getOrNull() - ?: return false - - appContext = context - initializePlayer(context) - registerScreenLockReceiver(context) - return exoPlayer != null + audioProcessor.setOnAudioLevelUpdateListener { left, right -> + _leftLevel = left + _rightLevel = right } + initializePlayer() + registerScreenLockReceiver() } private fun shouldUseConservativeCodecHandling(): Boolean { @@ -260,8 +243,8 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { manufacturer.equals("mediatek", ignoreCase = true) } - private fun registerScreenLockReceiver(context: Context) { - unregisterScreenLockReceiver(context) + private fun registerScreenLockReceiver() { + unregisterScreenLockReceiver() screenLockReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -309,16 +292,11 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_SCREEN_ON) } - try { - context.registerReceiver(screenLockReceiver, filter) - androidVideoLogger.d { "Screen lock receiver registered" } - } catch (e: Exception) { - androidVideoLogger.e { "Error registering screen lock receiver: ${e.message}" } - screenLockReceiver = null - } + context.registerReceiver(screenLockReceiver, filter) + androidVideoLogger.d { "Screen lock receiver registered" } } - private fun unregisterScreenLockReceiver(context: Context) { + private fun unregisterScreenLockReceiver() { screenLockReceiver?.let { try { context.unregisterReceiver(it) @@ -330,50 +308,45 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } - private fun initializePlayer(context: Context) { + private fun initializePlayer() { synchronized(playerInitializationLock) { - if (isPlayerReleased || exoPlayer != null) return + if (isPlayerReleased) return - try { - val audioSink = DefaultAudioSink.Builder(context) - .setAudioProcessors(arrayOf(audioProcessor)) - .build() - - val renderersFactory = object : DefaultRenderersFactory(context) { - override fun buildAudioSink( - context: Context, - enableFloatOutput: Boolean, - enableAudioTrackPlaybackParams: Boolean - ): AudioSink = audioSink - }.apply { - setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) - // Activer le fallback du décodeur pour une meilleure stabilité - setEnableDecoderFallback(true) - - // Sur les appareils problématiques, utiliser des paramètres plus conservateurs - if (shouldUseConservativeCodecHandling()) { - // On ne peut pas désactiver l'async queueing car la méthode n'existe pas - // Mais on peut utiliser le MediaCodecSelector par défaut - setMediaCodecSelector(MediaCodecSelector.DEFAULT) - } - } + val audioSink = DefaultAudioSink.Builder(context) + .setAudioProcessors(arrayOf(audioProcessor)) + .build() - exoPlayer = ExoPlayer.Builder(context) - .setRenderersFactory(renderersFactory) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_LOCAL) - .setPauseAtEndOfMediaItems(false) - .setReleaseTimeoutMs(2000) // Augmenter le timeout de libération - .build() - .apply { - playerListener = createPlayerListener() - addListener(playerListener!!) - volume = _volume - } - } catch (e: Exception) { - androidVideoLogger.e { "Error initializing player: ${e.message}" } - exoPlayer = null + val renderersFactory = object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink = audioSink + }.apply { + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) + // Activer le fallback du décodeur pour une meilleure stabilité + setEnableDecoderFallback(true) + + // Sur les appareils problématiques, utiliser des paramètres plus conservateurs + if (shouldUseConservativeCodecHandling()) { + // On ne peut pas désactiver l'async queueing car la méthode n'existe pas + // Mais on peut utiliser le MediaCodecSelector par défaut + setMediaCodecSelector(MediaCodecSelector.DEFAULT) + } } + + exoPlayer = ExoPlayer.Builder(context) + .setRenderersFactory(renderersFactory) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_LOCAL) + .setPauseAtEndOfMediaItems(false) + .setReleaseTimeoutMs(2000) // Augmenter le timeout de libération + .build() + .apply { + playerListener = createPlayerListener() + addListener(playerListener!!) + volume = _volume + } } } @@ -491,12 +464,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { player.release() // Réinitialiser - exoPlayer = null - playerListener = null - appContext?.let { context -> - initializePlayer(context) - registerScreenLockReceiver(context) - } + initializePlayer() // Restaurer l'élément média et la position currentMediaItem?.let { @@ -544,33 +512,13 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { updateJob = null } - actual fun openUri(uri: String, initializeplayerState: InitialPlayerState) { - if (previewMode) { - _error = null - _hasMedia = true - _isPlaying = initializeplayerState == InitialPlayerState.PLAY - return - } - if (!ensureInitialized()) { - _error = VideoPlayerError.UnknownError("Android context is not available (preview or missing ContextProvider initialization).") - return - } + override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { val mediaItemBuilder = MediaItem.Builder().setUri(uri) val mediaItem = mediaItemBuilder.build() openFromMediaItem(mediaItem, initializeplayerState) } - actual fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { - if (previewMode) { - _error = null - _hasMedia = true - _isPlaying = initializeplayerState == InitialPlayerState.PLAY - return - } - if (!ensureInitialized()) { - _error = VideoPlayerError.UnknownError("Android context is not available (preview or missing ContextProvider initialization).") - return - } + override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { val mediaItemBuilder = MediaItem.Builder() val videoUri: Uri = when (val androidFile = file.androidFile) { is AndroidFile.UriWrapper -> androidFile.uri @@ -585,55 +533,43 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { synchronized(playerInitializationLock) { if (isPlayerReleased) return - val player = exoPlayer ?: run { - _isPlaying = false - _hasMedia = false - _error = VideoPlayerError.UnknownError("Video player is not initialized.") - return - } - - player.stop() - player.clearMediaItems() - try { - _error = null - resetStates(keepMedia = true) - - // Extraire les métadonnées avant de préparer le lecteur - extractMediaItemMetadata(mediaItem) - - player.setMediaItem(mediaItem) - player.prepare() - player.volume = volume - player.repeatMode = if (loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF + exoPlayer?.let { player -> + player.stop() + player.clearMediaItems() + try { + _error = null + resetStates(keepMedia = true) - // Contrôler l'état de lecture initial - if (initializeplayerState == InitialPlayerState.PLAY) { - player.play() - _hasMedia = true - } else { - player.pause() + // Extraire les métadonnées avant de préparer le lecteur + extractMediaItemMetadata(mediaItem) + + player.setMediaItem(mediaItem) + player.prepare() + player.volume = volume + player.repeatMode = if (loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF + + // Contrôler l'état de lecture initial + if (initializeplayerState == InitialPlayerState.PLAY) { + player.play() + _hasMedia = true + } else { + player.pause() + _isPlaying = false + _hasMedia = true + } + } catch (e: Exception) { + androidVideoLogger.d { "Error opening media: ${e.message}" } _isPlaying = false - _hasMedia = true + _hasMedia = false + _error = VideoPlayerError.SourceError("Failed to load media: ${e.message}") } - } catch (e: Exception) { - androidVideoLogger.d { "Error opening media: ${e.message}" } - _isPlaying = false - _hasMedia = false - _error = VideoPlayerError.SourceError("Failed to load media: ${e.message}") } } } - actual fun play() { + override fun play() { synchronized(playerInitializationLock) { if (!isPlayerReleased) { - if (previewMode && exoPlayer == null) { - _hasMedia = true - _isPlaying = true - return - } - - ensureInitialized() exoPlayer?.let { player -> if (player.playbackState == Player.STATE_IDLE) { player.prepare() @@ -645,31 +581,17 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } - actual fun pause() { + override fun pause() { synchronized(playerInitializationLock) { if (!isPlayerReleased) { - if (previewMode && exoPlayer == null) { - _isPlaying = false - return - } - - ensureInitialized() exoPlayer?.pause() } } } - actual fun stop() { + override fun stop() { synchronized(playerInitializationLock) { if (!isPlayerReleased) { - if (previewMode && exoPlayer == null) { - _hasMedia = false - _isPlaying = false - resetStates(keepMedia = true) - return - } - - ensureInitialized() exoPlayer?.let { player -> player.stop() player.seekTo(0) @@ -680,18 +602,18 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } - actual fun seekTo(value: Float) { + override fun seekTo(value: Float) { if (_duration > 0 && !isPlayerReleased) { val targetTime = (value / 1000.0) * _duration exoPlayer?.seekTo((targetTime * 1000).toLong()) } } - actual fun clearError() { + override fun clearError() { _error = null } - actual fun toggleFullscreen() { + override fun toggleFullscreen() { _isFullscreen = !_isFullscreen } @@ -769,7 +691,7 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { } } - actual fun dispose() { + override fun dispose() { synchronized(playerInitializationLock) { isPlayerReleased = true stopPositionUpdates() @@ -793,12 +715,8 @@ actual open class VideoPlayerState internal constructor(isInPreview: Boolean) { playerListener = null exoPlayer = null - appContext?.let { unregisterScreenLockReceiver(it) } + unregisterScreenLockReceiver() resetStates() } } -} - -@OptIn(UnstableApi::class) -internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState = - VideoPlayerState(isInPreview) +} \ No newline at end of file diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt index e36d75ae..de1df643 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.android.kt @@ -7,8 +7,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -95,7 +101,8 @@ private fun VideoPlayerSurfaceInternal( onDispose { try { // Détacher la vue du player - playerState.attachPlayerView(null) + if (playerState is DefaultVideoPlayerState) + playerState.attachPlayerView(null) } catch (e: Exception) { androidVideoLogger.e { "Error detaching PlayerView on dispose: ${e.message}" } } @@ -112,7 +119,9 @@ private fun VideoPlayerSurfaceInternal( playerState.toggleFullscreen() } ) { - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + Box(modifier = Modifier + .fillMaxSize() + .background(Color.Black)) { VideoPlayerContent( playerState = playerState, modifier = Modifier.fillMaxHeight(), @@ -147,7 +156,7 @@ private fun VideoPlayerContent( modifier = modifier, contentAlignment = Alignment.Center ) { - if (playerState.hasMedia && playerState.exoPlayer != null) { + if (playerState.hasMedia) { AndroidView( modifier = contentScale.toCanvasModifier( playerState.aspectRatio, @@ -157,9 +166,18 @@ private fun VideoPlayerContent( factory = { context -> try { // Créer PlayerView avec le type de surface approprié + createPlayerViewWithSurfaceType(context, surfaceType).apply { - // Attacher le lecteur depuis l'état - player = playerState.exoPlayer + if (playerState is DefaultVideoPlayerState) { + // Attacher cette vue à l'état du lecteur + playerState.attachPlayerView(this) + + if (playerState.exoPlayer != null) { + // Attacher le lecteur depuis l'état + player = playerState.exoPlayer + } + } + useController = false defaultArtwork = null setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) @@ -171,8 +189,6 @@ private fun VideoPlayerContent( // Désactiver la vue de sous-titres native car nous utilisons des sous-titres basés sur Compose subtitleView?.visibility = android.view.View.GONE - // Attacher cette vue à l'état du lecteur - playerState.attachPlayerView(this) } } catch (e: Exception) { androidVideoLogger.e { "Error creating PlayerView: ${e.message}" } @@ -185,7 +201,7 @@ private fun VideoPlayerContent( update = { playerView -> try { // Vérifier que le player est toujours valide avant la mise à jour - if (playerState.exoPlayer != null && playerView.player != null) { + if (playerState is DefaultVideoPlayerState && playerState.exoPlayer != null && playerView.player != null) { // Mettre à jour le mode de redimensionnement lorsque contentScale change playerView.resizeMode = mapContentScaleToResizeMode(contentScale) } @@ -257,7 +273,10 @@ private fun mapContentScaleToResizeMode(contentScale: ContentScale): Int { } @OptIn(UnstableApi::class) -private fun createPlayerViewWithSurfaceType(context: Context, surfaceType: SurfaceType): PlayerView { +private fun createPlayerViewWithSurfaceType( + context: Context, + surfaceType: SurfaceType +): PlayerView { return try { // Essayer d'abord d'inflater les layouts personnalisés val layoutId = when (surfaceType) { @@ -285,6 +304,7 @@ private fun createPlayerViewWithSurfaceType(context: Context, surfaceType: Surfa } } } + SurfaceType.SurfaceView -> { // SurfaceView est le défaut androidVideoLogger.d { "Using SurfaceView" } @@ -299,7 +319,7 @@ private fun createPlayerViewWithSurfaceType(context: Context, surfaceType: Surfa } catch (e2: Exception) { androidVideoLogger.e { "Error creating PlayerView programmatically: ${e2.message}" } // Dernier recours : créer une vue vide pour éviter le crash - throw RuntimeException("Unable to create PlayerView", e2) + throw e2 } } } diff --git a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt index 177afd63..17d1c7d6 100644 --- a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt +++ b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStatePreviewTest.kt @@ -25,7 +25,7 @@ class VideoPlayerStatePreviewTest { @Test fun createStateWithoutInitializedContextProviderDoesNotThrow() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() playerState.dispose() } } diff --git a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt index 8091f27e..143dae0b 100644 --- a/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt +++ b/mediaplayer/src/androidUnitTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt @@ -1,11 +1,11 @@ package io.github.kdroidfilter.composemediaplayer +import com.kdroid.androidcontextprovider.ContextProvider import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.test.assertFalse -import com.kdroid.androidcontextprovider.ContextProvider /** * Tests for the Android implementation of VideoPlayerState @@ -39,7 +39,7 @@ class VideoPlayerStateTest { fun testCreateVideoPlayerState() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify the player state is initialized correctly assertNotNull(playerState) @@ -65,7 +65,7 @@ class VideoPlayerStateTest { fun testVolumeControl() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial volume assertEquals(1f, playerState.volume) @@ -92,7 +92,7 @@ class VideoPlayerStateTest { fun testLoopSetting() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial loop setting assertFalse(playerState.loop) @@ -115,7 +115,7 @@ class VideoPlayerStateTest { fun testFullscreenToggle() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial fullscreen state assertFalse(playerState.isFullscreen) @@ -138,7 +138,7 @@ class VideoPlayerStateTest { fun testErrorHandling() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Initially there should be no error assertEquals(null, playerState.error) @@ -161,7 +161,7 @@ class VideoPlayerStateTest { fun testSubtitleFunctionality() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Initially subtitles should be disabled assertFalse(playerState.subtitlesEnabled) @@ -200,7 +200,7 @@ class VideoPlayerStateTest { fun testMetadataFunctionality() { if (skipIfContextProviderNotAvailable()) return - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify metadata object is initialized assertNotNull(playerState.metadata) diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt index 03b110cc..9174d939 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -21,33 +21,87 @@ import io.github.vinceglb.filekit.PlatformFile * @constructor Initializes an instance of the video player state. */ @Stable -expect open class VideoPlayerState() { +interface VideoPlayerState { // Properties related to media state val hasMedia: Boolean + + /** + * Indicates whether the video is currently playing. + */ val isPlaying: Boolean val isLoading: Boolean + + /** + * Controls the playback volume. Valid values are within the range of 0.0 (muted) to 1.0 (maximum volume). + */ var volume: Float + + /** + * Represents the current playback position as a normalized value between 0.0 and 1.0. + */ var sliderPos: Float + + /** + * Denotes whether the user is manually adjusting the playback position. + */ var userDragging: Boolean + + /** + * Specifies if the video should loop when it reaches the end. + */ var loop: Boolean var playbackSpeed: Float + + /** + * Provides the audio level for the left channel as a percentage. + */ val leftLevel: Float + + /** + * Provides the audio level for the right channel as a percentage. + */ val rightLevel: Float + + /** + * Returns the current playback position as a formatted string. + */ val positionText: String + + /** + * Returns the total duration of the video as a formatted string. + */ val durationText: String val currentTime: Double var isFullscreen: Boolean val aspectRatio: Float // Functions to control playback + /** + * Starts or resumes video playback. + */ fun play() + + /** + * Pauses video playback. + */ fun pause() + + /** + * Stops playback and resets the player state. + */ fun stop() + + /** + * Seeks to a specific playback position based on the provided normalized value. + */ fun seekTo(value: Float) fun toggleFullscreen() // Functions to manage media sources + /** + * Opens a video file or URL for playback. + */ fun openUri(uri: String, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY) fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY) @@ -68,10 +122,17 @@ expect open class VideoPlayerState() { fun disableSubtitles() // Cleanup + /** + * Releases resources used by the video player and disposes of the state. + */ fun dispose() } -internal expect fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState +/** + * Create platform-specific video player state. Supported platforms include Windows, + * macOS, and Linux. + */ +expect fun createVideoPlayerState(): VideoPlayerState /** * Creates and manages an instance of `VideoPlayerState` within a composable function, ensuring @@ -83,8 +144,7 @@ internal expect fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerSta */ @Composable fun rememberVideoPlayerState(): VideoPlayerState { - val isInPreview = LocalInspectionMode.current - val playerState = remember { createVideoPlayerState(isInPreview) } + val playerState = remember { createVideoPlayerState() } DisposableEffect(Unit) { onDispose { playerState.dispose() @@ -93,13 +153,48 @@ fun rememberVideoPlayerState(): VideoPlayerState { return playerState } -@Composable -fun rememberPreviewVideoPlayerState(): VideoPlayerState { - val playerState = remember { createVideoPlayerState(isInPreview = true) } - DisposableEffect(Unit) { - onDispose { - playerState.dispose() - } - } - return playerState -} +/** + * Helper to mock the [VideoPlayerState]. + */ +data class PreviewableVideoPlayerState( + override val hasMedia: Boolean = true, + override val isPlaying: Boolean = true, + override val isLoading: Boolean = false, + override var volume: Float = 1f, + override var sliderPos: Float = 500f, + override var userDragging: Boolean = false, + override var loop: Boolean = true, + override var playbackSpeed: Float = 1f, + override val leftLevel: Float = 1f, + override val rightLevel: Float = 1f, + override val positionText: String = "00:05", + override val durationText: String = "00:10", + override val currentTime: Double = 5000.0, + override var isFullscreen: Boolean = false, + override val aspectRatio: Float = 1.7f, + override val error: VideoPlayerError? = null, + override val metadata: VideoMetadata = VideoMetadata(), + override var subtitlesEnabled: Boolean = false, + override var currentSubtitleTrack: SubtitleTrack? = null, + override val availableSubtitleTracks: MutableList = emptyList().toMutableList(), + override var subtitleTextStyle: TextStyle = TextStyle.Default, + override var subtitleBackgroundColor: Color = Color.Transparent, +) : VideoPlayerState { + override fun play() {} + override fun pause() {} + override fun stop() {} + override fun seekTo(value: Float) {} + override fun toggleFullscreen() {} + override fun openUri( + uri: String, + initializeplayerState: InitialPlayerState + ) {} + override fun openFile( + file: PlatformFile, + initializeplayerState: InitialPlayerState + ) {} + override fun clearError() {} + override fun selectSubtitleTrack(track: SubtitleTrack?) {} + override fun disableSubtitles() {} + override fun dispose() {} +} \ No newline at end of file diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt index d7ae7230..7d339186 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt @@ -28,14 +28,14 @@ import platform.UIKit.UIApplication import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue +actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() -@OptIn(ExperimentalForeignApi::class) @Stable -actual open class VideoPlayerState { +open class DefaultVideoPlayerState: VideoPlayerState { // Base states private var _volume = mutableStateOf(1.0f) - actual var volume: Float + override var volume: Float get() = _volume.value set(value) { val clampedValue = value.coerceIn(0f, 1f) @@ -45,10 +45,10 @@ actual open class VideoPlayerState { } } - actual var sliderPos: Float by mutableStateOf(0f) // value between 0 and 1000 - actual var userDragging: Boolean = false + override var sliderPos: Float by mutableStateOf(0f) // value between 0 and 1000 + override var userDragging: Boolean = false private var _loop by mutableStateOf(false) - actual var loop: Boolean + override var loop: Boolean get() = _loop set(value) { _loop = value @@ -87,7 +87,7 @@ actual open class VideoPlayerState { // Playback speed control private var _playbackSpeed by mutableStateOf(1.0f) - actual var playbackSpeed: Float + override var playbackSpeed: Float get() = _playbackSpeed set(value) { _playbackSpeed = value.coerceIn(0.5f, 2.0f) @@ -95,31 +95,31 @@ actual open class VideoPlayerState { } // Playback states - actual val hasMedia: Boolean get() = _hasMedia - actual val isPlaying: Boolean get() = _isPlaying + override val hasMedia: Boolean get() = _hasMedia + override val isPlaying: Boolean get() = _isPlaying private var _hasMedia by mutableStateOf(false) private var _isPlaying by mutableStateOf(false) // Displayed texts for position and duration private var _positionText: String by mutableStateOf("00:00") - actual val positionText: String get() = _positionText + override val positionText: String get() = _positionText private var _durationText: String by mutableStateOf("00:00") - actual val durationText: String get() = _durationText + override val durationText: String get() = _durationText // Loading state private var _isLoading by mutableStateOf(false) - actual val isLoading: Boolean + override val isLoading: Boolean get() = _isLoading // Fullscreen state private var _isFullscreen by mutableStateOf(false) - actual var isFullscreen: Boolean + override var isFullscreen: Boolean get() = _isFullscreen set(value) { _isFullscreen = value } - actual val error: VideoPlayerError? = null + override val error: VideoPlayerError? = null // Observable instance of AVPlayer var player: AVPlayer? by mutableStateOf(null) @@ -147,21 +147,21 @@ actual open class VideoPlayerState { // Internal time values (in seconds) private var _currentTime: Double = 0.0 private var _duration: Double = 0.0 - actual val currentTime: Double get() = _currentTime + override val currentTime: Double get() = _currentTime // Flag to indicate user-initiated pause private var userInitiatedPause: Boolean = false // Audio levels (not yet implemented) - actual val leftLevel: Float = 0f - actual val rightLevel: Float = 0f + override val leftLevel: Float = 0f + override val rightLevel: Float = 0f // Observable video aspect ratio (default to 16:9) private var _videoAspectRatio by mutableStateOf(16.0 / 9.0) val videoAspectRatio: CGFloat get() = _videoAspectRatio - actual val aspectRatio: Float = _videoAspectRatio.toFloat() + override val aspectRatio: Float = _videoAspectRatio.toFloat() // Video metadata private var _metadata = VideoMetadata(audioChannels = 2) @@ -271,7 +271,7 @@ actual open class VideoPlayerState { if (wasPlayingBeforeBackground) { Logger.d { "Player was playing before background, resuming" } player?.let { player -> - // Only resume if the player is actually paused + // Only resume if the player is overridely paused if (player.rate == 0.0f) { player.rate = _playbackSpeed player.play() @@ -335,7 +335,7 @@ actual open class VideoPlayerState { * @param uri The URI of the media to open * @param initializeplayerState Controls whether playback should start automatically after opening */ - actual fun openUri(uri: String, initializeplayerState: InitialPlayerState) { + override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { Logger.d { "openUri called with uri: $uri, initializeplayerState: $initializeplayerState" } val nsUrl = NSURL.URLWithString(uri) ?: run { Logger.d { "Failed to create NSURL from uri: $uri" } @@ -357,7 +357,7 @@ actual open class VideoPlayerState { // Create a temporary player with minimal setup to show something immediately val tempPlayerItem = AVPlayerItem(nsUrl) player = AVPlayer(playerItem = tempPlayerItem).apply { - volume = this@VideoPlayerState.volume + volume = this@DefaultVideoPlayerState.volume rate = 0.0f // Explicitly set rate to 0 to prevent auto-play pause() // Explicitly pause to ensure it doesn't auto-play allowsExternalPlayback = false // Disable AirPlay @@ -409,7 +409,7 @@ actual open class VideoPlayerState { val audioTracks = asset.tracksWithMediaType(AVMediaTypeAudio) if (audioTracks.isNotEmpty()) { // Set audio channels to 2 (stereo) as a more accurate default - // Most audio content is stereo, and we can't easily get the actual channel count + // Most audio content is stereo, and we can't easily get the override channel count // from AVAssetTrack in Kotlin/Native _metadata.audioChannels = 2 // Default to stereo instead of using track count @@ -447,7 +447,7 @@ actual open class VideoPlayerState { // Create the final player with the fully loaded asset player = AVPlayer(playerItem = playerItem).apply { - volume = this@VideoPlayerState.volume + volume = this@DefaultVideoPlayerState.volume // Don't set rate here, as it can cause auto-play actionAtItemEnd = AVPlayerActionAtItemEndNone @@ -504,7 +504,7 @@ actual open class VideoPlayerState { } } - actual fun play() { + override fun play() { Logger.d { "play called" } userInitiatedPause = false if (player == null) { @@ -546,7 +546,7 @@ actual open class VideoPlayerState { } } - actual fun pause() { + override fun pause() { Logger.d { "pause called" } userInitiatedPause = true // Ensure the pause call is on the main thread: @@ -556,7 +556,7 @@ actual open class VideoPlayerState { _isPlaying = false } - actual fun stop() { + override fun stop() { Logger.d { "stop called" } player?.pause() player?.seekToTime(CMTimeMakeWithSeconds(0.0, 1)) @@ -567,7 +567,7 @@ actual open class VideoPlayerState { _metadata = VideoMetadata(audioChannels = 2) } - actual fun seekTo(value: Float) { + override fun seekTo(value: Float) { if (_duration > 0) { // Set loading state to true to indicate seeking is happening _isLoading = true @@ -592,19 +592,19 @@ actual open class VideoPlayerState { } - actual fun clearError() { + override fun clearError() { Logger.d { "clearError called" } } /** * Toggles the fullscreen state of the video player */ - actual fun toggleFullscreen() { + override fun toggleFullscreen() { Logger.d { "toggleFullscreen called" } _isFullscreen = !_isFullscreen } - actual fun dispose() { + override fun dispose() { Logger.d { "dispose called" } cleanupCurrentPlayer() _hasMedia = false @@ -614,7 +614,7 @@ actual open class VideoPlayerState { _metadata = VideoMetadata(audioChannels = 2) } - actual fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { + override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { Logger.d { "openFile called with file: $file, initializeplayerState: $initializeplayerState" } // Use the getUri extension function to get a proper file URL val fileUrl = file.getUri() @@ -622,35 +622,35 @@ actual open class VideoPlayerState { openUri(fileUrl, initializeplayerState) } - actual val metadata: VideoMetadata + override val metadata: VideoMetadata get() = _metadata // Subtitle state private var _subtitlesEnabled by mutableStateOf(false) - actual var subtitlesEnabled: Boolean + override var subtitlesEnabled: Boolean get() = _subtitlesEnabled set(value) { _subtitlesEnabled = value } private var _currentSubtitleTrack by mutableStateOf(null) - actual var currentSubtitleTrack: SubtitleTrack? + override var currentSubtitleTrack: SubtitleTrack? get() = _currentSubtitleTrack set(value) { _currentSubtitleTrack = value } private val _availableSubtitleTracks = mutableListOf() - actual val availableSubtitleTracks: MutableList + override val availableSubtitleTracks: MutableList get() = _availableSubtitleTracks - actual var subtitleTextStyle: TextStyle = TextStyle( + override var subtitleTextStyle: TextStyle = TextStyle( color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Normal, textAlign = TextAlign.Center ) - actual var subtitleBackgroundColor: Color = Color.Black.copy(alpha = 0.5f) + override var subtitleBackgroundColor: Color = Color.Black.copy(alpha = 0.5f) /** * Selects a subtitle track for display. @@ -658,7 +658,7 @@ actual open class VideoPlayerState { * * @param track The subtitle track to select, or null to disable subtitles */ - actual fun selectSubtitleTrack(track: SubtitleTrack?) { + override fun selectSubtitleTrack(track: SubtitleTrack?) { Logger.d { "selectSubtitleTrack called with track: $track" } if (track == null) { disableSubtitles() @@ -676,7 +676,7 @@ actual open class VideoPlayerState { /** * Disables subtitle display. */ - actual fun disableSubtitles() { + override fun disableSubtitles() { Logger.d { "disableSubtitles called" } // Update state currentSubtitleTrack = null @@ -686,5 +686,3 @@ actual open class VideoPlayerState { // the native player for subtitle display } } - -internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState = VideoPlayerState() diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt index 3fa22be9..1d794fa2 100644 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.ios.kt @@ -71,11 +71,13 @@ fun VideoPlayerSurfaceImpl( } } - // Update the player when it changes - DisposableEffect(playerState.player) { - Logger.d{"Video Player updated"} - avPlayerViewController.player = playerState.player - onDispose { } + if (playerState is DefaultVideoPlayerState) { + // Update the player when it changes + DisposableEffect(playerState.player) { + Logger.d { "Video Player updated" } + avPlayerViewController.player = playerState.player + onDispose { } + } } if (playerState.hasMedia) { Box( @@ -86,9 +88,13 @@ fun VideoPlayerSurfaceImpl( // Use the contentScale parameter to adjust the view's size and scaling behavior UIKitView( modifier = contentScale.toCanvasModifier( - playerState.videoAspectRatio.toFloat(), - playerState.metadata.width, - playerState.metadata.height + aspectRatio = + if (playerState is DefaultVideoPlayerState) + playerState.videoAspectRatio.toFloat() + else 16.0f / 9.0f + , + width = playerState.metadata.width, + height = playerState.metadata.height ), factory = { UIView().apply { diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PlatformVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PlatformVideoPlayerState.kt deleted file mode 100644 index c7d54157..00000000 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PlatformVideoPlayerState.kt +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle - -/** - * Defines a platform-specific video player state interface, providing the essential - * properties and operations needed for video playback management. - * - * The interface is intended to be implemented by platform-specific classes, acting as - * a layer to abstract the underlying behavior of video players across different operating systems. - * - * Properties: - * - `isPlaying`: Read-only property indicating whether the video is currently playing. - * - `volume`: Controls the playback volume, with values between 0.0 (mute) and 1.0 (full volume). - * - `sliderPos`: Represents the current playback position as a normalized value between 0.0 and 1.0. - * - `userDragging`: Tracks if the user is currently interacting with the playback position control. - * - `loop`: Specifies whether the video playback should loop continuously. - * - `leftLevel`: Read-only property giving the audio peak level of the left channel. - * - `rightLevel`: Read-only property giving the audio peak level of the right channel. - * - `positionText`: Provides a formatted text representation of the current playback position. - * - `durationText`: Provides a formatted text representation of the total duration of the video. - * - * Methods: - * - `openUri(uri: String)`: Opens a video resource (file or URL) for playback. - * - `play()`: Begins or resumes video playback. - * - `pause()`: Pauses the current video playback. - * - `stop()`: Stops playback and resets the playback position to the beginning. - * - `seekTo(value: Float)`: Seeks to a specific playback position based on the given normalized value. - * - `dispose()`: Releases resources and performs cleanup for the video player instance. - */ -interface PlatformVideoPlayerState { - val hasMedia : Boolean - val isPlaying: Boolean - var volume: Float - var sliderPos: Float - var userDragging: Boolean - var loop: Boolean - var playbackSpeed: Float - val leftLevel: Float - val rightLevel: Float - val positionText: String - val durationText: String - val currentTime: Double - val isLoading: Boolean - val error: VideoPlayerError? - var isFullscreen: Boolean - - val metadata: VideoMetadata - val aspectRatio: Float - - // Subtitle management - var subtitlesEnabled: Boolean - var currentSubtitleTrack: SubtitleTrack? - val availableSubtitleTracks: MutableList - var subtitleTextStyle: TextStyle - var subtitleBackgroundColor: Color - fun selectSubtitleTrack(track: SubtitleTrack?) - fun disableSubtitles() - - fun openUri(uri: String, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY) - fun play() - fun pause() - fun stop() - fun seekTo(value: Float) - fun toggleFullscreen() - fun dispose() - fun clearError() -} diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt index 1593e298..bdb5b7dd 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.jvm.kt @@ -1,8 +1,5 @@ package io.github.kdroidfilter.composemediaplayer -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -12,16 +9,13 @@ import io.github.kdroidfilter.composemediaplayer.mac.MacVideoPlayerState import io.github.kdroidfilter.composemediaplayer.windows.WindowsVideoPlayerState import io.github.vinceglb.filekit.PlatformFile +actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() /** * Represents the state and behavior of a video player. This class provides properties * and methods to control video playback, manage the playback state, and interact with * platform-specific implementations. * - * The actual implementation delegates its behavior to platform-specific video player - * states based on the detected operating system. Supported platforms include Windows, - * macOS, and Linux. - * * Properties: * - `isPlaying`: Indicates whether the video is currently playing. * - `volume`: Controls the playback volume. Valid values are within the range of 0.0 (muted) to 1.0 (maximum volume). @@ -42,189 +36,83 @@ import io.github.vinceglb.filekit.PlatformFile * - `dispose()`: Releases resources used by the video player and disposes of the state. */ @Stable -actual open class VideoPlayerState internal constructor( - val delegate: PlatformVideoPlayerState, -) { - actual constructor() : this(createPlatformDelegate()) +open class DefaultVideoPlayerState: VideoPlayerState { + val delegate: VideoPlayerState = when { + Platform.isWindows() -> WindowsVideoPlayerState() + Platform.isMac() -> MacVideoPlayerState() + Platform.isLinux() -> LinuxVideoPlayerState() + else -> throw UnsupportedOperationException("Unsupported platform") + } - actual open val hasMedia: Boolean get() = delegate.hasMedia - actual open val isPlaying: Boolean get() = delegate.isPlaying - actual open val isLoading: Boolean get() = delegate.isLoading - actual open val error: VideoPlayerError? get() = delegate.error - actual open var volume: Float + override val hasMedia: Boolean get() = delegate.hasMedia + override val isPlaying: Boolean get() = delegate.isPlaying + override val isLoading: Boolean get() = delegate.isLoading + override val error: VideoPlayerError? get() = delegate.error + override var volume: Float get() = delegate.volume set(value) { delegate.volume = value } - actual open var sliderPos: Float + override var sliderPos: Float get() = delegate.sliderPos set(value) { delegate.sliderPos = value } - actual open var userDragging: Boolean + override var userDragging: Boolean get() = delegate.userDragging set(value) { delegate.userDragging = value } - actual open var loop: Boolean + override var loop: Boolean get() = delegate.loop set(value) { delegate.loop = value } - actual open var playbackSpeed: Float + override var playbackSpeed: Float get() = delegate.playbackSpeed set(value) { delegate.playbackSpeed = value } - actual open var isFullscreen: Boolean + override var isFullscreen: Boolean get() = delegate.isFullscreen set(value) { delegate.isFullscreen = value } - actual open val metadata: VideoMetadata get() = delegate.metadata - actual open val aspectRatio: Float get() = delegate.aspectRatio - - actual var subtitlesEnabled: Boolean - get() = delegate.subtitlesEnabled - set(value) { - delegate.subtitlesEnabled = value - } - - actual var currentSubtitleTrack: SubtitleTrack? - get() = delegate.currentSubtitleTrack - set(value) { - delegate.currentSubtitleTrack = value - } - - actual val availableSubtitleTracks: MutableList - get() = delegate.availableSubtitleTracks + override val metadata: VideoMetadata get() = delegate.metadata + override val aspectRatio: Float get() = delegate.aspectRatio - actual var subtitleTextStyle: TextStyle + override var subtitlesEnabled = delegate.subtitlesEnabled + override var currentSubtitleTrack : SubtitleTrack? = delegate.currentSubtitleTrack + override val availableSubtitleTracks = delegate.availableSubtitleTracks + override var subtitleTextStyle: TextStyle get() = delegate.subtitleTextStyle set(value) { delegate.subtitleTextStyle = value } - actual var subtitleBackgroundColor: Color + override var subtitleBackgroundColor: Color get() = delegate.subtitleBackgroundColor set(value) { delegate.subtitleBackgroundColor = value } - actual fun selectSubtitleTrack(track: SubtitleTrack?) = delegate.selectSubtitleTrack(track) - actual fun disableSubtitles() = delegate.disableSubtitles() - - actual open val leftLevel: Float get() = delegate.leftLevel - actual open val rightLevel: Float get() = delegate.rightLevel - actual open val positionText: String get() = delegate.positionText - actual open val durationText: String get() = delegate.durationText - actual open val currentTime: Double get() = delegate.currentTime - - actual open fun openUri(uri: String, initializeplayerState: InitialPlayerState) = delegate.openUri(uri, initializeplayerState) - actual open fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) = delegate.openUri(file.file.path, initializeplayerState) - actual open fun play() = delegate.play() - actual open fun pause() = delegate.pause() - actual open fun stop() = delegate.stop() - actual open fun seekTo(value: Float) = delegate.seekTo(value) - actual open fun toggleFullscreen() = delegate.toggleFullscreen() - actual open fun dispose() = delegate.dispose() - actual open fun clearError() = delegate.clearError() - -} - -private fun createPlatformDelegate(): PlatformVideoPlayerState = when { - Platform.isWindows() -> WindowsVideoPlayerState() - Platform.isMac() -> MacVideoPlayerState() - Platform.isLinux() -> LinuxVideoPlayerState() - else -> throw UnsupportedOperationException("Unsupported platform") -} - -internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState { - return if (isInPreview) { - VideoPlayerState(PreviewPlatformVideoPlayerState()) - } else { - VideoPlayerState() - } -} - -@Stable -private class PreviewPlatformVideoPlayerState : PlatformVideoPlayerState { - private var _hasMedia by mutableStateOf(false) - override val hasMedia: Boolean get() = _hasMedia - - private var _isPlaying by mutableStateOf(false) - override val isPlaying: Boolean get() = _isPlaying - - override var volume: Float by mutableStateOf(1f) - override var sliderPos: Float by mutableStateOf(0f) - override var userDragging: Boolean by mutableStateOf(false) - override var loop: Boolean by mutableStateOf(false) - override var playbackSpeed: Float by mutableStateOf(1f) - - override val leftLevel: Float get() = 0f - override val rightLevel: Float get() = 0f - override val positionText: String get() = "00:00" - override val durationText: String get() = "00:00" - override val currentTime: Double get() = 0.0 - - override val isLoading: Boolean get() = false - - private var _error by mutableStateOf(null) - override val error: VideoPlayerError? get() = _error - - override var isFullscreen: Boolean by mutableStateOf(false) - - override val metadata: VideoMetadata = VideoMetadata() - override val aspectRatio: Float get() = 16f / 9f - - override var subtitlesEnabled: Boolean by mutableStateOf(false) - override var currentSubtitleTrack: SubtitleTrack? by mutableStateOf(null) - override val availableSubtitleTracks: MutableList = mutableListOf() - override var subtitleTextStyle: TextStyle by mutableStateOf(TextStyle(color = Color.White)) - override var subtitleBackgroundColor: Color by mutableStateOf(Color.Black.copy(alpha = 0.5f)) - - override fun selectSubtitleTrack(track: SubtitleTrack?) { - currentSubtitleTrack = track - subtitlesEnabled = track != null - } - - override fun disableSubtitles() { - currentSubtitleTrack = null - subtitlesEnabled = false - } - - override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { - _hasMedia = true - _isPlaying = initializeplayerState == InitialPlayerState.PLAY - _error = VideoPlayerError.UnknownError("Video playback is not available in preview.") - } - - override fun play() { - _hasMedia = true - _isPlaying = true - } - - override fun pause() { - _isPlaying = false - } - - override fun stop() { - _isPlaying = false - sliderPos = 0f - } - - override fun seekTo(value: Float) { - sliderPos = value.coerceIn(0f, 1000f) - } - - override fun toggleFullscreen() { - isFullscreen = !isFullscreen - } - - override fun dispose() = Unit - - override fun clearError() { - _error = null - } + override fun selectSubtitleTrack(track: SubtitleTrack?) = delegate.selectSubtitleTrack(track) + override fun disableSubtitles() = delegate.disableSubtitles() + + override val leftLevel: Float get() = delegate.leftLevel + override val rightLevel: Float get() = delegate.rightLevel + override val positionText: String get() = delegate.positionText + override val durationText: String get() = delegate.durationText + override val currentTime: Double get() = delegate.currentTime + + override fun openUri(uri: String, initializeplayerState: InitialPlayerState) = delegate.openUri(uri, initializeplayerState) + override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) = delegate.openUri(file.file.path, initializeplayerState) + override fun play() = delegate.play() + override fun pause() = delegate.pause() + override fun stop() = delegate.stop() + override fun seekTo(value: Float) = delegate.seekTo(value) + override fun toggleFullscreen() = delegate.toggleFullscreen() + override fun dispose() = delegate.dispose() + override fun clearError() = delegate.clearError() } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt index 5ae03a0f..dc7f91ba 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurface.kt @@ -33,14 +33,13 @@ actual fun VideoPlayerSurface( contentScale: ContentScale, overlay: @Composable () -> Unit ) { - if (LocalInspectionMode.current) { - VideoPlayerSurfacePreview(modifier = modifier, overlay = overlay) - return - } - when (val delegate = playerState.delegate) { - is WindowsVideoPlayerState -> WindowsVideoPlayerSurface(delegate, modifier, contentScale, overlay) - is MacVideoPlayerState -> MacVideoPlayerSurface(delegate, modifier, contentScale, overlay) - is LinuxVideoPlayerState -> LinuxVideoPlayerSurface(delegate, modifier, contentScale, overlay) - else -> VideoPlayerSurfacePreview(modifier = modifier, message = "VideoPlayerSurface (preview)", overlay = overlay) - } + if (playerState is DefaultVideoPlayerState) + when (val delegate = playerState.delegate) { + is WindowsVideoPlayerState -> WindowsVideoPlayerSurface(delegate, modifier, contentScale, overlay) + is MacVideoPlayerState -> MacVideoPlayerSurface(delegate, modifier, contentScale, overlay) + is LinuxVideoPlayerState -> LinuxVideoPlayerSurface(delegate, modifier, contentScale, overlay) + else -> throw IllegalArgumentException("Unsupported player state type") + } + else + throw IllegalArgumentException("Unsupported player state type") } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt index 07b0fbca..1df7ec4d 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/common/FullscreenVideoPlayerWindow.kt @@ -3,7 +3,12 @@ package io.github.kdroidfilter.composemediaplayer.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key @@ -13,8 +18,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.rememberWindowState -import com.sun.jna.Platform -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.VideoPlayerStateRegistry /** @@ -26,8 +30,8 @@ import io.github.kdroidfilter.composemediaplayer.util.VideoPlayerStateRegistry */ @Composable fun openFullscreenWindow( - playerState: PlatformVideoPlayerState, - renderSurface: @Composable (PlatformVideoPlayerState, Modifier, Boolean) -> Unit + playerState: VideoPlayerState, + renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit ) { // Register the player state to be accessible from the fullscreen window VideoPlayerStateRegistry.registerState(playerState) @@ -41,7 +45,7 @@ fun openFullscreenWindow( */ @Composable private fun FullscreenVideoPlayerWindow( - renderSurface: @Composable (PlatformVideoPlayerState, Modifier, Boolean) -> Unit + renderSurface: @Composable (VideoPlayerState, Modifier, Boolean) -> Unit ) { // Get the player state from the registry val playerState = remember { diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt index 508da0c9..d45f4f6a 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerState.kt @@ -12,16 +12,25 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import io.github.kdroidfilter.composemediaplayer.InitialPlayerState -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.DEFAULT_ASPECT_RATIO import io.github.kdroidfilter.composemediaplayer.util.formatTime -import org.freedesktop.gstreamer.* +import io.github.vinceglb.filekit.PlatformFile +import org.freedesktop.gstreamer.Bin +import org.freedesktop.gstreamer.Bus +import org.freedesktop.gstreamer.Caps +import org.freedesktop.gstreamer.Element +import org.freedesktop.gstreamer.ElementFactory +import org.freedesktop.gstreamer.FlowReturn +import org.freedesktop.gstreamer.Format +import org.freedesktop.gstreamer.GhostPad +import org.freedesktop.gstreamer.Sample +import org.freedesktop.gstreamer.State import org.freedesktop.gstreamer.elements.AppSink import org.freedesktop.gstreamer.elements.PlayBin -import org.freedesktop.gstreamer.Format import org.freedesktop.gstreamer.event.SeekFlags import org.freedesktop.gstreamer.event.SeekType import org.freedesktop.gstreamer.message.MessageType @@ -46,7 +55,7 @@ import kotlin.math.pow * A Timer performs a slight seek to reposition exactly at the saved position. */ @Stable -class LinuxVideoPlayerState : PlatformVideoPlayerState { +class LinuxVideoPlayerState : VideoPlayerState { companion object { // Flag to enable text subtitles (GST_PLAY_FLAG_TEXT) @@ -736,6 +745,13 @@ class LinuxVideoPlayerState : PlatformVideoPlayerState { } } + override fun openFile( + file: PlatformFile, + initializeplayerState: InitialPlayerState + ) { + openUri(file.file.path, initializeplayerState) + } + override fun play() { try { playbin.play() diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt index 936fb4b3..48ee773c 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerState.kt @@ -13,11 +13,12 @@ import co.touchlab.kermit.Logger.Companion.setMinSeverity import co.touchlab.kermit.Severity import com.sun.jna.Pointer import io.github.kdroidfilter.composemediaplayer.InitialPlayerState -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.util.formatTime +import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.utils.toFile import io.github.vinceglb.filekit.utils.toPath import kotlinx.coroutines.* @@ -41,7 +42,7 @@ internal val macLogger = Logger.withTag("MacVideoPlayerState") * This implementation uses a native video player via SharedVideoPlayer. * All debug logs are handled with Kermit. */ -class MacVideoPlayerState : PlatformVideoPlayerState { +class MacVideoPlayerState : VideoPlayerState { // Main state variables private val mainMutex = Mutex() @@ -315,6 +316,13 @@ class MacVideoPlayerState : PlatformVideoPlayerState { } } + override fun openFile( + file: PlatformFile, + initializeplayerState: InitialPlayerState + ) { + openUri(file.file.path, initializeplayerState) + } + /** Cleans up current playback state. */ private suspend fun cleanupCurrentPlayback() { macLogger.d { "cleanupCurrentPlayback() - Cleaning up current playback" } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt index d10dffdc..cb7fa6bf 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/VideoPlayerStateRegistry.kt @@ -1,7 +1,7 @@ package io.github.kdroidfilter.composemediaplayer.util import androidx.compose.runtime.Stable -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import java.lang.ref.WeakReference /** @@ -10,7 +10,7 @@ import java.lang.ref.WeakReference */ @Stable object VideoPlayerStateRegistry { - private var registeredState: WeakReference? = null + private var registeredState: WeakReference? = null /** * Register a WindowsVideoPlayerState instance to be shared between windows. @@ -18,7 +18,7 @@ object VideoPlayerStateRegistry { * * @param state The WindowsVideoPlayerState to register */ - fun registerState(state: PlatformVideoPlayerState) { + fun registerState(state: VideoPlayerState) { registeredState = WeakReference(state) } @@ -27,7 +27,7 @@ object VideoPlayerStateRegistry { * * @return The registered WindowsVideoPlayerState or null if none is registered */ - fun getRegisteredState(): PlatformVideoPlayerState? { + fun getRegisteredState(): VideoPlayerState? { return registeredState?.get() } diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt index 778b5bf2..d428be9a 100644 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt @@ -21,18 +21,35 @@ import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.LongByReference import com.sun.jna.ptr.PointerByReference import io.github.kdroidfilter.composemediaplayer.InitialPlayerState -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.formatTime -import kotlinx.coroutines.* +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType @@ -54,7 +71,7 @@ internal val windowsLogger = Logger.withTag("WindowsVideoPlayerState") * Windows implementation of the video player state. * Handles media playback using Media Foundation on Windows platform. */ -class WindowsVideoPlayerState : PlatformVideoPlayerState { +class WindowsVideoPlayerState : VideoPlayerState { companion object { private val isMfBootstrapped = AtomicBoolean(false) @@ -441,6 +458,13 @@ class WindowsVideoPlayerState : PlatformVideoPlayerState { } } + override fun openFile( + file: PlatformFile, + initializeplayerState: InitialPlayerState + ) { + openUri(file.file.path, initializeplayerState) + } + /** * Internal implementation of openUri that assumes the player is initialized * diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt index a75cb769..579c9903 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/linux/LinuxVideoPlayerStateTest.kt @@ -1,23 +1,21 @@ package io.github.kdroidfilter.composemediaplayer.linux -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState -import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import com.sun.jna.Platform +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.freedesktop.gstreamer.Gst +import org.freedesktop.gstreamer.Version +import org.junit.Assume +import org.junit.Before import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.delay -import com.sun.jna.Platform -import org.junit.Before -import org.junit.Assume -import org.freedesktop.gstreamer.Gst -import org.freedesktop.gstreamer.Version /** - * Tests for the Linux implementation of PlatformVideoPlayerState + * Tests for the Linux implementation of VideoPlayerState * * Note: These tests will only run on Linux platforms. On other platforms, * the tests will be skipped. diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt index da2de80b..e27407c9 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/mac/MacVideoPlayerStateTest.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.delay import com.sun.jna.Platform /** - * Tests for the Mac implementation of PlatformVideoPlayerState + * Tests for the Mac implementation of VideoPlayerState * * Note: These tests will only run on Mac platforms. On other platforms, * the tests will be skipped. diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt index f3863620..521deb1f 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerStateTest.kt @@ -1,19 +1,18 @@ package io.github.kdroidfilter.composemediaplayer.windows -import io.github.kdroidfilter.composemediaplayer.PlatformVideoPlayerState +import com.sun.jna.Platform import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.delay -import com.sun.jna.Platform /** - * Tests for the Windows implementation of PlatformVideoPlayerState + * Tests for the Windows implementation of VideoPlayerState * * Note: These tests will only run on Windows platforms. On other platforms, * the tests will be skipped. diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt index 4c820b51..bbc6d0b4 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt @@ -9,23 +9,31 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import io.github.kdroidfilter.composemediaplayer.InitialPlayerState -import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.kdroidfilter.composemediaplayer.util.formatTime +import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.PlatformFile -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.io.IOException import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource +actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() + /** * Implementation of VideoPlayerState for WebAssembly/JavaScript platform. * Manages the state of a video player including playback controls, media information, * and error handling. */ @Stable -actual open class VideoPlayerState { +open class DefaultVideoPlayerState: VideoPlayerState { // Variable to store the last opened URI for potential replay private var lastUri: String? = null @@ -46,27 +54,27 @@ actual open class VideoPlayerState { // Playback state properties private var _isPlaying by mutableStateOf(false) - actual val isPlaying: Boolean get() = _isPlaying + override val isPlaying: Boolean get() = _isPlaying private var _hasMedia by mutableStateOf(false) - actual val hasMedia: Boolean get() = _hasMedia + override val hasMedia: Boolean get() = _hasMedia internal var _isLoading by mutableStateOf(false) - actual val isLoading: Boolean get() = _isLoading + override val isLoading: Boolean get() = _isLoading // Error handling private var _error by mutableStateOf(null) - actual val error: VideoPlayerError? get() = _error + override val error: VideoPlayerError? get() = _error // Media metadata - actual val metadata = VideoMetadata() - actual val aspectRatio : Float = 16f / 9f //TO DO: Get from video source + override val metadata = VideoMetadata() + override val aspectRatio : Float = 16f / 9f //TO DO: Get from video source // Subtitle management - actual var subtitlesEnabled by mutableStateOf(false) - actual var currentSubtitleTrack by mutableStateOf(null) - actual val availableSubtitleTracks = mutableListOf() - actual var subtitleTextStyle by mutableStateOf( + override var subtitlesEnabled by mutableStateOf(false) + override var currentSubtitleTrack by mutableStateOf(null) + override val availableSubtitleTracks = mutableListOf() + override var subtitleTextStyle by mutableStateOf( TextStyle( color = Color.White, fontSize = 18.sp, @@ -74,11 +82,11 @@ actual open class VideoPlayerState { textAlign = TextAlign.Center ) ) - actual var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f)) + override var subtitleBackgroundColor by mutableStateOf(Color.Black.copy(alpha = 0.5f)) // Playback control properties private var _volume by mutableStateOf(1.0f) - actual var volume: Float + override var volume: Float get() = _volume set(value) { val newValue = value.coerceIn(0f, 1f) @@ -88,12 +96,12 @@ actual open class VideoPlayerState { } } - actual var sliderPos by mutableStateOf(0.0f) - actual var userDragging by mutableStateOf(false) - actual var loop by mutableStateOf(false) + override var sliderPos by mutableStateOf(0.0f) + override var userDragging by mutableStateOf(false) + override var loop by mutableStateOf(false) private var _playbackSpeed by mutableStateOf(1.0f) - actual var playbackSpeed: Float + override var playbackSpeed: Float get() = _playbackSpeed set(value) { val newValue = value.coerceIn(0.5f, 2.0f) @@ -103,26 +111,26 @@ actual open class VideoPlayerState { } } - actual var isFullscreen by mutableStateOf(false) + override var isFullscreen by mutableStateOf(false) // Audio level indicators private var _leftLevel by mutableStateOf(0f) private var _rightLevel by mutableStateOf(0f) - actual val leftLevel: Float get() = _leftLevel - actual val rightLevel: Float get() = _rightLevel + override val leftLevel: Float get() = _leftLevel + override val rightLevel: Float get() = _rightLevel // Time display properties private var _positionText by mutableStateOf("00:00") private var _durationText by mutableStateOf("00:00") - actual val positionText: String get() = _positionText - actual val durationText: String get() = _durationText + override val positionText: String get() = _positionText + override val durationText: String get() = _durationText // Current duration of the media private var _currentDuration: Float = 0f // Current time of the media in seconds private var _currentTime: Double = 0.0 - actual val currentTime: Double get() = _currentTime + override val currentTime: Double get() = _currentTime // Job for handling seek operations internal var seekJob: Job? = null @@ -204,7 +212,7 @@ actual open class VideoPlayerState { * * @param track The subtitle track to select, or null to disable subtitles */ - actual fun selectSubtitleTrack(track: SubtitleTrack?) { + override fun selectSubtitleTrack(track: SubtitleTrack?) { currentSubtitleTrack = track subtitlesEnabled = (track != null) } @@ -212,7 +220,7 @@ actual open class VideoPlayerState { /** * Disables subtitles by clearing the current track and setting subtitlesEnabled to false. */ - actual fun disableSubtitles() { + override fun disableSubtitles() { currentSubtitleTrack = null subtitlesEnabled = false } @@ -223,7 +231,7 @@ actual open class VideoPlayerState { * @param uri The URI of the media to open * @param initializeplayerState Controls whether playback should start automatically after opening */ - actual fun openUri(uri: String, initializeplayerState: InitialPlayerState) { + override fun openUri(uri: String, initializeplayerState: InitialPlayerState) { playerScope.coroutineContext.cancelChildren() // Store the URI for potential replay after stop @@ -257,7 +265,7 @@ actual open class VideoPlayerState { * @param file The file to open * @param initializeplayerState Controls whether playback should start automatically after opening */ - actual fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { + override fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState) { val fileUri = file.getUri() openUri(fileUri, initializeplayerState) } @@ -266,7 +274,7 @@ actual open class VideoPlayerState { * Starts or resumes playback of the current media. * If no media is loaded but a previous URI exists, reopens that media. */ - actual fun play() { + override fun play() { if (_hasMedia && !_isPlaying) { _isPlaying = true } else if (!_hasMedia && lastUri != null) { @@ -278,7 +286,7 @@ actual open class VideoPlayerState { /** * Pauses playback of the current media. */ - actual fun pause() { + override fun pause() { if (_isPlaying) { _isPlaying = false } @@ -288,7 +296,7 @@ actual open class VideoPlayerState { * Stops playback and resets the player state. * Note: lastUri is preserved for potential replay. */ - actual fun stop() { + override fun stop() { _isPlaying = false _sourceUri = null _hasMedia = false @@ -305,7 +313,7 @@ actual open class VideoPlayerState { * * @param value The position to seek to, as a percentage (0-1000) */ - actual fun seekTo(value: Float) { + override fun seekTo(value: Float) { sliderPos = value seekJob?.cancel() } @@ -313,14 +321,14 @@ actual open class VideoPlayerState { /** * Clears any error state. */ - actual fun clearError() { + override fun clearError() { _error = null } /** * Toggles the fullscreen state of the video player */ - actual fun toggleFullscreen() { + override fun toggleFullscreen() { FullscreenManager.toggleFullscreen(isFullscreen) { newFullscreenState -> isFullscreen = newFullscreenState } @@ -402,7 +410,7 @@ actual open class VideoPlayerState { /** * Disposes of resources used by the player. */ - actual fun dispose() { + override fun dispose() { pendingVolumeChange?.cancel() pendingSpeedChange?.cancel() playerScope.cancel() @@ -412,5 +420,3 @@ actual open class VideoPlayerState { internal const val PERCENTAGE_MULTIPLIER = 1000f } } - -internal actual fun createVideoPlayerState(isInPreview: Boolean): VideoPlayerState = VideoPlayerState() diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt index e950960f..4160de1e 100644 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt @@ -85,8 +85,10 @@ internal fun HTMLVideoElement.addEventListeners( } loadingEvents.forEach { (event, isLoading) -> - addEventListener(event) { - scope.launch { playerState._isLoading = isLoading } + if (playerState is DefaultVideoPlayerState) { + addEventListener(event) { + scope.launch { playerState._isLoading = isLoading } + } } } } @@ -377,22 +379,24 @@ internal fun setupVideoElement( } } - video.addEventListeners( - scope = scope, - playerState = playerState, - events = mapOf( - "timeupdate" to { event -> playerState.onTimeUpdateEvent(event) }, - "ended" to { scope.launch { playerState.pause() } } - ), - loadingEvents = mapOf( - "seeking" to true, - "waiting" to true, - "playing" to false, - "seeked" to false, - "canplaythrough" to false, - "canplay" to false + if (playerState is DefaultVideoPlayerState) { + video.addEventListeners( + scope = scope, + playerState = playerState, + events = mapOf( + "timeupdate" to { event -> playerState.onTimeUpdateEvent(event) }, + "ended" to { scope.launch { playerState.pause() } } + ), + loadingEvents = mapOf( + "seeking" to true, + "waiting" to true, + "playing" to false, + "seeked" to false, + "canplaythrough" to false, + "canplay" to false + ) ) - ) + } val conditionalLoadingEvents = mapOf( "suspend" to { video.readyState >= 3 }, @@ -406,7 +410,7 @@ internal fun setupVideoElement( } scope.launch { - if (condition()) { + if (playerState is DefaultVideoPlayerState && condition()) { playerState._isLoading = false } @@ -426,7 +430,7 @@ internal fun setupVideoElement( initAudioAnalyzer() } - if (enableAudioDetection && audioLevelJob?.isActive != true) { + if (playerState is DefaultVideoPlayerState && enableAudioDetection && audioLevelJob?.isActive != true) { audioLevelJob = scope.launch { while (video.paused.not()) { val (left, right) = if (!corsErrorDetected) { @@ -448,7 +452,8 @@ internal fun setupVideoElement( video.addEventListener("error") { scope.launch { - playerState._isLoading = false + if (playerState is DefaultVideoPlayerState) + playerState._isLoading = false corsErrorDetected = true val error = video.error @@ -462,7 +467,9 @@ internal fun setupVideoElement( } else { "Failed to load because no supported source was found" } - playerState.setError(VideoPlayerError.SourceError(errorMsg)) + if (playerState is DefaultVideoPlayerState) { + playerState.setError(VideoPlayerError.SourceError(errorMsg)) + } } } } @@ -475,7 +482,7 @@ internal fun setupVideoElement( } } -internal fun VideoPlayerState.onTimeUpdateEvent(event: Event) { +internal fun DefaultVideoPlayerState.onTimeUpdateEvent(event: Event) { (event.target as? HTMLVideoElement)?.let { onTimeUpdate(it.currentTime.toFloat(), it.duration.toFloat()) } @@ -577,18 +584,20 @@ internal fun VideoPlayerEffects( } // Handle source change effect - LaunchedEffect(videoElement, playerState.sourceUri) { - videoElement?.let { video -> - val sourceUri = playerState.sourceUri ?: "" - if (sourceUri.isNotEmpty()) { - playerState.clearError() - video.src = sourceUri - video.load() - if (playerState.isPlaying) video.safePlay() else video.safePause() + + if (playerState is DefaultVideoPlayerState) { + LaunchedEffect(videoElement, playerState.sourceUri) { + videoElement?.let { video -> + val sourceUri = playerState.sourceUri ?: "" + if (sourceUri.isNotEmpty()) { + playerState.clearError() + video.src = sourceUri + video.load() + if (playerState.isPlaying) video.safePlay() else video.safePause() + } } } } - // Handle play/pause LaunchedEffect(videoElement, playerState.isPlaying) { videoElement?.let { video -> @@ -634,13 +643,13 @@ internal fun VideoPlayerEffects( // Handle seeking LaunchedEffect(playerState.sliderPos) { - if (!playerState.userDragging && playerState.hasMedia) { + if (playerState is DefaultVideoPlayerState && !playerState.userDragging && playerState.hasMedia) { playerState.seekJob?.cancel() videoElement?.let { video -> val duration = video.duration.toFloat() if (duration > 0f) { - val newTime = (playerState.sliderPos / VideoPlayerState.PERCENTAGE_MULTIPLIER) * duration + val newTime = (playerState.sliderPos / DefaultVideoPlayerState.PERCENTAGE_MULTIPLIER) * duration val currentTime = video.currentTime if (abs(currentTime - newTime) > 0.5) { @@ -683,56 +692,58 @@ internal fun VideoVolumeAndSpeedEffects( var pendingVolumeChange by remember { mutableStateOf(null) } var pendingPlaybackSpeedChange by remember { mutableStateOf(null) } - DisposableEffect(videoElement) { - val video = videoElement ?: return@DisposableEffect onDispose {} + if (playerState is DefaultVideoPlayerState) { + DisposableEffect(videoElement) { + val video = videoElement ?: return@DisposableEffect onDispose {} - playerState.applyVolumeCallback = { value -> - if (playerState._isLoading) { - pendingVolumeChange = value.toDouble() - } else { - video.volume = value.toDouble() - pendingVolumeChange = null + playerState.applyVolumeCallback = { value -> + if (playerState._isLoading) { + pendingVolumeChange = value.toDouble() + } else { + video.volume = value.toDouble() + pendingVolumeChange = null + } } - } - if (!playerState._isLoading) { - video.volume = playerState.volume.toDouble() - } else { - pendingVolumeChange = playerState.volume.toDouble() - } - - playerState.applyPlaybackSpeedCallback = { value -> - if (playerState._isLoading) { - pendingPlaybackSpeedChange = value + if (!playerState._isLoading) { + video.volume = playerState.volume.toDouble() } else { - video.safeSetPlaybackRate(value) - pendingPlaybackSpeedChange = null + pendingVolumeChange = playerState.volume.toDouble() } - } - if (!playerState._isLoading) { - video.safeSetPlaybackRate(playerState.playbackSpeed) - } else { - pendingPlaybackSpeedChange = playerState.playbackSpeed - } + playerState.applyPlaybackSpeedCallback = { value -> + if (playerState._isLoading) { + pendingPlaybackSpeedChange = value + } else { + video.safeSetPlaybackRate(value) + pendingPlaybackSpeedChange = null + } + } - val seekedListener: (Event) -> Unit = { - pendingVolumeChange?.let { volume -> - video.volume = volume - pendingVolumeChange = null + if (!playerState._isLoading) { + video.safeSetPlaybackRate(playerState.playbackSpeed) + } else { + pendingPlaybackSpeedChange = playerState.playbackSpeed } - pendingPlaybackSpeedChange?.let { speed -> - video.safeSetPlaybackRate(speed) - pendingPlaybackSpeedChange = null + + val seekedListener: (Event) -> Unit = { + pendingVolumeChange?.let { volume -> + video.volume = volume + pendingVolumeChange = null + } + pendingPlaybackSpeedChange?.let { speed -> + video.safeSetPlaybackRate(speed) + pendingPlaybackSpeedChange = null + } } - } - video.addEventListener("seeked", seekedListener) + video.addEventListener("seeked", seekedListener) - onDispose { - video.removeEventListener("seeked", seekedListener) - playerState.applyVolumeCallback = null - playerState.applyPlaybackSpeedCallback = null + onDispose { + video.removeEventListener("seeked", seekedListener) + playerState.applyVolumeCallback = null + playerState.applyPlaybackSpeedCallback = null + } } } } diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index b3905473..08c92fce 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -96,7 +96,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 23 + minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = 36 applicationId = "sample.app.androidApp" @@ -143,3 +143,8 @@ tasks.register("runIos") { println("Running iOS app in simulator...") } } + +dependencies { + debugImplementation(compose.uiTooling) +} + diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt index c63c7b55..72ab80ac 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt @@ -2,31 +2,68 @@ package sample.app.singleplayer // Import the extracted composable functions import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.layout.width import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.layout.ContentScale import androidx.compose.ui.unit.dp import io.github.kdroidfilter.composemediaplayer.InitialPlayerState +import io.github.kdroidfilter.composemediaplayer.PreviewableVideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack +import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.name +import org.jetbrains.compose.ui.tooling.preview.Preview import sample.app.SubtitleManagementDialog -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SinglePlayerScreen() { + SinglePlayerScreenCore(rememberVideoPlayerState()) +} + +@Composable +@Preview +private fun SinglePlayerScreen_OnPaused_Preview() { + val playerState = PreviewableVideoPlayerState(isPlaying = false) + SinglePlayerScreenCore(playerState) +} + +@Composable +@Preview +private fun SinglePlayerScreen_OnError_Preview() { + val playerState = PreviewableVideoPlayerState( + loop = false, + error = VideoPlayerError.CodecError("Unable to decode") + ) + SinglePlayerScreenCore(playerState) +} + +@Composable +private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { MaterialTheme { // Default video URL var videoUrl by remember { mutableStateOf("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") } - val playerState = rememberVideoPlayerState() - + // State for initial player state (PLAY or PAUSE) var initialPlayerState by remember { mutableStateOf(InitialPlayerState.PLAY) } From 6ba494080648dc18d686571b51f99745ae68da8e Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 24 Dec 2025 18:49:17 +0200 Subject: [PATCH 2/3] Fix tests for VideoPlayerState interface --- .../VideoPlayerStateTest.kt | 12 +++++------ .../VideoPlayerStateTest.kt | 11 +++++----- .../VideoPlayerStateTest.kt | 20 ++++++++++--------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt index 789b171a..527ab929 100644 --- a/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt +++ b/mediaplayer/src/iosTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt @@ -16,7 +16,7 @@ class VideoPlayerStateTest { */ @Test fun testCreateVideoPlayerState() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify the player state is initialized correctly assertNotNull(playerState) @@ -40,7 +40,7 @@ class VideoPlayerStateTest { */ @Test fun testVolumeControl() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial volume assertEquals(1f, playerState.volume) @@ -65,7 +65,7 @@ class VideoPlayerStateTest { */ @Test fun testLoopSetting() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial loop setting assertFalse(playerState.loop) @@ -86,7 +86,7 @@ class VideoPlayerStateTest { */ @Test fun testFullscreenToggle() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial fullscreen state assertFalse(playerState.isFullscreen) @@ -108,7 +108,7 @@ class VideoPlayerStateTest { */ @Test fun testErrorHandling() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test opening a non-existent file playerState.openUri("non_existent_file.mp4") @@ -125,7 +125,7 @@ class VideoPlayerStateTest { */ @Test fun testSubtitleFunctionality() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify initial subtitle state assertFalse(playerState.subtitlesEnabled) diff --git a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt index 94be5cbf..1ef74f68 100644 --- a/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt +++ b/mediaplayer/src/jvmTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt @@ -57,11 +57,10 @@ class VideoPlayerStateTest { return } - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify the player state is initialized correctly assertNotNull(playerState) - assertNotNull(playerState.delegate) assertFalse(playerState.hasMedia) assertFalse(playerState.isPlaying) assertEquals(0f, playerState.sliderPos) @@ -88,7 +87,7 @@ class VideoPlayerStateTest { return } - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial volume assertEquals(1f, playerState.volume) @@ -119,7 +118,7 @@ class VideoPlayerStateTest { return } - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial loop setting assertFalse(playerState.loop) @@ -146,7 +145,7 @@ class VideoPlayerStateTest { return } - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial fullscreen state assertFalse(playerState.isFullscreen) @@ -173,7 +172,7 @@ class VideoPlayerStateTest { return } - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Initially there should be no error assertEquals(null, playerState.error) diff --git a/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt b/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt index 0e99e999..5d439212 100644 --- a/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt +++ b/mediaplayer/src/wasmJsTest/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerStateTest.kt @@ -16,7 +16,7 @@ class VideoPlayerStateTest { */ @Test fun testCreateVideoPlayerState() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Verify the player state is initialized correctly assertNotNull(playerState) @@ -40,7 +40,7 @@ class VideoPlayerStateTest { */ @Test fun testVolumeControl() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial volume assertEquals(1f, playerState.volume) @@ -65,7 +65,7 @@ class VideoPlayerStateTest { */ @Test fun testLoopSetting() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial loop setting assertFalse(playerState.loop) @@ -86,7 +86,7 @@ class VideoPlayerStateTest { */ @Test fun testFullscreenToggle() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Test initial fullscreen state assertFalse(playerState.isFullscreen) @@ -107,13 +107,14 @@ class VideoPlayerStateTest { */ @Test fun testErrorHandling() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() + val webPlayerState = playerState as DefaultVideoPlayerState // Initially there should be no error assertEquals(null, playerState.error) // Test setting an error manually (since we can't easily trigger a real error in tests) - playerState.setError(VideoPlayerError.NetworkError("Test error")) + webPlayerState.setError(VideoPlayerError.NetworkError("Test error")) // There should be an error now assertNotNull(playerState.error) @@ -131,7 +132,7 @@ class VideoPlayerStateTest { */ @Test fun testSubtitleFunctionality() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() // Initially subtitles should be disabled assertFalse(playerState.subtitlesEnabled) @@ -168,7 +169,8 @@ class VideoPlayerStateTest { */ @Test fun testPositionUpdates() { - val playerState = VideoPlayerState() + val playerState = createVideoPlayerState() + val webPlayerState = playerState as DefaultVideoPlayerState // Test initial position assertEquals(0f, playerState.sliderPos) @@ -176,7 +178,7 @@ class VideoPlayerStateTest { assertEquals("00:00", playerState.durationText) // Test updating position manually with forceUpdate to bypass rate limiting - playerState.updatePosition(30f, 120f, forceUpdate = true) + webPlayerState.updatePosition(30f, 120f, forceUpdate = true) // Verify position was updated assertEquals("00:30", playerState.positionText) From fe8b7ea2f5d41c53123b5f08d711ab1f301baddc Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Wed, 24 Dec 2025 19:19:19 +0200 Subject: [PATCH 3/3] Handle missing Android context in createVideoPlayerState --- .../VideoPlayerState.android.kt | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt index 422c3498..3accae82 100644 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.android.kt @@ -35,7 +35,37 @@ import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* @OptIn(UnstableApi::class) -actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() +actual fun createVideoPlayerState(): VideoPlayerState = + try { + DefaultVideoPlayerState() + } catch (e: IllegalStateException) { + PreviewableVideoPlayerState( + hasMedia = false, + isPlaying = false, + isLoading = false, + volume = 1f, + sliderPos = 0f, + userDragging = false, + loop = false, + playbackSpeed = 1f, + leftLevel = 0f, + rightLevel = 0f, + positionText = "00:00", + durationText = "00:00", + currentTime = 0.0, + isFullscreen = false, + aspectRatio = 16f / 9f, + error = VideoPlayerError.UnknownError( + "Android context is not available (preview or missing ContextProvider initialization)." + ), + metadata = VideoMetadata(), + subtitlesEnabled = false, + currentSubtitleTrack = null, + availableSubtitleTracks = mutableListOf(), + subtitleTextStyle = TextStyle.Default, + subtitleBackgroundColor = Color.Transparent + ) + } /** * Logger for WebAssembly video player surface @@ -719,4 +749,4 @@ open class DefaultVideoPlayerState: VideoPlayerState { resetStates() } } -} \ No newline at end of file +}