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 +