From c99333d58d70ee4c6017f2a7e1c1860706151d3d Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:33:39 +0100 Subject: [PATCH 1/6] feat: add pip support --- gradle/libs.versions.toml | 2 +- .../PipController.android.kt | 51 ++++++++++ .../VideoPlayerState.android.kt | 13 +++ .../composemediaplayer/PipController.kt | 10 ++ .../composemediaplayer/VideoPlayerState.kt | 7 +- .../composemediaplayer/PipController.ios.kt | 27 ++++++ .../VideoPlayerState.ios.kt | 29 +++++- .../VideoPlayerSurface.ios.kt | 93 ++++++++++--------- .../src/androidMain/AndroidManifest.xml | 13 +-- .../src/androidMain/kotlin/sample/app/main.kt | 10 ++ .../src/commonMain/kotlin/sample/app/App.kt | 76 +++++++-------- .../sample/app/VideoAttachmentPlayer.kt | 10 ++ .../app/singleplayer/PlayerComponents.kt | 2 + .../iosApp/iosApp.xcodeproj/project.pbxproj | 12 ++- sample/iosApp/iosApp/Info.plist | 66 ++++++------- 15 files changed, 294 insertions(+), 127 deletions(-) create mode 100644 mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt create mode 100644 mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt create mode 100644 mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 781d7418..781e2cdb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ filekit = "0.12.0" gst1JavaCore = "1.4.0" kermit = "2.0.8" kotlin = "2.3.0" -agp = "8.13.2" +agp = "8.12.0" kotlinx-coroutines = "1.10.2" kotlinxBrowserWasmJs = "0.5.0" kotlinxDatetime = "0.7.1-0.6.x-compat" diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt new file mode 100644 index 00000000..128faaf5 --- /dev/null +++ b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt @@ -0,0 +1,51 @@ +package io.github.kdroidfilter.composemediaplayer + +import android.app.Activity +import android.app.PictureInPictureParams +import android.content.pm.PackageManager +import android.os.Build +import android.util.Rational +import com.kdroid.androidcontextprovider.ContextProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.lang.ref.WeakReference + +actual class PipController { + actual val isInPipMode: StateFlow = _isInPipMode.asStateFlow() + + + + companion object { + lateinit var activity: WeakReference + private val _isInPipMode = MutableStateFlow(false) + + fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + _isInPipMode.value = isInPictureInPictureMode + } + } + + + + actual fun enterPip() { + _isInPipMode.value = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val currentActivity = activity?.get() + + if (currentActivity != null && isPipSupported()) { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .build() + currentActivity.enterPictureInPictureMode(params) + } + } + } + + actual fun isPipSupported(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val context = activity?.get() ?: ContextProvider.getContext() + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + return false + } +} \ No newline at end of file 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 3accae82..7a7643d6 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 @@ -1,12 +1,18 @@ package io.github.kdroidfilter.composemediaplayer +import android.app.Activity +import android.app.PictureInPictureParams import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build +import android.util.Rational import androidx.annotation.OptIn import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf @@ -33,6 +39,7 @@ import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.AndroidFile import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* +import java.lang.ref.WeakReference @OptIn(UnstableApi::class) actual fun createVideoPlayerState(): VideoPlayerState = @@ -246,6 +253,8 @@ open class DefaultVideoPlayerState: VideoPlayerState { override val durationText: String get() = formatTime(_duration) override val currentTime: Double get() = _currentTime + override val pipController = PipController() + init { audioProcessor.setOnAudioLevelUpdateListener { left, right -> @@ -632,6 +641,10 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } + override fun enterPip() = + pipController.enterPip() + + override fun seekTo(value: Float) { if (_duration > 0 && !isPlayerReleased) { val targetTime = (value / 1000.0) * _duration diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt new file mode 100644 index 00000000..1e4004ad --- /dev/null +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt @@ -0,0 +1,10 @@ +package io.github.kdroidfilter.composemediaplayer + +import kotlinx.coroutines.flow.StateFlow + +expect class PipController() { + val isInPipMode: StateFlow + + fun enterPip() + fun isPipSupported(): Boolean +} \ No newline at end of file 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 9174d939..7bba5a12 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -92,12 +92,16 @@ interface VideoPlayerState { */ fun stop() + fun enterPip() = Unit + /** * Seeks to a specific playback position based on the provided normalized value. */ fun seekTo(value: Float) fun toggleFullscreen() + val pipController: PipController + // Functions to manage media sources /** * Opens a video file or URL for playback. @@ -179,6 +183,7 @@ data class PreviewableVideoPlayerState( override val availableSubtitleTracks: MutableList = emptyList().toMutableList(), override var subtitleTextStyle: TextStyle = TextStyle.Default, override var subtitleBackgroundColor: Color = Color.Transparent, + override val pipController: PipController = PipController() ) : VideoPlayerState { override fun play() {} override fun pause() {} @@ -197,4 +202,4 @@ data class PreviewableVideoPlayerState( 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/PipController.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt new file mode 100644 index 00000000..d2fe4e5e --- /dev/null +++ b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt @@ -0,0 +1,27 @@ +package io.github.kdroidfilter.composemediaplayer + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import platform.AVKit.AVPictureInPictureController + +actual class PipController { + private val _isInPipMode = MutableStateFlow(false) + actual val isInPipMode: StateFlow = _isInPipMode.asStateFlow() + + internal var pipController: AVPictureInPictureController? = null + + actual fun enterPip() { + if (pipController?.isPictureInPicturePossible() == true) { + pipController?.startPictureInPicture() + } + } + + actual fun isPipSupported(): Boolean { + return AVPictureInPictureController.isPictureInPictureSupported() + } + + internal fun setInPipMode(value: Boolean) { + _isInPipMode.value = value + } +} \ 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 c938d8b6..9ffcb010 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 @@ -22,6 +22,8 @@ import platform.AVFAudio.AVAudioSessionCategoryPlayback import platform.AVFAudio.AVAudioSessionModeMoviePlayback import platform.AVFAudio.setActive import platform.AVFoundation.* +import platform.AVKit.AVPictureInPictureController +import platform.AVKit.AVPictureInPictureControllerDelegateProtocol import platform.CoreGraphics.CGFloat import platform.CoreMedia.CMTimeGetSeconds import platform.CoreMedia.CMTimeMake @@ -117,6 +119,23 @@ open class DefaultVideoPlayerState: VideoPlayerState { var playerLayer: AVPlayerLayer? by mutableStateOf(null) internal set + + + override val pipController = PipController() + + + private val pipDelegate = object : NSObject(), AVPictureInPictureControllerDelegateProtocol { + override fun pictureInPictureControllerDidStartPictureInPicture(pictureInPictureController: AVPictureInPictureController) { + _isPip = true + } + + override fun pictureInPictureControllerDidStopPictureInPicture(pictureInPictureController: AVPictureInPictureController) { + _isPip = false + } + } + + private var _isPip by mutableStateOf(false) + // Periodic observer for position updates (≈60 fps) private var timeObserverToken: Any? = null @@ -207,6 +226,14 @@ open class DefaultVideoPlayerState: VideoPlayerState { ) } + override fun enterPip() { + println("is pipController null ${pipController == null}") + println("is isPictureInPicturePossible ${pipController.pipController?.isPictureInPicturePossible() == true}") + if (pipController.pipController?.isPictureInPicturePossible() == true) { + pipController.pipController?.startPictureInPicture() + } + } + private fun setupObservers(player: AVPlayer, item: AVPlayerItem) { // KVO for timeControlStatus (Playing, Paused, Loading) timeControlStatusObserver = player.observe("timeControlStatus") { _ -> @@ -693,4 +720,4 @@ private fun NSObject.observe( context = null ) return observer -} \ No newline at end of file +} 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 5a7df9ca..b347a9f3 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 @@ -14,21 +14,17 @@ import co.touchlab.kermit.Logger import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier import io.github.kdroidfilter.composemediaplayer.util.toTimeMs -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.CValue import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.ObjCClass import kotlinx.cinterop.cValue import platform.AVFoundation.AVLayerVideoGravityResize import platform.AVFoundation.AVLayerVideoGravityResizeAspect import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill -import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerLayer +import platform.AVKit.AVPictureInPictureController import platform.CoreGraphics.CGRect -import platform.Foundation.NSCoder +import platform.QuartzCore.CATransaction import platform.UIKit.UIColor import platform.UIKit.UIView -import platform.UIKit.UIViewMeta @OptIn(ExperimentalForeignApi::class) @Composable @@ -39,7 +35,14 @@ actual fun VideoPlayerSurface( overlay: @Composable () -> Unit ) { // Set pauseOnDispose to false to prevent pausing during screen rotation - VideoPlayerSurfaceImpl(playerState, modifier, contentScale, overlay, isInFullscreenView = false, pauseOnDispose = false) + VideoPlayerSurfaceImpl( + playerState, + modifier, + contentScale, + overlay, + isInFullscreenView = false, + pauseOnDispose = false + ) } @OptIn(ExperimentalForeignApi::class) @@ -80,45 +83,53 @@ fun VideoPlayerSurfaceImpl( height = playerState.metadata.height ), factory = { - PlayerUIView(frame = cValue()).apply { - player = currentPlayer - backgroundColor = UIColor.blackColor - clipsToBounds = true + val playerLayer = AVPlayerLayer() + playerLayer.player = currentPlayer + + (playerState as? DefaultVideoPlayerState)?.let { state -> + state.playerLayer = playerLayer + state.pipController?.pipController = AVPictureInPictureController(playerLayer = playerLayer) + } - (playerState as? DefaultVideoPlayerState)?.playerLayer = layer as? AVPlayerLayer + PlayerContainerView(playerLayer).apply { + backgroundColor = UIColor.blackColor } }, - update = { playerView -> - playerView.player = currentPlayer + update = { view -> - // Hide or show the view depending on the presence of media - playerView.hidden = !playerState.hasMedia + view.playerLayer.player = currentPlayer + view.hidden = !playerState.hasMedia - // Update the videoGravity when contentScale changes val videoGravity = when (contentScale) { ContentScale.Crop, - ContentScale.FillHeight -> AVLayerVideoGravityResizeAspectFill // ⬅️ changement - ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill // (même logique) - ContentScale.FillBounds -> AVLayerVideoGravityResize // pas d’aspect-ratio + ContentScale.FillHeight -> AVLayerVideoGravityResizeAspectFill + + ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill + ContentScale.FillBounds -> AVLayerVideoGravityResize ContentScale.Fit, ContentScale.Inside -> AVLayerVideoGravityResizeAspect else -> AVLayerVideoGravityResizeAspect } - playerView.videoGravity = videoGravity + view.playerLayer.videoGravity = videoGravity + + view.updateLayerFrame() Logger.d { "View configured with contentScale: $contentScale, videoGravity: $videoGravity" } + }, - onRelease = { playerView -> - playerView.player = null + onRelease = { view -> + if (view is PlayerContainerView) { + view.playerLayer.player = null + } } ) // Add Compose-based subtitle layer if (playerState.subtitlesEnabled && playerState.currentSubtitleTrack != null) { // Calculate current time in milliseconds - val currentTimeMs = (playerState.sliderPos / 1000f * - playerState.durationText.toTimeMs()).toLong() + val currentTimeMs = (playerState.sliderPos / 1000f * + playerState.durationText.toTimeMs()).toLong() // Calculate duration in milliseconds val durationMs = playerState.durationText.toTimeMs() @@ -151,25 +162,21 @@ fun VideoPlayerSurfaceImpl( } } -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private class PlayerUIView : UIView { - companion object : UIViewMeta() { - override fun layerClass(): ObjCClass = AVPlayerLayer +@OptIn(ExperimentalForeignApi::class) +private class PlayerContainerView(val playerLayer: AVPlayerLayer) : UIView(frame = cValue()) { + init { + layer.addSublayer(playerLayer) } - constructor(frame: CValue) : super(frame) - constructor(coder: NSCoder) : super(coder) - - var player: AVPlayer? - get() = (layer as? AVPlayerLayer)?.player - set(value) { - (layer as? AVPlayerLayer)?.player = value - } + override fun layoutSubviews() { + super.layoutSubviews() + updateLayerFrame() + } - var videoGravity: String? - get() = (layer as? AVPlayerLayer)?.videoGravity - set(value) { - (layer as? AVPlayerLayer)?.videoGravity = value - } + fun updateLayerFrame() { + CATransaction.begin() + CATransaction.setDisableActions(true) + playerLayer.frame = bounds + CATransaction.commit() + } } - diff --git a/sample/composeApp/src/androidMain/AndroidManifest.xml b/sample/composeApp/src/androidMain/AndroidManifest.xml index df8f1d62..db50ac9d 100644 --- a/sample/composeApp/src/androidMain/AndroidManifest.xml +++ b/sample/composeApp/src/androidMain/AndroidManifest.xml @@ -1,5 +1,5 @@ - + @@ -11,11 +11,12 @@ android:usesCleartextTraffic="true" android:theme="@android:style/Theme.Material.NoActionBar"> + android:name=".AppActivity" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize" + android:launchMode="singleInstance" + android:windowSoftInputMode="adjustPan" + android:supportsPictureInPicture="true" + android:exported="true" tools:targetApi="24"> diff --git a/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt b/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt index cd114034..e45cf372 100644 --- a/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt +++ b/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt @@ -1,15 +1,25 @@ package sample.app +import android.content.res.Configuration import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import io.github.kdroidfilter.composemediaplayer.PipController import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init +import java.lang.ref.WeakReference class AppActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) FileKit.init(this) + PipController.activity = WeakReference(this) setContent { App() } } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + PipController.onPictureInPictureModeChanged(isInPictureInPictureMode) + } } \ No newline at end of file diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt index 40e2f622..8cae8095 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt @@ -19,43 +19,43 @@ fun App() { MaterialTheme(colorScheme = if(isSystemInDarkMode()) darkColorScheme() else lightColorScheme()) { // Navigation state var currentScreen by remember { mutableStateOf(Screen.SinglePlayer) } - - Scaffold( - bottomBar = { - NavigationBar { - NavigationBarItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Single Player") }, - label = { Text("Single Player") }, - selected = currentScreen == Screen.SinglePlayer, - onClick = { currentScreen = Screen.SinglePlayer } - ) - NavigationBarItem( - icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Multi Player") }, - label = { Text("Multi Player") }, - selected = currentScreen == Screen.MultiPlayer, - onClick = { currentScreen = Screen.MultiPlayer } - ) - NavigationBarItem( - icon = { Icon(Icons.Default.Subtitles, contentDescription = "Video Attachment") }, - label = { Text("Video Attachment") }, - selected = currentScreen == Screen.VideoAttachmentPlayer, - onClick = { currentScreen = Screen.VideoAttachmentPlayer } - ) - } - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background) - ) { - when (currentScreen) { - Screen.SinglePlayer -> SinglePlayerScreen() - Screen.MultiPlayer -> MultiPlayerScreen() - Screen.VideoAttachmentPlayer -> VideoAttachmentPlayerScreen() - } - } - } + SinglePlayerScreen() +// Scaffold( +// bottomBar = { +// NavigationBar { +// NavigationBarItem( +// icon = { Icon(Icons.Default.Home, contentDescription = "Single Player") }, +// label = { Text("Single Player") }, +// selected = currentScreen == Screen.SinglePlayer, +// onClick = { currentScreen = Screen.SinglePlayer } +// ) +// NavigationBarItem( +// icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Multi Player") }, +// label = { Text("Multi Player") }, +// selected = currentScreen == Screen.MultiPlayer, +// onClick = { currentScreen = Screen.MultiPlayer } +// ) +// NavigationBarItem( +// icon = { Icon(Icons.Default.Subtitles, contentDescription = "Video Attachment") }, +// label = { Text("Video Attachment") }, +// selected = currentScreen == Screen.VideoAttachmentPlayer, +// onClick = { currentScreen = Screen.VideoAttachmentPlayer } +// ) +// } +// } +// ) { paddingValues -> +// Box( +// modifier = Modifier +// .fillMaxSize() +// .padding(paddingValues) +// .background(MaterialTheme.colorScheme.background) +// ) { +// when (currentScreen) { +// Screen.SinglePlayer -> SinglePlayerScreen() +// Screen.MultiPlayer -> MultiPlayerScreen() +// Screen.VideoAttachmentPlayer -> VideoAttachmentPlayerScreen() +// } +// } +// } } } diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt index 93e4b676..02e64aa0 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* @@ -214,5 +215,14 @@ fun VideoAttachmentPlayerScreen() { } } } + item { + val pipController = remember { PipController() } + Button(onClick = { + pipController.enterPip() + }) { + Icon(Icons.Default.PictureInPicture, contentDescription = "Enter PiP") + Text("Enter PiP") + } + } } } \ No newline at end of file diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt index d752dd46..6fb3a578 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt @@ -302,6 +302,8 @@ fun PrimaryControls( ) { Icon(Icons.Default.AspectRatio, contentDescription = "Content Scale") } + + } } diff --git a/sample/iosApp/iosApp.xcodeproj/project.pbxproj b/sample/iosApp/iosApp.xcodeproj/project.pbxproj index 4ca6587d..2acdc237 100644 --- a/sample/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/sample/iosApp/iosApp.xcodeproj/project.pbxproj @@ -113,8 +113,8 @@ /* Begin PBXShellScriptBuildPhase section */ A9D80A052AAB5CDE006C8738 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; alwaysOutOfDate = 1; + buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( @@ -127,7 +127,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eo pipefail\n# Ensure we run from the iOS project folder's parent (sample)\ncd \"$SRCROOT/..\"\n\n# Prefer JDK 17 (or 21) for Gradle, if available\nif command -v /usr/libexec/java_home >/dev/null 2>&1; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || /usr/libexec/java_home -v 21 2>/dev/null || true)\nfi\n\n# Run Gradle task to build and embed the Kotlin framework for Xcode\n./../gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode --no-configuration-cache --stacktrace --info\n"; + shellScript = "set -eo pipefail\n# Ensure we run from the iOS project folder's parent (sample)\ncd \"$SRCROOT/..\"\n\n# Prefer JDK 17 (or 21) for Gradle, if available\nif command -v /usr/libexec/java_home >/dev/null 2>&1; then\n export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || /usr/libexec/java_home -v 21 2>/dev/null || true)\nfi\n\n# Run Gradle task to build and embed the Kotlin framework for Xcode\n./../gradlew :sample:composeApp:embedAndSignAppleFrameworkForXcode --no-configuration-cache --stacktrace --info\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -262,6 +262,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 745RV8BFT2; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -271,7 +272,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = sample.app.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = sample.app.iosApp.composemediaplayer; PRODUCT_NAME = "Multiplatform App"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -284,6 +285,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 745RV8BFT2; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -293,7 +295,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = sample.app.iosApp; + PRODUCT_BUNDLE_IDENTIFIER = sample.app.iosApp.composemediaplayer; PRODUCT_NAME = "Multiplatform App"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -325,4 +327,4 @@ /* End XCConfigurationList section */ }; rootObject = A93A952F29CC810C00F8E227 /* Project object */; -} \ No newline at end of file +} diff --git a/sample/iosApp/iosApp/Info.plist b/sample/iosApp/iosApp/Info.plist index 4d08dbff..db8a022e 100644 --- a/sample/iosApp/iosApp/Info.plist +++ b/sample/iosApp/iosApp/Info.plist @@ -2,37 +2,39 @@ - CADisableMinimumFrameDurationOnPhone - - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneClassName - UIWindowScene - - - - - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSFileAccessUsageDescription - This app needs access to files to play local videos - UIFileSharingEnabled - + CADisableMinimumFrameDurationOnPhone + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSFileAccessUsageDescription + This app needs access to files to play local videos + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIBackgroundModes + + audio + + UIFileSharingEnabled + From 8e5fc97d35be945e7caff4e0c465fde9b519cf41 Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:41:57 +0100 Subject: [PATCH 2/6] feat: add pip support for ios and android --- gradle.properties | 5 +- mediaplayer/build.gradle.kts | 19 +- .../composemediaplayer/PipController.kt | 1 + .../composemediaplayer/PipController.jvm.kt | 16 + .../VideoPlayerState.jvm.kt | 2 + .../linux/LinuxVideoPlayerState.kt | 5 +- .../mac/MacVideoPlayerState.kt | 3 + .../windows/WindowsVideoPlayerState.kt | 5 +- .../composemediaplayer/PipController.web.kt | 16 + .../VideoPlayerState.web.kt | 2 + .../app/singleplayer/SinglePlayerScreen.kt | 314 ++++++++++-------- settings.gradle.kts | 1 + 12 files changed, 238 insertions(+), 151 deletions(-) create mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt create mode 100644 mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt diff --git a/gradle.properties b/gradle.properties index 03fc209d..8563f019 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # Enable native access to avoid Java 21+ warnings from Gradle native-platform (System::load) org.gradle.jvmargs=-Xmx8G --enable-native-access=ALL-UNNAMED org.gradle.caching=true -org.gradle.configuration-cache=true +org.gradle.configuration-cache=false org.gradle.daemon=true org.gradle.parallel=true @@ -15,4 +15,5 @@ kotlin.mpp.enableCInteropCommonization=true android.useAndroidX=true android.nonTransitiveRClass=true -org.jetbrains.compose.experimental.macos.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.macos.enabled=true + diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index be40107b..d3fc8727 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -13,15 +13,18 @@ plugins { alias(libs.plugins.vannitktech.maven.publish) alias(libs.plugins.dokka) alias(libs.plugins.kotlinCocoapods) + id("maven-publish") } group = "io.github.kdroidfilter.composemediaplayer" -val ref = System.getenv("GITHUB_REF") ?: "" -val version = if (ref.startsWith("refs/tags/")) { - val tag = ref.removePrefix("refs/tags/") - if (tag.startsWith("v")) tag.substring(1) else tag -} else "dev" +val ref = System.getenv("GITHUB_REF") ?: "8.0.0" +val version = "1.0.0-pushcroll-SNAPSHOT" + +// if (ref.startsWith("refs/tags/")) { +// val tag = ref.removePrefix("refs/tags/") +// if (tag.startsWith("v")) tag.substring(1) else tag +//} else "dev" tasks.withType().configureEach { @@ -51,7 +54,7 @@ kotlin { iosX64(), ).forEach { target -> target.compilations.getByName("main") { - // The default file path is src/nativeInterop/cinterop/.def + // Tahe default file path is src/nativeInterop/cinterop/.def val nskeyvalueobserving by cinterops.creating } } @@ -230,7 +233,5 @@ mavenPublishing { } } - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) - - signAllPublications() + this.signAllPublications() } diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt index 1e4004ad..db2bba65 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt @@ -3,6 +3,7 @@ package io.github.kdroidfilter.composemediaplayer import kotlinx.coroutines.flow.StateFlow expect class PipController() { + val isInPipMode: StateFlow fun enterPip() diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt new file mode 100644 index 00000000..bf036c81 --- /dev/null +++ b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt @@ -0,0 +1,16 @@ +package io.github.kdroidfilter.composemediaplayer + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +actual class PipController actual constructor() { + actual val isInPipMode: StateFlow + get() = MutableStateFlow(false).asStateFlow() + + actual fun enterPip() { + } + + actual fun isPipSupported(): Boolean = false + +} 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 bdb5b7dd..43719cb6 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 @@ -37,6 +37,8 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState( */ @Stable open class DefaultVideoPlayerState: VideoPlayerState { + + override lateinit var pipController: PipController val delegate: VideoPlayerState = when { Platform.isWindows() -> WindowsVideoPlayerState() Platform.isMac() -> MacVideoPlayerState() 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 2874527e..866511a8 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 @@ -35,6 +35,7 @@ import org.freedesktop.gstreamer.event.SeekFlags import org.freedesktop.gstreamer.event.SeekType import org.freedesktop.gstreamer.message.MessageType import com.sun.jna.Pointer +import io.github.kdroidfilter.composemediaplayer.PipController import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType @@ -59,6 +60,8 @@ import kotlin.math.pow @Stable class LinuxVideoPlayerState : VideoPlayerState { + override lateinit var pipController: PipController + companion object { // Flag to enable text subtitles (GST_PLAY_FLAG_TEXT) const val GST_PLAY_FLAG_TEXT = 1 shl 2 @@ -908,7 +911,7 @@ class LinuxVideoPlayerState : VideoPlayerState { } // Single memory copy: GStreamer buffer → Skia bitmap - val dstRowBytes = pixmap.rowBytes.toInt() + val dstRowBytes = pixmap.rowBytes val dstSizeBytes = dstRowBytes.toLong() * height.toLong() val dstBuffer = Pointer(pixelsAddr).getByteBuffer(0, dstSizeBytes) 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 3b688b2f..ae21df6d 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,6 +13,7 @@ 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.PipController import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata @@ -46,6 +47,8 @@ internal val macLogger = Logger.withTag("MacVideoPlayerState") */ class MacVideoPlayerState : VideoPlayerState { + override lateinit var pipController: PipController + // Main state variables private val mainMutex = Mutex() private val frameMutex = Mutex() 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 3db9060b..c72ab503 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,6 +21,7 @@ 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.PipController import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError @@ -72,7 +73,9 @@ internal val windowsLogger = Logger.withTag("WindowsVideoPlayerState") * Windows implementation of the video player state. * Handles media playback using Media Foundation on Windows platform. */ -class WindowsVideoPlayerState : VideoPlayerState { +class WindowsVideoPlayerState() : VideoPlayerState { + + override lateinit var pipController: PipController companion object { private val isMfBootstrapped = AtomicBoolean(false) diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt new file mode 100644 index 00000000..3bc46062 --- /dev/null +++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt @@ -0,0 +1,16 @@ +package io.github.kdroidfilter.composemediaplayer + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +actual class PipController actual constructor() { + actual val isInPipMode: StateFlow + get() = MutableStateFlow(false).asStateFlow() + + actual fun enterPip() { + } + + actual fun isPipSupported(): Boolean = false +} + 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 bbc6d0b4..b8dfad5f 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 @@ -35,6 +35,8 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState( @Stable open class DefaultVideoPlayerState: VideoPlayerState { + override lateinit var pipController: PipController + // Variable to store the last opened URI for potential replay private var lastUri: String? = null 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 72ab80ac..1a7a8159 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt @@ -2,6 +2,7 @@ package sample.app.singleplayer // Import the extracted composable functions import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,8 +13,16 @@ 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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -21,6 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import io.github.kdroidfilter.composemediaplayer.InitialPlayerState @@ -28,6 +39,7 @@ 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.VideoPlayerSurface import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.dialogs.FileKitType @@ -110,55 +122,107 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { var showContentScaleDialog by remember { mutableStateOf(false) } // State to store the selected content scale - var selectedContentScale by remember { mutableStateOf(ContentScale.Fit) } + var selectedContentScale by remember { mutableStateOf(ContentScale.Fit) } - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - val isLandscape = maxWidth > maxHeight + val isPipState by playerState.pipController.isInPipMode.collectAsState() - if (isLandscape) { - // Landscape layout (horizontal) - Row( + LaunchedEffect(isPipState) { + println("is pip mode $isPipState") + } + + +// BoxWithConstraints( +// modifier = Modifier +// .fillMaxSize() +// .background(MaterialTheme.colorScheme.background) +// ) { +// val isLandscape = maxWidth > maxHeight +// +// if (isLandscape) { +// // Landscape layout (horizontal) +// Row( +// modifier = Modifier +// .fillMaxSize() +// .padding(16.dp) +// ) { +// // Left side: Video and Timeline +// Column( +// modifier = Modifier +// .weight(1f) +// .fillMaxHeight() +// ) { +// // Header with title +// PlayerHeader(title = "Compose Media Player Sample") +// +// VideoPlayerSurface( +// playerState = playerState, +// modifier = Modifier +// .weight(1f) +// .fillMaxWidth(), +// contentScale = selectedContentScale +// ) +// +// Spacer(modifier = Modifier.height(8.dp)) +// +// // Video timeline and slider +// TimelineControls(playerState = playerState) +// } +// +// Spacer(modifier = Modifier.width(16.dp)) +// +// // Right side: Controls +// Column( +// modifier = Modifier +// .weight(1f) +// .fillMaxHeight() +// .padding(top = 48.dp) // Align with video content +// ) { +// // Primary controls: load video, play/pause, stop +// PrimaryControls( +// playerState = playerState, +// videoFileLauncher = { videoFileLauncher.launch() }, +// onSubtitleDialogRequest = { showSubtitleDialog = true }, +// onMetadataDialogRequest = { showMetadataDialog = true }, +// onContentScaleDialogRequest = { showContentScaleDialog = true } +// ) +// +// Spacer(modifier = Modifier.height(16.dp)) +// +// // Secondary controls: volume, loop, video URL input +// ControlsCard( +// playerState = playerState, +// videoUrl = videoUrl, +// onVideoUrlChange = { videoUrl = it }, +// onOpenUrl = { +// if (videoUrl.isNotEmpty()) { +// playerState.openUri(videoUrl, initialPlayerState) +// } +// }, +// initialPlayerState = initialPlayerState, +// onInitialPlayerStateChange = { initialPlayerState = it } +// ) +// } +// } +// } else { + // Portrait layout (vertical) + Column( modifier = Modifier .fillMaxSize() .padding(16.dp) ) { - // Left side: Video and Timeline - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - ) { - // Header with title - PlayerHeader(title = "Compose Media Player Sample",) + if (!isPipState) { + + PlayerHeader(title = "Compose Media Player Sample") // Video display area - VideoDisplay( - playerState = playerState, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentScale = selectedContentScale - ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Video timeline and slider TimelineControls(playerState = playerState) - } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Right side: Controls - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(top = 48.dp) // Align with video content - ) { // Primary controls: load video, play/pause, stop PrimaryControls( playerState = playerState, @@ -184,114 +248,88 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { onInitialPlayerStateChange = { initialPlayerState = it } ) } - } - } else { - // Portrait layout (vertical) - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - // Header with title - PlayerHeader(title = "Compose Media Player Sample",) - - // Video display area - VideoDisplay( - playerState = playerState, + Box( modifier = Modifier .weight(1f) - .fillMaxWidth(), - contentScale = selectedContentScale - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Video timeline and slider - TimelineControls(playerState = playerState) - - Spacer(modifier = Modifier.height(16.dp)) - - // Primary controls: load video, play/pause, stop - PrimaryControls( - playerState = playerState, - videoFileLauncher = { videoFileLauncher.launch() }, - onSubtitleDialogRequest = { showSubtitleDialog = true }, - onMetadataDialogRequest = { showMetadataDialog = true }, - onContentScaleDialogRequest = { showContentScaleDialog = true } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Secondary controls: volume, loop, video URL input - ControlsCard( - playerState = playerState, - videoUrl = videoUrl, - onVideoUrlChange = { videoUrl = it }, - onOpenUrl = { - if (videoUrl.isNotEmpty()) { - playerState.openUri(videoUrl, initialPlayerState) - } - }, - initialPlayerState = initialPlayerState, - onInitialPlayerStateChange = { initialPlayerState = it } - ) - } - } - - // Animated error Snackbar - playerState.error?.let { error -> - ErrorSnackbar( - error = error, - onDismiss = { playerState.clearError() }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp) - ) - } - - // Subtitle management dialog - if (showSubtitleDialog) { - SubtitleManagementDialog( - subtitleTracks = subtitleTracks, - selectedSubtitleTrack = selectedSubtitleTrack, - onSubtitleSelected = { track -> - selectedSubtitleTrack = track - playerState.selectSubtitleTrack(track) - }, - onDisableSubtitles = { - selectedSubtitleTrack = null - playerState.disableSubtitles() - }, - subtitleFileLauncher = { subtitleFileLauncher.launch() }, - onDismiss = { - showSubtitleDialog = false + ) { + VideoPlayerSurface( + playerState = playerState, + modifier = Modifier + .fillMaxSize(), + contentScale = selectedContentScale + ) } - ) - } - // Metadata dialog - if (showMetadataDialog) { - MetadataDialog( - playerState = playerState, - onDismiss = { - showMetadataDialog = false - } - ) - } + if (!isPipState) { - // Content scale dialog - if (showContentScaleDialog) { - ContentScaleDialog( - currentContentScale = selectedContentScale, - onContentScaleSelected = { contentScale -> - selectedContentScale = contentScale - showContentScaleDialog = false - }, - onDismiss = { - showContentScaleDialog = false + FilledIconButton( + onClick = { playerState.pipController.enterPip() }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon(Icons.Default.PictureInPicture, contentDescription = "Content Scale") + } } - ) + } } - } - } +// if (!isPipState) { +// +// // Animated error Snackbar +// playerState.error?.let { error -> +// ErrorSnackbar( +// error = error, +// onDismiss = { playerState.clearError() }, +// modifier = Modifier +// .align(Alignment.BottomCenter) +// .padding(16.dp) +// ) +// } +// +// // Subtitle management dialog +// if (showSubtitleDialog) { +// SubtitleManagementDialog( +// subtitleTracks = subtitleTracks, +// selectedSubtitleTrack = selectedSubtitleTrack, +// onSubtitleSelected = { track -> +// selectedSubtitleTrack = track +// playerState.selectSubtitleTrack(track) +// }, +// onDisableSubtitles = { +// selectedSubtitleTrack = null +// playerState.disableSubtitles() +// }, +// subtitleFileLauncher = { subtitleFileLauncher.launch() }, +// onDismiss = { +// showSubtitleDialog = false +// } +// ) +// } +// +// // Metadata dialog +// if (showMetadataDialog) { +// MetadataDialog( +// playerState = playerState, +// onDismiss = { +// showMetadataDialog = false +// } +// ) +// } +// +// // Content scale dialog +// if (showContentScaleDialog) { +// ContentScaleDialog( +// currentContentScale = selectedContentScale, +// onContentScaleSelected = { contentScale -> +// selectedContentScale = contentScale +// showContentScaleDialog = false +// }, +// onDismiss = { +// showContentScaleDialog = false +// } +// ) +// } +// } +// } +// } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e45b71b4..9c3e8e18 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ pluginManagement { } dependencyResolutionManagement { + repositories { google { content { From 9912f23c6186d83596c20a03b41963c06110d7ab Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:12:13 +0100 Subject: [PATCH 3/6] fix: remove logs --- .../kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt | 2 -- .../kotlin/sample/app/singleplayer/SinglePlayerScreen.kt | 5 ----- 2 files changed, 7 deletions(-) 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 9ffcb010..e97b0627 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 @@ -227,8 +227,6 @@ open class DefaultVideoPlayerState: VideoPlayerState { } override fun enterPip() { - println("is pipController null ${pipController == null}") - println("is isPictureInPicturePossible ${pipController.pipController?.isPictureInPicturePossible() == true}") if (pipController.pipController?.isPictureInPicturePossible() == true) { pipController.pipController?.startPictureInPicture() } 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 1a7a8159..431c76bb 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt @@ -126,11 +126,6 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { val isPipState by playerState.pipController.isInPipMode.collectAsState() - LaunchedEffect(isPipState) { - println("is pip mode $isPipState") - } - - // BoxWithConstraints( // modifier = Modifier // .fillMaxSize() From 1f7bb028341d8ce0201c209ef7032f79a23c4498 Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:58:06 +0100 Subject: [PATCH 4/6] fix: ios video surface ui --- .../VideoPlayerSurface.ios.kt | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) 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 b347a9f3..10e8f0f4 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 @@ -14,17 +14,22 @@ import co.touchlab.kermit.Logger import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier import io.github.kdroidfilter.composemediaplayer.util.toTimeMs +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.CValue import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCClass import kotlinx.cinterop.cValue import platform.AVFoundation.AVLayerVideoGravityResize import platform.AVFoundation.AVLayerVideoGravityResizeAspect import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill +import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerLayer import platform.AVKit.AVPictureInPictureController import platform.CoreGraphics.CGRect -import platform.QuartzCore.CATransaction +import platform.Foundation.NSCoder import platform.UIKit.UIColor import platform.UIKit.UIView +import platform.UIKit.UIViewMeta @OptIn(ExperimentalForeignApi::class) @Composable @@ -35,14 +40,7 @@ actual fun VideoPlayerSurface( overlay: @Composable () -> Unit ) { // Set pauseOnDispose to false to prevent pausing during screen rotation - VideoPlayerSurfaceImpl( - playerState, - modifier, - contentScale, - overlay, - isInFullscreenView = false, - pauseOnDispose = false - ) + VideoPlayerSurfaceImpl(playerState, modifier, contentScale, overlay, isInFullscreenView = false, pauseOnDispose = false) } @OptIn(ExperimentalForeignApi::class) @@ -83,45 +81,42 @@ fun VideoPlayerSurfaceImpl( height = playerState.metadata.height ), factory = { - val playerLayer = AVPlayerLayer() - playerLayer.player = currentPlayer + PlayerUIView(frame = cValue()).apply { + player = currentPlayer + backgroundColor = UIColor.blackColor + clipsToBounds = true - (playerState as? DefaultVideoPlayerState)?.let { state -> - state.playerLayer = playerLayer - state.pipController?.pipController = AVPictureInPictureController(playerLayer = playerLayer) - } - PlayerContainerView(playerLayer).apply { - backgroundColor = UIColor.blackColor + (playerState as? DefaultVideoPlayerState)?.let { state -> + val playerLayer = layer as? AVPlayerLayer ?: return@let + state.playerLayer = playerLayer + state.pipController.pipController = AVPictureInPictureController(playerLayer = playerLayer) + } } }, - update = { view -> + update = { playerView -> + playerView.player = currentPlayer - view.playerLayer.player = currentPlayer - view.hidden = !playerState.hasMedia + // Hide or show the view depending on the presence of media + playerView.hidden = !playerState.hasMedia + // Update the videoGravity when contentScale changes val videoGravity = when (contentScale) { ContentScale.Crop, - ContentScale.FillHeight -> AVLayerVideoGravityResizeAspectFill - - ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill - ContentScale.FillBounds -> AVLayerVideoGravityResize + ContentScale.FillHeight -> AVLayerVideoGravityResizeAspectFill // ⬅️ changement + ContentScale.FillWidth -> AVLayerVideoGravityResizeAspectFill // (même logique) + ContentScale.FillBounds -> AVLayerVideoGravityResize // pas d’aspect-ratio ContentScale.Fit, ContentScale.Inside -> AVLayerVideoGravityResizeAspect else -> AVLayerVideoGravityResizeAspect } - view.playerLayer.videoGravity = videoGravity - - view.updateLayerFrame() + playerView.videoGravity = videoGravity Logger.d { "View configured with contentScale: $contentScale, videoGravity: $videoGravity" } - }, - onRelease = { view -> - if (view is PlayerContainerView) { - view.playerLayer.player = null - } + onRelease = { playerView -> + playerView.player = null } ) @@ -162,21 +157,24 @@ fun VideoPlayerSurfaceImpl( } } -@OptIn(ExperimentalForeignApi::class) -private class PlayerContainerView(val playerLayer: AVPlayerLayer) : UIView(frame = cValue()) { - init { - layer.addSublayer(playerLayer) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class PlayerUIView : UIView { + companion object : UIViewMeta() { + override fun layerClass(): ObjCClass = AVPlayerLayer } - override fun layoutSubviews() { - super.layoutSubviews() - updateLayerFrame() - } + constructor(frame: CValue) : super(frame) + constructor(coder: NSCoder) : super(coder) - fun updateLayerFrame() { - CATransaction.begin() - CATransaction.setDisableActions(true) - playerLayer.frame = bounds - CATransaction.commit() - } + var player: AVPlayer? + get() = (layer as? AVPlayerLayer)?.player + set(value) { + (layer as? AVPlayerLayer)?.player = value + } + + var videoGravity: String? + get() = (layer as? AVPlayerLayer)?.videoGravity + set(value) { + (layer as? AVPlayerLayer)?.videoGravity = value + } } From 03c4b4fdd748d19e74c504d8190a3bdd15446e1b Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:04:34 +0100 Subject: [PATCH 5/6] feat: add pip support for android and ios --- .run/Browser.run.xml | 3 + gradle.properties | 5 +- gradle/libs.versions.toml | 3 +- mediaplayer/build.gradle.kts | 17 +- .../PipController.android.kt | 51 --- .../VideoPlayerState.android.kt | 75 +++- .../VideoPlayerSurface.android.kt | 95 ++++-- .../composemediaplayer/PipController.kt | 11 - .../composemediaplayer/VideoPlayerState.kt | 20 +- .../composemediaplayer/util/PipResult.kt | 9 + .../composemediaplayer/PipController.ios.kt | 27 -- .../VideoPlayerState.ios.kt | 72 ++-- .../VideoPlayerSurface.ios.kt | 2 +- .../composemediaplayer/PipController.jvm.kt | 16 - .../VideoPlayerState.jvm.kt | 2 - .../linux/LinuxVideoPlayerState.kt | 3 - .../mac/MacVideoPlayerState.kt | 3 - .../windows/WindowsVideoPlayerState.kt | 3 - .../composemediaplayer/PipController.web.kt | 16 - .../VideoPlayerState.web.kt | 2 - .../src/androidMain/kotlin/sample/app/main.kt | 7 +- .../src/commonMain/kotlin/sample/app/App.kt | 76 ++--- .../sample/app/VideoAttachmentPlayer.kt | 10 - .../app/singleplayer/PlayerComponents.kt | 223 +++++++----- .../app/singleplayer/SinglePlayerScreen.kt | 319 ++++++++---------- settings.gradle.kts | 1 - 26 files changed, 537 insertions(+), 534 deletions(-) delete mode 100644 mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt delete mode 100644 mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt create mode 100644 mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/PipResult.kt delete mode 100644 mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt delete mode 100644 mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt delete mode 100644 mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt diff --git a/.run/Browser.run.xml b/.run/Browser.run.xml index a54f39a9..32a33739 100644 --- a/.run/Browser.run.xml +++ b/.run/Browser.run.xml @@ -17,8 +17,11 @@ true true + false false false + true + true \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8563f019..03fc209d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # Enable native access to avoid Java 21+ warnings from Gradle native-platform (System::load) org.gradle.jvmargs=-Xmx8G --enable-native-access=ALL-UNNAMED org.gradle.caching=true -org.gradle.configuration-cache=false +org.gradle.configuration-cache=true org.gradle.daemon=true org.gradle.parallel=true @@ -15,5 +15,4 @@ kotlin.mpp.enableCInteropCommonization=true android.useAndroidX=true android.nonTransitiveRClass=true -org.jetbrains.compose.experimental.macos.enabled=true - +org.jetbrains.compose.experimental.macos.enabled=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 781e2cdb..029d302e 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.0" +agp = "8.13.2" kotlinx-coroutines = "1.10.2" kotlinxBrowserWasmJs = "0.5.0" kotlinxDatetime = "0.7.1-0.6.x-compat" @@ -45,6 +45,7 @@ jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna"} platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtoolsDarkmodedetector" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "kotlin" } [plugins] diff --git a/mediaplayer/build.gradle.kts b/mediaplayer/build.gradle.kts index d3fc8727..be6a7afa 100644 --- a/mediaplayer/build.gradle.kts +++ b/mediaplayer/build.gradle.kts @@ -13,18 +13,15 @@ plugins { alias(libs.plugins.vannitktech.maven.publish) alias(libs.plugins.dokka) alias(libs.plugins.kotlinCocoapods) - id("maven-publish") } group = "io.github.kdroidfilter.composemediaplayer" -val ref = System.getenv("GITHUB_REF") ?: "8.0.0" -val version = "1.0.0-pushcroll-SNAPSHOT" - -// if (ref.startsWith("refs/tags/")) { -// val tag = ref.removePrefix("refs/tags/") -// if (tag.startsWith("v")) tag.substring(1) else tag -//} else "dev" +val ref = System.getenv("GITHUB_REF") ?: "" +val version = if (ref.startsWith("refs/tags/")) { + val tag = ref.removePrefix("refs/tags/") + if (tag.startsWith("v")) tag.substring(1) else tag +} else "dev" tasks.withType().configureEach { @@ -32,7 +29,6 @@ tasks.withType().configureEach { offlineMode.set(true) } - kotlin { jvmToolchain(17) androidTarget { publishLibraryVariants("release") } @@ -54,7 +50,7 @@ kotlin { iosX64(), ).forEach { target -> target.compilations.getByName("main") { - // Tahe default file path is src/nativeInterop/cinterop/.def + // The default file path is src/nativeInterop/cinterop/.def val nskeyvalueobserving by cinterops.creating } } @@ -99,6 +95,7 @@ kotlin { implementation(libs.androidx.media3.ui) implementation(libs.androidx.activityCompose) implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime.ktx) } androidUnitTest.dependencies { diff --git a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt b/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt deleted file mode 100644 index 128faaf5..00000000 --- a/mediaplayer/src/androidMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.android.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import android.app.Activity -import android.app.PictureInPictureParams -import android.content.pm.PackageManager -import android.os.Build -import android.util.Rational -import com.kdroid.androidcontextprovider.ContextProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.lang.ref.WeakReference - -actual class PipController { - actual val isInPipMode: StateFlow = _isInPipMode.asStateFlow() - - - - companion object { - lateinit var activity: WeakReference - private val _isInPipMode = MutableStateFlow(false) - - fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - _isInPipMode.value = isInPictureInPictureMode - } - } - - - - actual fun enterPip() { - _isInPipMode.value = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val currentActivity = activity?.get() - - if (currentActivity != null && isPipSupported()) { - val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build() - currentActivity.enterPictureInPictureMode(params) - } - } - } - - actual fun isPipSupported(): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val context = activity?.get() ?: ContextProvider.getContext() - return context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - } - return false - } -} \ No newline at end of file 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 7a7643d6..b8c23238 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 @@ -11,6 +11,7 @@ import android.net.Uri import android.os.Build import android.util.Rational import androidx.annotation.OptIn +import androidx.annotation.RequiresApi import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -18,6 +19,7 @@ import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -35,10 +37,12 @@ import androidx.media3.ui.PlayerView import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import com.kdroid.androidcontextprovider.ContextProvider +import io.github.kdroidfilter.composemediaplayer.util.PipResult import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.vinceglb.filekit.AndroidFile import io.github.vinceglb.filekit.PlatformFile import kotlinx.coroutines.* +import kotlinx.coroutines.android.awaitFrame import java.lang.ref.WeakReference @OptIn(UnstableApi::class) @@ -82,7 +86,24 @@ internal val androidVideoLogger = Logger.withTag("AndroidVideoPlayerSurface") @UnstableApi @Stable -open class DefaultVideoPlayerState: VideoPlayerState { +open class DefaultVideoPlayerState : VideoPlayerState { + companion object { + var activity: WeakReference = WeakReference(null) + + private var currentPlayerState: WeakReference? = null + + /** + * Call this from Activity.onPictureInPictureModeChanged() + */ + fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + currentPlayerState?.get()?.isPipActive = isInPictureInPictureMode + } + + internal fun register(state: DefaultVideoPlayerState) { + currentPlayerState = WeakReference(state) + } + } + private val context: Context = ContextProvider.getContext() internal var exoPlayer: ExoPlayer? = null private var updateJob: Job? = null @@ -238,6 +259,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { private var _aspectRatio by mutableFloatStateOf(16f / 9f) override val aspectRatio: Float get() = _aspectRatio + // Fullscreen state private var _isFullscreen by mutableStateOf(false) override var isFullscreen: Boolean @@ -253,10 +275,21 @@ open class DefaultVideoPlayerState: VideoPlayerState { override val durationText: String get() = formatTime(_duration) override val currentTime: Double get() = _currentTime - override val pipController = PipController() + override val isPipSupported: Boolean + get() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ctx = activity.get() ?: ContextProvider.getContext() + return ctx.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + return false + } + + override var isPipEnabled by mutableStateOf(false) + override var isPipActive by mutableStateOf(false) init { + register(this) audioProcessor.setOnAudioLevelUpdateListener { left, right -> _leftLevel = left _rightLevel = right @@ -304,6 +337,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } } + Intent.ACTION_SCREEN_ON -> { androidVideoLogger.d { "Screen turned on (unlocked)" } synchronized(playerInitializationLock) { @@ -467,14 +501,17 @@ open class DefaultVideoPlayerState: VideoPlayerState { // Tenter une récupération pour les erreurs de codec attemptPlayerRecovery() } + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> { _error = VideoPlayerError.NetworkError("Network error: ${error.message}") } + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { _error = VideoPlayerError.SourceError("Invalid media source: ${error.message}") } + else -> { _error = VideoPlayerError.UnknownError("Playback error: ${error.message}") } @@ -641,9 +678,39 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } - override fun enterPip() = - pipController.enterPip() + override suspend fun enterPip(): PipResult { + if (!isPipSupported) return PipResult.NotSupported + if (!isPipEnabled) return PipResult.NotEnabled + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return PipResult.NotPossible + + val currentActivity = activity.get() ?: return PipResult.NotPossible + + if (!isFullscreen) { + toggleFullscreen() + // Wait for Compose to recompose with fullscreen layout + withFrameNanos { } + withFrameNanos { } // two frames to be safe + } + + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(16, 9)) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(true) + } + } + .build() + + val result = currentActivity.enterPictureInPictureMode(params) + + return if (result) { + isPipActive = true + PipResult.Success + } else { + PipResult.NotPossible + } + } override fun seekTo(value: Float) { if (_duration > 0 && !isPlayerReleased) { 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 de1df643..eec9214c 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 @@ -1,7 +1,12 @@ package io.github.kdroidfilter.composemediaplayer +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.view.LayoutInflater +import android.view.TextureView +import android.view.View +import androidx.activity.ComponentActivity import androidx.annotation.OptIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -10,17 +15,21 @@ import androidx.compose.foundation.layout.fillMaxSize 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.MonotonicFrameClock import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView @@ -28,6 +37,11 @@ import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer import io.github.kdroidfilter.composemediaplayer.util.FullScreenLayout import io.github.kdroidfilter.composemediaplayer.util.toCanvasModifier import io.github.kdroidfilter.composemediaplayer.util.toTimeMs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @UnstableApi @Composable @@ -77,30 +91,23 @@ private fun VideoPlayerSurfaceInternal( VideoPlayerSurfacePreview(modifier = modifier, overlay = overlay) return } - // Use rememberSaveable to preserve fullscreen state across configuration changes - var isFullscreen by rememberSaveable { - mutableStateOf(playerState.isFullscreen) - } - // Keep the playerState.isFullscreen in sync with our saved state - LaunchedEffect(isFullscreen) { - if (playerState.isFullscreen != isFullscreen) { - playerState.isFullscreen = isFullscreen - } - } + // Single source of truth — no rememberSaveable, drive directly from playerState + val isFullscreen = playerState.isFullscreen - // Listen for changes from playerState.isFullscreen - LaunchedEffect(playerState.isFullscreen) { - if (isFullscreen != playerState.isFullscreen) { - isFullscreen = playerState.isFullscreen + AutoPipEffect(playerState = playerState) + + // Exit fullscreen when returning from PiP + LaunchedEffect(playerState.isPipActive) { + if (!playerState.isPipActive && playerState.isFullscreen) { + delay(300) + playerState.toggleFullscreen() } } - // Nettoyer lorsque le composable est détruit DisposableEffect(playerState) { onDispose { try { - // Détacher la vue du player if (playerState is DefaultVideoPlayerState) playerState.attachPlayerView(null) } catch (e: Exception) { @@ -110,18 +117,17 @@ private fun VideoPlayerSurfaceInternal( } if (isFullscreen) { - // Use FullScreenLayout for fullscreen mode FullScreenLayout( modifier = Modifier, onDismissRequest = { - isFullscreen = false - // Call playerState.toggleFullscreen() to ensure proper cleanup - playerState.toggleFullscreen() + playerState.toggleFullscreen() // single call, single source of truth } ) { - Box(modifier = Modifier - .fillMaxSize() - .background(Color.Black)) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { VideoPlayerContent( playerState = playerState, modifier = Modifier.fillMaxHeight(), @@ -132,7 +138,6 @@ private fun VideoPlayerSurfaceInternal( } } } else { - // Regular non-fullscreen display VideoPlayerContent( playerState = playerState, modifier = modifier, @@ -187,7 +192,7 @@ private fun VideoPlayerContent( resizeMode = mapContentScaleToResizeMode(contentScale) // Désactiver la vue de sous-titres native car nous utilisons des sous-titres basés sur Compose - subtitleView?.visibility = android.view.View.GONE + subtitleView?.visibility = View.GONE } } catch (e: Exception) { @@ -299,7 +304,7 @@ private fun createPlayerViewWithSurfaceType( SurfaceType.TextureView -> { // Utiliser TextureView si disponible videoSurfaceView?.let { view -> - if (view is android.view.TextureView) { + if (view is TextureView) { androidVideoLogger.d { "Using TextureView" } } } @@ -323,3 +328,33 @@ private fun createPlayerViewWithSurfaceType( } } } + +@Composable +fun AutoPipEffect( + playerState: VideoPlayerState +) { + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && playerState.isPipEnabled) { + scope.coroutineContext[MonotonicFrameClock]?.let { monoticClock -> + val activity = context as? ComponentActivity + activity?.lifecycleScope?.launch(context = Dispatchers.Main + monoticClock) { + playerState.enterPip() + } + } + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} + + diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt deleted file mode 100644 index db2bba65..00000000 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import kotlinx.coroutines.flow.StateFlow - -expect class PipController() { - - val isInPipMode: StateFlow - - fun enterPip() - fun isPipSupported(): Boolean -} \ No newline at end of file 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 7bba5a12..e44ea551 100644 --- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt @@ -5,8 +5,8 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.TextStyle +import io.github.kdroidfilter.composemediaplayer.util.PipResult import io.github.vinceglb.filekit.PlatformFile /** @@ -76,6 +76,14 @@ interface VideoPlayerState { var isFullscreen: Boolean val aspectRatio: Float + val isPipSupported: Boolean get() = false + var isPipActive: Boolean get() = false + set(value) {} + var isPipEnabled: Boolean get() = false + set(value) {} + + suspend fun enterPip() : PipResult = PipResult.NotSupported + // Functions to control playback /** * Starts or resumes video playback. @@ -92,16 +100,12 @@ interface VideoPlayerState { */ fun stop() - fun enterPip() = Unit - /** * Seeks to a specific playback position based on the provided normalized value. */ fun seekTo(value: Float) fun toggleFullscreen() - val pipController: PipController - // Functions to manage media sources /** * Opens a video file or URL for playback. @@ -183,7 +187,9 @@ data class PreviewableVideoPlayerState( override val availableSubtitleTracks: MutableList = emptyList().toMutableList(), override var subtitleTextStyle: TextStyle = TextStyle.Default, override var subtitleBackgroundColor: Color = Color.Transparent, - override val pipController: PipController = PipController() + override val isPipSupported: Boolean = false, + override var isPipActive: Boolean = false, + override var isPipEnabled: Boolean = false, ) : VideoPlayerState { override fun play() {} override fun pause() {} @@ -202,4 +208,4 @@ data class PreviewableVideoPlayerState( override fun selectSubtitleTrack(track: SubtitleTrack?) {} override fun disableSubtitles() {} override fun dispose() {} -} +} \ No newline at end of file diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/PipResult.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/PipResult.kt new file mode 100644 index 00000000..0097d602 --- /dev/null +++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/util/PipResult.kt @@ -0,0 +1,9 @@ +package io.github.kdroidfilter.composemediaplayer.util + +// commonMain +sealed interface PipResult { + data object Success : PipResult + data object NotSupported : PipResult + data object NotEnabled : PipResult + data object NotPossible : PipResult // supported & enabled but conditions not met +} \ No newline at end of file diff --git a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt deleted file mode 100644 index d2fe4e5e..00000000 --- a/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.ios.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import platform.AVKit.AVPictureInPictureController - -actual class PipController { - private val _isInPipMode = MutableStateFlow(false) - actual val isInPipMode: StateFlow = _isInPipMode.asStateFlow() - - internal var pipController: AVPictureInPictureController? = null - - actual fun enterPip() { - if (pipController?.isPictureInPicturePossible() == true) { - pipController?.startPictureInPicture() - } - } - - actual fun isPipSupported(): Boolean { - return AVPictureInPictureController.isPictureInPictureSupported() - } - - internal fun setInPipMode(value: Boolean) { - _isInPipMode.value = value - } -} \ 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 121324d5..e8f1887b 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 @@ -11,6 +11,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import co.touchlab.kermit.Logger +import io.github.kdroidfilter.composemediaplayer.util.PipResult import io.github.kdroidfilter.composemediaplayer.util.formatTime import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.PlatformFile @@ -23,7 +24,6 @@ import platform.AVFAudio.AVAudioSessionModeMoviePlayback import platform.AVFAudio.setActive import platform.AVFoundation.* import platform.AVKit.AVPictureInPictureController -import platform.AVKit.AVPictureInPictureControllerDelegateProtocol import platform.CoreGraphics.CGFloat import platform.CoreMedia.CMTimeGetSeconds import platform.CoreMedia.CMTimeMake @@ -49,7 +49,7 @@ import platform.darwin.dispatch_get_main_queue actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState() @Stable -open class DefaultVideoPlayerState: VideoPlayerState { +open class DefaultVideoPlayerState : VideoPlayerState { // Base states private var _volume = mutableStateOf(1.0f) @@ -110,6 +110,20 @@ open class DefaultVideoPlayerState: VideoPlayerState { _isFullscreen = value } + override val isPipSupported: Boolean + get() = AVPictureInPictureController.isPictureInPictureSupported() + + private var _isPipEnabled by mutableStateOf(false) + + override var isPipEnabled: Boolean + get() = _isPipEnabled + set(value) { + pipController?.setCanStartPictureInPictureAutomaticallyFromInline(value) + _isPipEnabled = value + } + + override var isPipActive by mutableStateOf(false) + override val error: VideoPlayerError? = null // Observable instance of AVPlayer @@ -119,22 +133,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { var playerLayer: AVPlayerLayer? by mutableStateOf(null) internal set - - - override val pipController = PipController() - - - private val pipDelegate = object : NSObject(), AVPictureInPictureControllerDelegateProtocol { - override fun pictureInPictureControllerDidStartPictureInPicture(pictureInPictureController: AVPictureInPictureController) { - _isPip = true - } - - override fun pictureInPictureControllerDidStopPictureInPicture(pictureInPictureController: AVPictureInPictureController) { - _isPip = false - } - } - - private var _isPip by mutableStateOf(false) + internal var pipController: AVPictureInPictureController? = null // Periodic observer for position updates (≈60 fps) private var timeObserverToken: Any? = null @@ -149,10 +148,10 @@ open class DefaultVideoPlayerState: VideoPlayerState { // App lifecycle notification observers private var backgroundObserver: Any? = null private var foregroundObserver: Any? = null - + // Flag to track if player was playing before going to background private var wasPlayingBeforeBackground: Boolean = false - + // Flag to track if the state has been disposed private var isDisposed = false @@ -178,7 +177,12 @@ open class DefaultVideoPlayerState: VideoPlayerState { private fun configureAudioSession() { val session = AVAudioSession.sharedInstance() try { - session.setCategory(AVAudioSessionCategoryPlayback, mode = AVAudioSessionModeMoviePlayback, options = 0u, error = null) + session.setCategory( + AVAudioSessionCategoryPlayback, + mode = AVAudioSessionModeMoviePlayback, + options = 0u, + error = null + ) session.setActive(true, error = null) } catch (e: Exception) { Logger.e { "Failed to configure audio session: ${e.message}" } @@ -226,10 +230,14 @@ open class DefaultVideoPlayerState: VideoPlayerState { ) } - override fun enterPip() { - if (pipController.pipController?.isPictureInPicturePossible() == true) { - pipController.pipController?.startPictureInPicture() - } + override suspend fun enterPip(): PipResult { + + if (!isPipSupported) return PipResult.NotSupported + if (!isPipEnabled) return PipResult.NotEnabled + pipController?.setCanStartPictureInPictureAutomaticallyFromInline(true) + pipController?.startPictureInPicture() + isPipActive = true + return PipResult.Success } private fun setupObservers(player: AVPlayer, item: AVPlayerItem) { @@ -318,7 +326,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { Logger.d { "App entered background (screen locked)" } // Store current playing state before background wasPlayingBeforeBackground = _isPlaying - + // If player is paused by the system, update our state to match player?.let { player -> if (player.rate == 0.0f) { @@ -327,7 +335,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } } - + // Add observer for when app comes to foreground (screen unlock) foregroundObserver = NSNotificationCenter.defaultCenter.addObserverForName( name = UIApplicationWillEnterForegroundNotification, @@ -346,16 +354,16 @@ open class DefaultVideoPlayerState: VideoPlayerState { } } } - + Logger.d { "App lifecycle observers set up" } } - + private fun removeAppLifecycleObservers() { backgroundObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) backgroundObserver = null } - + foregroundObserver?.let { NSNotificationCenter.defaultCenter.removeObserver(it) foregroundObserver = null @@ -485,7 +493,7 @@ open class DefaultVideoPlayerState: VideoPlayerState { // Create player item from asset to get more accurate metadata val playerItem = AVPlayerItem(asset) - val durationSeconds = CMTimeGetSeconds(playerItem.duration) + val durationSeconds = CMTimeGetSeconds(playerItem.duration) if (durationSeconds > 0 && !durationSeconds.isNaN()) { _metadata.duration = (durationSeconds * 1000).toLong() } @@ -728,4 +736,4 @@ private fun NSObject.observe( context = null ) return observer -} +} \ No newline at end of file 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 10e8f0f4..7eccff04 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 @@ -90,7 +90,7 @@ fun VideoPlayerSurfaceImpl( (playerState as? DefaultVideoPlayerState)?.let { state -> val playerLayer = layer as? AVPlayerLayer ?: return@let state.playerLayer = playerLayer - state.pipController.pipController = AVPictureInPictureController(playerLayer = playerLayer) + state.pipController = AVPictureInPictureController(playerLayer = playerLayer) } } }, diff --git a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt deleted file mode 100644 index bf036c81..00000000 --- a/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.jvm.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -actual class PipController actual constructor() { - actual val isInPipMode: StateFlow - get() = MutableStateFlow(false).asStateFlow() - - actual fun enterPip() { - } - - actual fun isPipSupported(): Boolean = false - -} 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 43719cb6..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 @@ -37,8 +37,6 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState( */ @Stable open class DefaultVideoPlayerState: VideoPlayerState { - - override lateinit var pipController: PipController val delegate: VideoPlayerState = when { Platform.isWindows() -> WindowsVideoPlayerState() Platform.isMac() -> MacVideoPlayerState() 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 866511a8..48e5ea0b 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 @@ -35,7 +35,6 @@ import org.freedesktop.gstreamer.event.SeekFlags import org.freedesktop.gstreamer.event.SeekType import org.freedesktop.gstreamer.message.MessageType import com.sun.jna.Pointer -import io.github.kdroidfilter.composemediaplayer.PipController import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType import org.jetbrains.skia.ColorType @@ -60,8 +59,6 @@ import kotlin.math.pow @Stable class LinuxVideoPlayerState : VideoPlayerState { - override lateinit var pipController: PipController - companion object { // Flag to enable text subtitles (GST_PLAY_FLAG_TEXT) const val GST_PLAY_FLAG_TEXT = 1 shl 2 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 ae21df6d..3b688b2f 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,7 +13,6 @@ 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.PipController import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata @@ -47,8 +46,6 @@ internal val macLogger = Logger.withTag("MacVideoPlayerState") */ class MacVideoPlayerState : VideoPlayerState { - override lateinit var pipController: PipController - // Main state variables private val mainMutex = Mutex() private val frameMutex = Mutex() 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 c72ab503..1f8b9380 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,7 +21,6 @@ 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.PipController import io.github.kdroidfilter.composemediaplayer.SubtitleTrack import io.github.kdroidfilter.composemediaplayer.VideoMetadata import io.github.kdroidfilter.composemediaplayer.VideoPlayerError @@ -74,8 +73,6 @@ internal val windowsLogger = Logger.withTag("WindowsVideoPlayerState") * Handles media playback using Media Foundation on Windows platform. */ class WindowsVideoPlayerState() : VideoPlayerState { - - override lateinit var pipController: PipController companion object { private val isMfBootstrapped = AtomicBoolean(false) diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt deleted file mode 100644 index 3bc46062..00000000 --- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/PipController.web.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.kdroidfilter.composemediaplayer - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -actual class PipController actual constructor() { - actual val isInPipMode: StateFlow - get() = MutableStateFlow(false).asStateFlow() - - actual fun enterPip() { - } - - actual fun isPipSupported(): Boolean = false -} - 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 b8dfad5f..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 @@ -35,8 +35,6 @@ actual fun createVideoPlayerState(): VideoPlayerState = DefaultVideoPlayerState( @Stable open class DefaultVideoPlayerState: VideoPlayerState { - override lateinit var pipController: PipController - // Variable to store the last opened URI for potential replay private var lastUri: String? = null diff --git a/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt b/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt index e45cf372..caed6b48 100644 --- a/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt +++ b/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt @@ -4,7 +4,7 @@ import android.content.res.Configuration import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import io.github.kdroidfilter.composemediaplayer.PipController +import io.github.kdroidfilter.composemediaplayer.DefaultVideoPlayerState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init import java.lang.ref.WeakReference @@ -14,12 +14,13 @@ class AppActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) FileKit.init(this) - PipController.activity = WeakReference(this) + DefaultVideoPlayerState.activity = WeakReference(this) setContent { App() } } override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - PipController.onPictureInPictureModeChanged(isInPictureInPictureMode) + DefaultVideoPlayerState.onPictureInPictureModeChanged(isInPictureInPictureMode) } + } \ No newline at end of file diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt index 8cae8095..40e2f622 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/App.kt @@ -19,43 +19,43 @@ fun App() { MaterialTheme(colorScheme = if(isSystemInDarkMode()) darkColorScheme() else lightColorScheme()) { // Navigation state var currentScreen by remember { mutableStateOf(Screen.SinglePlayer) } - SinglePlayerScreen() -// Scaffold( -// bottomBar = { -// NavigationBar { -// NavigationBarItem( -// icon = { Icon(Icons.Default.Home, contentDescription = "Single Player") }, -// label = { Text("Single Player") }, -// selected = currentScreen == Screen.SinglePlayer, -// onClick = { currentScreen = Screen.SinglePlayer } -// ) -// NavigationBarItem( -// icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Multi Player") }, -// label = { Text("Multi Player") }, -// selected = currentScreen == Screen.MultiPlayer, -// onClick = { currentScreen = Screen.MultiPlayer } -// ) -// NavigationBarItem( -// icon = { Icon(Icons.Default.Subtitles, contentDescription = "Video Attachment") }, -// label = { Text("Video Attachment") }, -// selected = currentScreen == Screen.VideoAttachmentPlayer, -// onClick = { currentScreen = Screen.VideoAttachmentPlayer } -// ) -// } -// } -// ) { paddingValues -> -// Box( -// modifier = Modifier -// .fillMaxSize() -// .padding(paddingValues) -// .background(MaterialTheme.colorScheme.background) -// ) { -// when (currentScreen) { -// Screen.SinglePlayer -> SinglePlayerScreen() -// Screen.MultiPlayer -> MultiPlayerScreen() -// Screen.VideoAttachmentPlayer -> VideoAttachmentPlayerScreen() -// } -// } -// } + + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Single Player") }, + label = { Text("Single Player") }, + selected = currentScreen == Screen.SinglePlayer, + onClick = { currentScreen = Screen.SinglePlayer } + ) + NavigationBarItem( + icon = { Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Multi Player") }, + label = { Text("Multi Player") }, + selected = currentScreen == Screen.MultiPlayer, + onClick = { currentScreen = Screen.MultiPlayer } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Subtitles, contentDescription = "Video Attachment") }, + label = { Text("Video Attachment") }, + selected = currentScreen == Screen.VideoAttachmentPlayer, + onClick = { currentScreen = Screen.VideoAttachmentPlayer } + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background) + ) { + when (currentScreen) { + Screen.SinglePlayer -> SinglePlayerScreen() + Screen.MultiPlayer -> MultiPlayerScreen() + Screen.VideoAttachmentPlayer -> VideoAttachmentPlayerScreen() + } + } + } } } diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt index 02e64aa0..93e4b676 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/VideoAttachmentPlayer.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.automirrored.outlined.VolumeUp -import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* @@ -215,14 +214,5 @@ fun VideoAttachmentPlayerScreen() { } } } - item { - val pipController = remember { PipController() } - Button(onClick = { - pipController.enterPip() - }) { - Icon(Icons.Default.PictureInPicture, contentDescription = "Enter PiP") - Text("Enter PiP") - } - } } } \ No newline at end of file diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt index 6fb3a578..ac27171f 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt @@ -27,6 +27,7 @@ import io.github.kdroidfilter.composemediaplayer.VideoPlayerError import io.github.kdroidfilter.composemediaplayer.VideoPlayerState import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun MetadataRow(label: String, value: String) { @@ -91,94 +92,99 @@ fun VideoDisplay( .clip(RoundedCornerShape(16.dp)), contentScale = contentScale ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (playerState.isFullscreen) { - var controlsVisible by remember { mutableStateOf(false) } - - // Reset timer when controls become visible - LaunchedEffect(controlsVisible) { - if (controlsVisible) { - delay(3000) // Hide controls after 3 seconds - controlsVisible = false - } - } + if (playerState.isFullscreen) { + FullscreenControls(playerState = playerState) + } + } - // Detect taps to show controls - Box( - modifier = Modifier - .fillMaxSize() - .clickable { controlsVisible = true } - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - // Show controls when mouse moves - if (event.type == PointerEventType.Move) { - controlsVisible = true - } - } - } - } - ) { - // Show controls when visible - AnimatedVisibility( - visible = controlsVisible, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.Center) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.7f), - shape = RoundedCornerShape(16.dp) - ) - .padding(16.dp) - ) { - // Play/Pause button - IconButton( - onClick = { - if (playerState.isPlaying) playerState.pause() else playerState.play() - } - ) { - Icon( - imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - tint = Color.White, - modifier = Modifier.size(56.dp) - ) - } - - // Exit fullscreen button - IconButton( - onClick = { playerState.toggleFullscreen() } - ) { - Icon( - imageVector = Icons.Default.FullscreenExit, - contentDescription = "Exit Fullscreen", - tint = Color.White, - modifier = Modifier.size(56.dp) - ) - } - } + if (playerState.isLoading) { + LoadingOverlay() + } + } +} + +@Composable +private fun FullscreenControls(playerState: VideoPlayerState) { + var controlsVisible by remember { mutableStateOf(false) } + + LaunchedEffect(controlsVisible) { + if (controlsVisible) { + delay(3000) + controlsVisible = false + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable { controlsVisible = true } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Move) { + controlsVisible = true } } } - } + }, + contentAlignment = Alignment.Center + ) { + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + FullscreenControlsBar(playerState = playerState) } + } +} - if (playerState.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Transparent), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() +@Composable +private fun FullscreenControlsBar(playerState: VideoPlayerState) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.7f), + shape = RoundedCornerShape(16.dp) + ) + .padding(16.dp) + ) { + IconButton( + onClick = { + if (playerState.isPlaying) playerState.pause() else playerState.play() } + ) { + Icon( + imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(56.dp) + ) } + + IconButton(onClick = { playerState.toggleFullscreen() }) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = "Exit Fullscreen", + tint = Color.White, + modifier = Modifier.size(56.dp) + ) + } + } +} + +@Composable +private fun LoadingOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } @@ -232,10 +238,10 @@ fun PrimaryControls( onMetadataDialogRequest: () -> Unit, onContentScaleDialogRequest: () -> Unit ) { - Row( + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + verticalArrangement = Arrangement.Center ) { FilledIconButton( onClick = { videoFileLauncher() }, @@ -302,11 +308,24 @@ fun PrimaryControls( ) { Icon(Icons.Default.AspectRatio, contentDescription = "Content Scale") } + val scope = rememberCoroutineScope() - + FilledIconButton( + onClick = { + scope.launch { + playerState.enterPip() + } + }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon(Icons.Default.PictureInPicture, contentDescription = "Content Scale") + } } } + @Composable fun VolumeAndPlaybackControls( playerState: VideoPlayerState @@ -531,10 +550,12 @@ fun ControlsCard( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold ) - - Row( + + FlowRow( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.Center, + itemVerticalAlignment = Alignment.CenterVertically, ) { RadioButton( selected = initialPlayerState == InitialPlayerState.PLAY, @@ -545,9 +566,9 @@ fun ControlsCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { onInitialPlayerStateChange(InitialPlayerState.PLAY) } ) - + Spacer(modifier = Modifier.width(16.dp)) - + RadioButton( selected = initialPlayerState == InitialPlayerState.PAUSE, onClick = { onInitialPlayerStateChange(InitialPlayerState.PAUSE) } @@ -557,6 +578,32 @@ fun ControlsCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { onInitialPlayerStateChange(InitialPlayerState.PAUSE) } ) + + Spacer(modifier = Modifier.width(16.dp)) + + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + + Switch( + checked = playerState.isPipEnabled, + onCheckedChange = { enable -> + playerState.isPipEnabled = enable + } + ) + + Text( + text = "Is pip enabled", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(horizontal = 6.dp) + .clickable { onInitialPlayerStateChange(InitialPlayerState.PAUSE) } + ) + + + } + } } 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 431c76bb..38b5d5c7 100644 --- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt @@ -2,7 +2,6 @@ package sample.app.singleplayer // Import the extracted composable functions import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,18 +10,13 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PictureInPicture -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -30,8 +24,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import io.github.kdroidfilter.composemediaplayer.InitialPlayerState @@ -39,7 +31,6 @@ 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.VideoPlayerSurface import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState import io.github.kdroidfilter.composemediaplayer.util.getUri import io.github.vinceglb.filekit.dialogs.FileKitType @@ -122,102 +113,59 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { var showContentScaleDialog by remember { mutableStateOf(false) } // State to store the selected content scale - var selectedContentScale by remember { mutableStateOf(ContentScale.Fit) } - - val isPipState by playerState.pipController.isInPipMode.collectAsState() - -// BoxWithConstraints( -// modifier = Modifier -// .fillMaxSize() -// .background(MaterialTheme.colorScheme.background) -// ) { -// val isLandscape = maxWidth > maxHeight -// -// if (isLandscape) { -// // Landscape layout (horizontal) -// Row( -// modifier = Modifier -// .fillMaxSize() -// .padding(16.dp) -// ) { -// // Left side: Video and Timeline -// Column( -// modifier = Modifier -// .weight(1f) -// .fillMaxHeight() -// ) { -// // Header with title -// PlayerHeader(title = "Compose Media Player Sample") -// -// VideoPlayerSurface( -// playerState = playerState, -// modifier = Modifier -// .weight(1f) -// .fillMaxWidth(), -// contentScale = selectedContentScale -// ) -// -// Spacer(modifier = Modifier.height(8.dp)) -// -// // Video timeline and slider -// TimelineControls(playerState = playerState) -// } -// -// Spacer(modifier = Modifier.width(16.dp)) -// -// // Right side: Controls -// Column( -// modifier = Modifier -// .weight(1f) -// .fillMaxHeight() -// .padding(top = 48.dp) // Align with video content -// ) { -// // Primary controls: load video, play/pause, stop -// PrimaryControls( -// playerState = playerState, -// videoFileLauncher = { videoFileLauncher.launch() }, -// onSubtitleDialogRequest = { showSubtitleDialog = true }, -// onMetadataDialogRequest = { showMetadataDialog = true }, -// onContentScaleDialogRequest = { showContentScaleDialog = true } -// ) -// -// Spacer(modifier = Modifier.height(16.dp)) -// -// // Secondary controls: volume, loop, video URL input -// ControlsCard( -// playerState = playerState, -// videoUrl = videoUrl, -// onVideoUrlChange = { videoUrl = it }, -// onOpenUrl = { -// if (videoUrl.isNotEmpty()) { -// playerState.openUri(videoUrl, initialPlayerState) -// } -// }, -// initialPlayerState = initialPlayerState, -// onInitialPlayerStateChange = { initialPlayerState = it } -// ) -// } -// } -// } else { - // Portrait layout (vertical) - Column( + var selectedContentScale by remember { mutableStateOf(ContentScale.Fit) } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(rememberScrollState()) + ) { + val isLandscape = maxWidth > maxHeight + + if (isLandscape) { + // Landscape layout (horizontal) + Row( modifier = Modifier .fillMaxSize() .padding(16.dp) ) { - if (!isPipState) { + // Left side: Video and Timeline + Column( + modifier = Modifier + .heightIn(min = 300.dp) + .weight(1f) + ) { + // Header with title PlayerHeader(title = "Compose Media Player Sample") // Video display area + VideoDisplay( + playerState = playerState, + modifier = Modifier + .heightIn(min = 300.dp) + .weight(1f) - Spacer(modifier = Modifier.height(16.dp)) + .fillMaxWidth(), + contentScale = selectedContentScale + ) + + Spacer(modifier = Modifier.height(8.dp)) // Video timeline and slider TimelineControls(playerState = playerState) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) + // Right side: Controls + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(top = 48.dp) // Align with video content + ) { // Primary controls: load video, play/pause, stop PrimaryControls( playerState = playerState, @@ -243,88 +191,115 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) { onInitialPlayerStateChange = { initialPlayerState = it } ) } - Box( + } + } else { + // Portrait layout (vertical) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header with title + PlayerHeader(title = "Compose Media Player Sample") + + // Video display area + VideoDisplay( + playerState = playerState, modifier = Modifier - .weight(1f) - ) { - VideoPlayerSurface( - playerState = playerState, - modifier = Modifier - .fillMaxSize(), - contentScale = selectedContentScale - ) + .heightIn(min = 100.dp) + .fillMaxHeight() + .fillMaxWidth(), + contentScale = selectedContentScale + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Video timeline and slider + TimelineControls(playerState = playerState) + + Spacer(modifier = Modifier.height(16.dp)) + + // Primary controls: load video, play/pause, stop + PrimaryControls( + playerState = playerState, + videoFileLauncher = { videoFileLauncher.launch() }, + onSubtitleDialogRequest = { showSubtitleDialog = true }, + onMetadataDialogRequest = { showMetadataDialog = true }, + onContentScaleDialogRequest = { showContentScaleDialog = true } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Secondary controls: volume, loop, video URL input + ControlsCard( + playerState = playerState, + videoUrl = videoUrl, + onVideoUrlChange = { videoUrl = it }, + onOpenUrl = { + if (videoUrl.isNotEmpty()) { + playerState.openUri(videoUrl, initialPlayerState) + } + }, + initialPlayerState = initialPlayerState, + onInitialPlayerStateChange = { initialPlayerState = it } + ) + } + } + + // Animated error Snackbar + playerState.error?.let { error -> + ErrorSnackbar( + error = error, + onDismiss = { playerState.clearError() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + } + + // Subtitle management dialog + if (showSubtitleDialog) { + SubtitleManagementDialog( + subtitleTracks = subtitleTracks, + selectedSubtitleTrack = selectedSubtitleTrack, + onSubtitleSelected = { track -> + selectedSubtitleTrack = track + playerState.selectSubtitleTrack(track) + }, + onDisableSubtitles = { + selectedSubtitleTrack = null + playerState.disableSubtitles() + }, + subtitleFileLauncher = { subtitleFileLauncher.launch() }, + onDismiss = { + showSubtitleDialog = false } + ) + } - if (!isPipState) { + // Metadata dialog + if (showMetadataDialog) { + MetadataDialog( + playerState = playerState, + onDismiss = { + showMetadataDialog = false + } + ) + } - FilledIconButton( - onClick = { playerState.pipController.enterPip() }, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Icon(Icons.Default.PictureInPicture, contentDescription = "Content Scale") - } + // Content scale dialog + if (showContentScaleDialog) { + ContentScaleDialog( + currentContentScale = selectedContentScale, + onContentScaleSelected = { contentScale -> + selectedContentScale = contentScale + showContentScaleDialog = false + }, + onDismiss = { + showContentScaleDialog = false } - } + ) } -// if (!isPipState) { -// -// // Animated error Snackbar -// playerState.error?.let { error -> -// ErrorSnackbar( -// error = error, -// onDismiss = { playerState.clearError() }, -// modifier = Modifier -// .align(Alignment.BottomCenter) -// .padding(16.dp) -// ) -// } -// -// // Subtitle management dialog -// if (showSubtitleDialog) { -// SubtitleManagementDialog( -// subtitleTracks = subtitleTracks, -// selectedSubtitleTrack = selectedSubtitleTrack, -// onSubtitleSelected = { track -> -// selectedSubtitleTrack = track -// playerState.selectSubtitleTrack(track) -// }, -// onDisableSubtitles = { -// selectedSubtitleTrack = null -// playerState.disableSubtitles() -// }, -// subtitleFileLauncher = { subtitleFileLauncher.launch() }, -// onDismiss = { -// showSubtitleDialog = false -// } -// ) -// } -// -// // Metadata dialog -// if (showMetadataDialog) { -// MetadataDialog( -// playerState = playerState, -// onDismiss = { -// showMetadataDialog = false -// } -// ) -// } -// -// // Content scale dialog -// if (showContentScaleDialog) { -// ContentScaleDialog( -// currentContentScale = selectedContentScale, -// onContentScaleSelected = { contentScale -> -// selectedContentScale = contentScale -// showContentScaleDialog = false -// }, -// onDismiss = { -// showContentScaleDialog = false -// } -// ) -// } -// } -// } -// } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9c3e8e18..e45b71b4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,7 +16,6 @@ pluginManagement { } dependencyResolutionManagement { - repositories { google { content { From 1ff5cf71bb514b93bd247b4399904b6838efa9b2 Mon Sep 17 00:00:00 2001 From: Dhia Chemingui <78903066+dhiaspaner@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:20:56 +0100 Subject: [PATCH 6/6] fix: fix full screen android --- .../composemediaplayer/VideoPlayerState.android.kt | 11 +++++++++-- .../composemediaplayer/VideoPlayerSurface.android.kt | 11 +++++++---- 2 files changed, 16 insertions(+), 6 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 b8c23238..34ccb037 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.graphics.Color @@ -268,6 +269,8 @@ open class DefaultVideoPlayerState : VideoPlayerState { _isFullscreen = value } + var isPipFullScreen by mutableStateOf(false) + // Time tracking private var _currentTime by mutableDoubleStateOf(0.0) private var _duration by mutableDoubleStateOf(0.0) @@ -678,6 +681,10 @@ open class DefaultVideoPlayerState : VideoPlayerState { } } + fun togglePipFullScreen() { + isPipFullScreen = !isPipFullScreen + } + override suspend fun enterPip(): PipResult { if (!isPipSupported) return PipResult.NotSupported @@ -686,8 +693,8 @@ open class DefaultVideoPlayerState : VideoPlayerState { val currentActivity = activity.get() ?: return PipResult.NotPossible - if (!isFullscreen) { - toggleFullscreen() + if (!isPipFullScreen) { + togglePipFullScreen() // Wait for Compose to recompose with fullscreen layout withFrameNanos { } withFrameNanos { } // two frames to be safe 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 eec9214c..9882e943 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 @@ -94,14 +94,17 @@ private fun VideoPlayerSurfaceInternal( // Single source of truth — no rememberSaveable, drive directly from playerState val isFullscreen = playerState.isFullscreen + val isPipFullScreen = (playerState as? DefaultVideoPlayerState)?.isPipFullScreen ?: false AutoPipEffect(playerState = playerState) // Exit fullscreen when returning from PiP LaunchedEffect(playerState.isPipActive) { - if (!playerState.isPipActive && playerState.isFullscreen) { - delay(300) - playerState.toggleFullscreen() + (playerState as? DefaultVideoPlayerState)?.let { playerState -> + if (!playerState.isPipActive && playerState.isPipFullScreen) { + delay(300) + playerState.togglePipFullScreen() + } } } @@ -116,7 +119,7 @@ private fun VideoPlayerSurfaceInternal( } } - if (isFullscreen) { + if (isFullscreen || isPipFullScreen) { FullScreenLayout( modifier = Modifier, onDismissRequest = {