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/libs.versions.toml b/gradle/libs.versions.toml
index 781d7418..029d302e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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 be40107b..be6a7afa 100644
--- a/mediaplayer/build.gradle.kts
+++ b/mediaplayer/build.gradle.kts
@@ -29,7 +29,6 @@ tasks.withType().configureEach {
offlineMode.set(true)
}
-
kotlin {
jvmToolchain(17)
androidTarget { publishLibraryVariants("release") }
@@ -96,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 {
@@ -230,7 +230,5 @@ mavenPublishing {
}
}
- publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
-
- signAllPublications()
+ this.signAllPublications()
}
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..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
@@ -1,17 +1,26 @@
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.annotation.RequiresApi
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
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
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -29,10 +38,13 @@ 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)
actual fun createVideoPlayerState(): VideoPlayerState =
@@ -75,7 +87,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
@@ -231,6 +260,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
@@ -239,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)
@@ -246,8 +278,21 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override val durationText: String get() = formatTime(_duration)
override val currentTime: Double get() = _currentTime
+ 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
@@ -295,6 +340,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
}
}
+
Intent.ACTION_SCREEN_ON -> {
androidVideoLogger.d { "Screen turned on (unlocked)" }
synchronized(playerInitializationLock) {
@@ -458,14 +504,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}")
}
@@ -632,6 +681,44 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
}
+ fun togglePipFullScreen() {
+ isPipFullScreen = !isPipFullScreen
+ }
+
+
+ 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 (!isPipFullScreen) {
+ togglePipFullScreen()
+ // 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) {
val targetTime = (value / 1000.0) * _duration
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..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
@@ -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,26 @@ 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
+ val isPipFullScreen = (playerState as? DefaultVideoPlayerState)?.isPipFullScreen ?: false
- // 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) {
+ (playerState as? DefaultVideoPlayerState)?.let { playerState ->
+ if (!playerState.isPipActive && playerState.isPipFullScreen) {
+ delay(300)
+ playerState.togglePipFullScreen()
+ }
}
}
- // 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) {
@@ -109,19 +119,18 @@ private fun VideoPlayerSurfaceInternal(
}
}
- if (isFullscreen) {
- // Use FullScreenLayout for fullscreen mode
+ if (isFullscreen || isPipFullScreen) {
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 +141,6 @@ private fun VideoPlayerSurfaceInternal(
}
}
} else {
- // Regular non-fullscreen display
VideoPlayerContent(
playerState = playerState,
modifier = modifier,
@@ -187,7 +195,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 +307,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 +331,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/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
index 9174d939..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.
@@ -179,6 +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 isPipSupported: Boolean = false,
+ override var isPipActive: Boolean = false,
+ override var isPipEnabled: Boolean = false,
) : VideoPlayerState {
override fun play() {}
override fun pause() {}
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/VideoPlayerState.ios.kt b/mediaplayer/src/iosMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.ios.kt
index 71d6415e..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
@@ -22,6 +23,7 @@ import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionModeMoviePlayback
import platform.AVFAudio.setActive
import platform.AVFoundation.*
+import platform.AVKit.AVPictureInPictureController
import platform.CoreGraphics.CGFloat
import platform.CoreMedia.CMTimeGetSeconds
import platform.CoreMedia.CMTimeMake
@@ -47,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)
@@ -108,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
@@ -117,6 +133,8 @@ open class DefaultVideoPlayerState: VideoPlayerState {
var playerLayer: AVPlayerLayer? by mutableStateOf(null)
internal set
+ internal var pipController: AVPictureInPictureController? = null
+
// Periodic observer for position updates (≈60 fps)
private var timeObserverToken: Any? = null
@@ -130,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
@@ -159,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}" }
@@ -207,6 +230,16 @@ open class DefaultVideoPlayerState: VideoPlayerState {
)
}
+ 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) {
// KVO for timeControlStatus (Playing, Paused, Loading)
timeControlStatusObserver = player.observe("timeControlStatus") { _ ->
@@ -293,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) {
@@ -302,7 +335,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
}
}
}
-
+
// Add observer for when app comes to foreground (screen unlock)
foregroundObserver = NSNotificationCenter.defaultCenter.addObserverForName(
name = UIApplicationWillEnterForegroundNotification,
@@ -321,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
@@ -460,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()
}
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..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
@@ -24,6 +24,7 @@ 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.UIKit.UIColor
@@ -85,7 +86,12 @@ fun VideoPlayerSurfaceImpl(
backgroundColor = UIColor.blackColor
clipsToBounds = true
- (playerState as? DefaultVideoPlayerState)?.playerLayer = layer as? AVPlayerLayer
+
+ (playerState as? DefaultVideoPlayerState)?.let { state ->
+ val playerLayer = layer as? AVPlayerLayer ?: return@let
+ state.playerLayer = playerLayer
+ state.pipController = AVPictureInPictureController(playerLayer = playerLayer)
+ }
}
},
update = { playerView ->
@@ -117,8 +123,8 @@ fun VideoPlayerSurfaceImpl(
// 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()
@@ -172,4 +178,3 @@ private class PlayerUIView : UIView {
(layer as? AVPlayerLayer)?.videoGravity = value
}
}
-
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..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
@@ -908,7 +908,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/windows/WindowsVideoPlayerState.kt b/mediaplayer/src/jvmMain/kotlin/io/github/kdroidfilter/composemediaplayer/windows/WindowsVideoPlayerState.kt
index 3db9060b..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
@@ -72,7 +72,7 @@ internal val windowsLogger = Logger.withTag("WindowsVideoPlayerState")
* Windows implementation of the video player state.
* Handles media playback using Media Foundation on Windows platform.
*/
-class WindowsVideoPlayerState : VideoPlayerState {
+class WindowsVideoPlayerState() : VideoPlayerState {
companion object {
private val isMfBootstrapped = AtomicBoolean(false)
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..caed6b48 100644
--- a/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt
+++ b/sample/composeApp/src/androidMain/kotlin/sample/app/main.kt
@@ -1,15 +1,26 @@
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.DefaultVideoPlayerState
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)
+ DefaultVideoPlayerState.activity = WeakReference(this)
setContent { App() }
}
+
+ override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+ DefaultVideoPlayerState.onPictureInPictureModeChanged(isInPictureInPictureMode)
+ }
+
}
\ 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..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,9 +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
@@ -529,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,
@@ -543,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) }
@@ -555,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 72ab80ac..38b5d5c7 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
@@ -10,8 +10,11 @@ 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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -116,6 +119,7 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
+ .verticalScroll(rememberScrollState())
) {
val isLandscape = maxWidth > maxHeight
@@ -129,17 +133,20 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
// Left side: Video and Timeline
Column(
modifier = Modifier
+ .heightIn(min = 300.dp)
.weight(1f)
- .fillMaxHeight()
+
) {
// Header with title
- PlayerHeader(title = "Compose Media Player Sample",)
+ PlayerHeader(title = "Compose Media Player Sample")
// Video display area
VideoDisplay(
playerState = playerState,
modifier = Modifier
+ .heightIn(min = 300.dp)
.weight(1f)
+
.fillMaxWidth(),
contentScale = selectedContentScale
)
@@ -193,13 +200,14 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
.padding(16.dp)
) {
// Header with title
- PlayerHeader(title = "Compose Media Player Sample",)
+ PlayerHeader(title = "Compose Media Player Sample")
// Video display area
VideoDisplay(
playerState = playerState,
modifier = Modifier
- .weight(1f)
+ .heightIn(min = 100.dp)
+ .fillMaxHeight()
.fillMaxWidth(),
contentScale = selectedContentScale
)
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
+