From d9af3694b94357342d6925cb26025750dc916870 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Thu, 1 May 2025 12:02:07 +0400 Subject: [PATCH 01/18] add video player feature with Picture-in-Picture support --- coreapp/src/androidMain/AndroidManifest.xml | 12 ++- .../com/metacto/core/di/CorePlatformModule.kt | 5 + .../components/videoPlayer/Events.kt | 46 ++++++++++ .../videoPlayer/VideoPlayerActivity.kt | 91 +++++++++++++++++++ .../res/layout/activity_video_player.xml | 23 +++++ coreapp/src/androidMain/res/values/themes.xml | 15 +++ 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt create mode 100644 coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt create mode 100644 coreapp/src/androidMain/res/layout/activity_video_player.xml create mode 100644 coreapp/src/androidMain/res/values/themes.xml diff --git a/coreapp/src/androidMain/AndroidManifest.xml b/coreapp/src/androidMain/AndroidManifest.xml index 1ff38829..fd8ffd55 100644 --- a/coreapp/src/androidMain/AndroidManifest.xml +++ b/coreapp/src/androidMain/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + corePlatformModule( EventBroadcaster } + single { + VideoPlayerEventBroadcaster + } + single { Firebase.remoteConfig } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt new file mode 100644 index 00000000..dd892c0d --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -0,0 +1,46 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +internal sealed class VideoPlayerEvent { + data object StoppedPip : VideoPlayerEvent() +} + +internal object VideoPlayerEventBroadcaster { + private val events = MutableSharedFlow() + + @OptIn(DelicateCoroutinesApi::class) + fun emit(event: VideoPlayerEvent) { + GlobalScope.launch { + events.emit(event) + } + } + + @SuppressLint("ComposableNaming") + @Composable + inline fun collectInCompose(crossinline onReceived: (T) -> Unit) { + LaunchedEffect(Unit) { + events + .filter { it is T } + .collectLatest { + onReceived(it as T) + } + } + } + + suspend inline fun collect(crossinline onReceived: (T) -> Unit) { + events + .filter { it is T } + .collectLatest { + onReceived(it as T) + } + } +} \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt new file mode 100644 index 00000000..60301791 --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -0,0 +1,91 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Rational +import android.view.SurfaceView +import android.widget.ImageButton +import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import com.metacto.coreApp.R +import org.koin.android.ext.android.inject + +internal class VideoPlayerActivity : AppCompatActivity() { + private val eventBroadcaster: VideoPlayerEventBroadcaster by inject() + private var exoPlayer: ExoPlayer? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Set content view + setContentView(R.layout.activity_video_player) + + // Setup views + setupViews() + + // Config player view + intent?.getStringExtra(KEY_PLAYER_ID)?.let { + configPlayerView(it) + } + } + + private fun setupViews() { + val ibPip = findViewById(R.id.ib_pip) + ibPip.setOnClickListener { + enablePip() + } + } + + private fun enablePip() { + val params = PictureInPictureParams.Builder().run { + setAspectRatio(Rational(16, 9)) + build() + } + + if (isInPictureInPictureMode.not()) { + enterPictureInPictureMode(params) + } + } + + @OptIn(UnstableApi::class) + private fun configPlayerView(playerId: String) { + val playerManagers by inject>() + exoPlayer = playerManagers[playerId]?.exoPlayer ?: return + + val playerView = findViewById(R.id.player_view) + playerView.player = exoPlayer + + (playerView.videoSurfaceView as? SurfaceView)?.let { + exoPlayer?.setVideoSurfaceView(it) + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + intent?.getStringExtra(KEY_PLAYER_ID)?.let { + configPlayerView(it) + } + } + + override fun onStop() { + eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + exoPlayer?.pause() + super.onStop() + } + + companion object { + private const val KEY_PLAYER_ID = "player_id" + + fun start(context: Context, uniqueId: String) { + val intent = Intent(context, VideoPlayerActivity::class.java).apply { + putExtra(KEY_PLAYER_ID, uniqueId) + } + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/coreapp/src/androidMain/res/layout/activity_video_player.xml b/coreapp/src/androidMain/res/layout/activity_video_player.xml new file mode 100644 index 00000000..4508e508 --- /dev/null +++ b/coreapp/src/androidMain/res/layout/activity_video_player.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/coreapp/src/androidMain/res/values/themes.xml b/coreapp/src/androidMain/res/values/themes.xml new file mode 100644 index 00000000..b2919a50 --- /dev/null +++ b/coreapp/src/androidMain/res/values/themes.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file From 137af5d2ef1a873fdb0442c432667b5fde23d3b3 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Thu, 1 May 2025 13:55:53 +0400 Subject: [PATCH 02/18] full screen and pip with VideoPlayerActivity --- .../videoPlayer/VideoPlayer.android.kt | 185 ++++++------------ .../videoPlayer/VideoPlayerActivity.kt | 7 +- .../src/androidMain/res/drawable/ic_pip.xml | 9 + 3 files changed, 68 insertions(+), 133 deletions(-) create mode 100644 coreapp/src/androidMain/res/drawable/ic_pip.xml diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index fdff867f..796502a3 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -1,12 +1,10 @@ package com.metacto.core.presentation.components.videoPlayer import android.annotation.SuppressLint -import android.content.res.Configuration import android.view.SurfaceView import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.annotation.OptIn -import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -16,14 +14,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.viewinterop.AndroidView @@ -33,14 +30,9 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.metacto.core.domain.DiQualifiers -import com.metacto.core.presentation.components.dialog.dismissFullScreenDialog -import com.metacto.core.presentation.components.dialog.showFullScreenDialog import com.metacto.core.presentation.components.visibilities.FadeVisibility import com.metacto.core.utils.extensions.OnLifecycleEvent -import com.metacto.core.utils.extensions.getActivity import com.metacto.core.utils.extensions.noRippleClickable -import com.metacto.core.utils.extensions.setPortraitOrientation -import com.metacto.core.utils.extensions.setUnspecifiedOrientation import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject @@ -79,65 +71,36 @@ actual fun VideoPlayer( onVideoEnd: (() -> Unit)? ) { // Inject main stuff + val context = LocalContext.current + val eventBroadcaster = koinInject() val playerManagers = koinInject>(DiQualifiers.videoPlayerManagers) val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } + // Local state variables for UI and playback. val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } val isVideoEnded = remember { mutableStateOf(false) } - val isFullScreen = remember { mutableStateOf(false) } - val activity = LocalContext.current.getActivity() - val configuration = LocalConfiguration.current - val isLandscape by remember(configuration.orientation) { - derivedStateOf { - configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - } - } - // Set fullscreen by default when move to landscape - LaunchedEffect(isLandscape) { - if (isLandscape) { - isFullScreen.value = true - } + // TODO: should change full screen icon to fixed icon + // Full screen handler + var enableRendering by remember { + mutableStateOf(true) } - - // Handle fullscreen changes - LaunchedEffect(isFullScreen.value) { - if (isFullScreen.value) { - activity?.setUnspecifiedOrientation() - } else { - activity?.setPortraitOrientation() - } + fun onFullScreen(uniqueId: String) { + VideoPlayerActivity.start( + context = context, + uniqueId = uniqueId + ) + enableRendering = false } - // Show and dismiss fullscreen dialog when needed - LaunchedEffect(isFullScreen.value) { - if (isFullScreen.value) { - // Show fullscreen dialog if needed - activity?.showFullScreenDialog { - FullScreenVideoPlayer( - modifier = Modifier, - playerManager = playerManager, - isPlayButtonVisible = isPlayButtonVisible, - icon = icon, - isPlaying = isPlaying, - isVideoEnded = isVideoEnded, - isFullScreen = isFullScreen, - controllerShowTimeoutMs = controllerShowTimeoutMs, - controlsType = controlsType, - customControlsSize = customControlsSize, - customControlsElevation = customControlsElevation, - customControlsShape = customControlsShape - ) - } - } else { - // Dismiss current fullscreen dialog if exists - activity?.dismissFullScreenDialog() - } + // Collect required events + eventBroadcaster.collectInCompose { + enableRendering = true } // Listen for player state changes. @@ -217,23 +180,24 @@ actual fun VideoPlayer( } // Render normal video player if needed - if (isFullScreen.value.not()) { - NormalVideoPlayer( - modifier = modifier, - playerManager = playerManager, - isPlayButtonVisible = isPlayButtonVisible, - icon = icon, - isPlaying = isPlaying, - isVideoEnded = isVideoEnded, - isFullScreen = isFullScreen, - scaleToCrop = scaleToCrop, - controllerShowTimeoutMs = controllerShowTimeoutMs, - controlsType = controlsType, - customControlsSize = customControlsSize, - customControlsElevation = customControlsElevation, - customControlsShape = customControlsShape - ) - } + NormalVideoPlayer( + modifier = modifier, + playerManager = playerManager, + enableRendering = enableRendering, + isPlayButtonVisible = isPlayButtonVisible, + icon = icon, + isPlaying = isPlaying, + isVideoEnded = isVideoEnded, + scaleToCrop = scaleToCrop, + controllerShowTimeoutMs = controllerShowTimeoutMs, + controlsType = controlsType, + customControlsSize = customControlsSize, + customControlsElevation = customControlsElevation, + customControlsShape = customControlsShape, + onFullscreenClick = { + onFullScreen(uniqueId) + } + ) // Pause the player when the composable is disposed or when the lifecycle pauses. DisposableEffect(Unit) { @@ -257,11 +221,12 @@ actual fun VideoPlayer( private fun NormalVideoPlayer( modifier: Modifier, playerManager: VideoPlayerManager, + enableRendering: Boolean, isPlayButtonVisible: Boolean, icon: DrawableResource, isPlaying: MutableState, isVideoEnded: MutableState, - isFullScreen: MutableState, + onFullscreenClick: () -> Unit, scaleToCrop: Boolean, controllerShowTimeoutMs: Int, controlsType: ControlsType, @@ -274,6 +239,7 @@ private fun NormalVideoPlayer( ) { VideoPlayerContent( playerManager = playerManager, + enableRendering = enableRendering, controlsType = controlsType, controllerShowTimeoutMs = controllerShowTimeoutMs, isPlayButtonVisible = isPlayButtonVisible, @@ -285,54 +251,7 @@ private fun NormalVideoPlayer( true -> AspectRatioFrameLayout.RESIZE_MODE_FILL false -> AspectRatioFrameLayout.RESIZE_MODE_FIT }, - onNativeFullscreenClick = { - isFullScreen.value = isFullScreen.value.not() - }, - onTogglePlay = { - if (isPlaying.value) { - playerManager.pause() - } else { - if (isVideoEnded.value) { - playerManager.exoPlayer.seekTo(0) - isVideoEnded.value = false - } - playerManager.play() - isPlaying.value = true - } - } - ) - } -} - -@SuppressLint("UnsafeOptInUsageError") -@Composable -private fun FullScreenVideoPlayer( - modifier: Modifier, - playerManager: VideoPlayerManager, - isPlayButtonVisible: Boolean, - icon: DrawableResource, - isPlaying: MutableState, - isVideoEnded: MutableState, - isFullScreen: MutableState, - controllerShowTimeoutMs: Int, - controlsType: ControlsType, - customControlsSize: Dp, - customControlsElevation: Dp, - customControlsShape: RoundedCornerShape -) { - Box( - modifier = modifier.fillMaxSize() - ) { - VideoPlayerContent( - playerManager = playerManager, - controlsType = controlsType, - controllerShowTimeoutMs = controllerShowTimeoutMs, - isPlayButtonVisible = isPlayButtonVisible, - icon = icon, - customControlsSize = customControlsSize, - customControlsElevation = customControlsElevation, - customControlsShape = customControlsShape, - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH, + onNativeFullscreenClick = onFullscreenClick, onTogglePlay = { if (isPlaying.value) { playerManager.pause() @@ -344,9 +263,6 @@ private fun FullScreenVideoPlayer( playerManager.play() isPlaying.value = true } - }, - onNativeFullscreenClick = { - isFullScreen.value = isFullScreen.value.not() } ) } @@ -356,6 +272,7 @@ private fun FullScreenVideoPlayer( @Composable private fun VideoPlayerContent( playerManager: VideoPlayerManager, + enableRendering: Boolean, controlsType: ControlsType, controllerShowTimeoutMs: Int, resizeMode: Int, @@ -379,18 +296,26 @@ private fun VideoPlayerContent( player = playerManager.exoPlayer layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - (videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) + setFullscreenButtonClickListener { + onNativeFullscreenClick() } - if (controlsType == ControlsType.NativeControls) { - // Leverage native full screen button. - setFullscreenButtonClickListener { onNativeFullscreenClick() } + + if (enableRendering) { + (videoSurfaceView as? SurfaceView)?.let { + playerManager.exoPlayer.setVideoSurfaceView(it) + } } } }, update = { playerView -> - (playerView.videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) + playerView.setFullscreenButtonClickListener { + onNativeFullscreenClick() + } + + if (enableRendering) { + (playerView.videoSurfaceView as? SurfaceView)?.let { + playerManager.exoPlayer.setVideoSurfaceView(it) + } } } ) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 60301791..fa61e759 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView +import com.metacto.core.domain.DiQualifiers import com.metacto.coreApp.R import org.koin.android.ext.android.inject @@ -54,7 +55,7 @@ internal class VideoPlayerActivity : AppCompatActivity() { @OptIn(UnstableApi::class) private fun configPlayerView(playerId: String) { - val playerManagers by inject>() + val playerManagers by inject>(DiQualifiers.videoPlayerManagers) exoPlayer = playerManagers[playerId]?.exoPlayer ?: return val playerView = findViewById(R.id.player_view) @@ -65,9 +66,9 @@ internal class VideoPlayerActivity : AppCompatActivity() { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - intent?.getStringExtra(KEY_PLAYER_ID)?.let { + intent.getStringExtra(KEY_PLAYER_ID)?.let { configPlayerView(it) } } diff --git a/coreapp/src/androidMain/res/drawable/ic_pip.xml b/coreapp/src/androidMain/res/drawable/ic_pip.xml new file mode 100644 index 00000000..34904f6a --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/ic_pip.xml @@ -0,0 +1,9 @@ + + + From 05a521d8b1d09b8b3edd5f17cd7f780f4c682a0d Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Thu, 1 May 2025 19:43:04 +0400 Subject: [PATCH 03/18] enhance VideoPlayer.android.kt to remove duplicate --- .../videoPlayer/VideoPlayer.android.kt | 334 ++++++++---------- 1 file changed, 142 insertions(+), 192 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 796502a3..64b77406 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -1,6 +1,5 @@ package com.metacto.core.presentation.components.videoPlayer -import android.annotation.SuppressLint import android.view.SurfaceView import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout @@ -70,41 +69,36 @@ actual fun VideoPlayer( onVideoLoop: (() -> Unit)?, onVideoEnd: (() -> Unit)? ) { - // Inject main stuff + // Setup context and dependencies val context = LocalContext.current val eventBroadcaster = koinInject() val playerManagers = koinInject>(DiQualifiers.videoPlayerManagers) - val playerManager = playerManagers.getOrPut(uniqueId) { - VideoPlayerManager(uniqueId) - } + val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } - // Local state variables for UI and playback. + // State management val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } + val isVideoEnded = remember { mutableStateOf(false) } + var enableRendering by remember { mutableStateOf(true) } val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } - val isVideoEnded = remember { mutableStateOf(false) } - // TODO: should change full screen icon to fixed icon - // Full screen handler - var enableRendering by remember { - mutableStateOf(true) - } - fun onFullScreen(uniqueId: String) { - VideoPlayerActivity.start( - context = context, - uniqueId = uniqueId - ) - enableRendering = false - } - - // Collect required events + // Event handling eventBroadcaster.collectInCompose { enableRendering = true } - // Listen for player state changes. - LaunchedEffect(playerManager.exoPlayer) { + // Player controller setup + val controller = remember(playerManager) { + object : VideoPlayerController { + override fun play() = playerManager.play() + override fun pause() = playerManager.pause() + } + } + + // Configure player options + LaunchedEffect(key1 = playerManager) { + // Setup player listeners playerManager.exoPlayer.addListener(object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { isPlaying.value = playWhenReady @@ -124,82 +118,95 @@ actual fun VideoPlayer( }) } - // Voice configuration - LaunchedEffect(playerManager, enableVoice) { - playerManager.exoPlayer.volume = if (enableVoice) 1f else 0f - } - - // Setup scaling mode - LaunchedEffect(playerManager, scaleToCrop) { - playerManager.setScaleToCrop(scaleToCrop) - } - - // Auto play configuration - LaunchedEffect(playerManager, autoPlay) { - playerManager.setAutoPlay(autoPlay) - } - - // Auto repeat configuration - LaunchedEffect(playerManager, autoRepeat) { - playerManager.setAutoRepeat(autoRepeat) - } - - // Media metadata configuration - LaunchedEffect(playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl) { + // Configure player settings + LaunchedEffect( + playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl, + autoPlay, scaleToCrop, autoRepeat, enableVoice, enableMediaMetadata, + onVideoLoop, onVideoEnd, controller, onPlayerCreated + ) { + // Media configuration playerManager.setMedia( videoUrl = videoUrl, videoTitle = videoTitle, videoArtist = videoArtist, videoArtworkUrl = videoArtworkUrl ) - } - LaunchedEffect(enableMediaMetadata) { - playerManager.setMediaMetadataEnabled(enableMediaMetadata) - } - // Set up and deliver the player controller callback. - val controller = remember(playerManager) { - object : VideoPlayerController { - override fun play() = playerManager.play() - override fun pause() = playerManager.pause() - } - } + // Player settings + playerManager.setAutoPlay(autoPlay) + playerManager.setScaleToCrop(scaleToCrop) + playerManager.setAutoRepeat(autoRepeat) + playerManager.setMediaMetadataEnabled(enableMediaMetadata) + playerManager.exoPlayer.volume = if (enableVoice) 1f else 0f - LaunchedEffect(controller, onPlayerCreated) { + // Callbacks + playerManager.onVideoLoop = onVideoLoop + playerManager.onVideoEnd = onVideoEnd onPlayerCreated?.invoke(controller) } - // Video loop configuration - LaunchedEffect(onVideoLoop) { - playerManager.onVideoLoop = onVideoLoop + // Fullscreen handler + fun onFullScreen(id: String) { + VideoPlayerActivity.start(context = context, uniqueId = id) + enableRendering = false } - // Video end configuration - LaunchedEffect(onVideoEnd) { - playerManager.onVideoEnd = onVideoEnd - } + // Render video player + Box(modifier = modifier) { + // Player view + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + createPlayerView( + context = ctx, + playerManager = playerManager, + controlsType = controlsType, + controllerShowTimeoutMs = controllerShowTimeoutMs, + resizeMode = if (scaleToCrop) AspectRatioFrameLayout.RESIZE_MODE_FILL + else AspectRatioFrameLayout.RESIZE_MODE_FIT, + enableRendering = enableRendering, + onFullscreenClick = { onFullScreen(uniqueId) } + ) + }, + update = { playerView -> + updatePlayerView( + playerView = playerView, + playerManager = playerManager, + enableRendering = enableRendering, + onFullscreenClick = { onFullScreen(uniqueId) } + ) + } + ) - // Render normal video player if needed - NormalVideoPlayer( - modifier = modifier, - playerManager = playerManager, - enableRendering = enableRendering, - isPlayButtonVisible = isPlayButtonVisible, - icon = icon, - isPlaying = isPlaying, - isVideoEnded = isVideoEnded, - scaleToCrop = scaleToCrop, - controllerShowTimeoutMs = controllerShowTimeoutMs, - controlsType = controlsType, - customControlsSize = customControlsSize, - customControlsElevation = customControlsElevation, - customControlsShape = customControlsShape, - onFullscreenClick = { - onFullScreen(uniqueId) + // Custom controls overlay + if (controlsType == ControlsType.CustomControls) { + FadeVisibility( + visible = isPlayButtonVisible, + duration = CONTROLS_ANIM_DURATION, + modifier = Modifier.align(Alignment.Center) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier + .size(customControlsSize) + .shadow( + elevation = customControlsElevation, + shape = customControlsShape + ) + .noRippleClickable { + togglePlayback( + isPlaying = isPlaying.value, + isVideoEnded = isVideoEnded, + playerManager = playerManager + ) + } + ) + } } - ) + } - // Pause the player when the composable is disposed or when the lifecycle pauses. + // Lifecycle management DisposableEffect(Unit) { onDispose { if (handleLifecyclePause) { @@ -216,128 +223,71 @@ actual fun VideoPlayer( ) } -@SuppressLint("UnsafeOptInUsageError") -@Composable -private fun NormalVideoPlayer( - modifier: Modifier, - playerManager: VideoPlayerManager, - enableRendering: Boolean, - isPlayButtonVisible: Boolean, - icon: DrawableResource, - isPlaying: MutableState, + +// Toggles playback between play and pause states + +@OptIn(UnstableApi::class) +private fun togglePlayback( + isPlaying: Boolean, isVideoEnded: MutableState, - onFullscreenClick: () -> Unit, - scaleToCrop: Boolean, - controllerShowTimeoutMs: Int, - controlsType: ControlsType, - customControlsSize: Dp, - customControlsElevation: Dp, - customControlsShape: RoundedCornerShape + playerManager: VideoPlayerManager ) { - Box( - modifier = modifier - ) { - VideoPlayerContent( - playerManager = playerManager, - enableRendering = enableRendering, - controlsType = controlsType, - controllerShowTimeoutMs = controllerShowTimeoutMs, - isPlayButtonVisible = isPlayButtonVisible, - icon = icon, - customControlsSize = customControlsSize, - customControlsElevation = customControlsElevation, - customControlsShape = customControlsShape, - resizeMode = when (scaleToCrop) { - true -> AspectRatioFrameLayout.RESIZE_MODE_FILL - false -> AspectRatioFrameLayout.RESIZE_MODE_FIT - }, - onNativeFullscreenClick = onFullscreenClick, - onTogglePlay = { - if (isPlaying.value) { - playerManager.pause() - } else { - if (isVideoEnded.value) { - playerManager.exoPlayer.seekTo(0) - isVideoEnded.value = false - } - playerManager.play() - isPlaying.value = true - } - } - ) + if (isPlaying) { + playerManager.pause() + } else { + if (isVideoEnded.value) { + playerManager.exoPlayer.seekTo(0) + isVideoEnded.value = false + } + playerManager.play() } } +// Creates a PlayerView with the specified configuration @OptIn(UnstableApi::class) -@Composable -private fun VideoPlayerContent( +private fun createPlayerView( + context: android.content.Context, playerManager: VideoPlayerManager, - enableRendering: Boolean, controlsType: ControlsType, controllerShowTimeoutMs: Int, resizeMode: Int, - isPlayButtonVisible: Boolean, - icon: DrawableResource, - customControlsSize: Dp, - customControlsElevation: Dp, - customControlsShape: RoundedCornerShape, - onTogglePlay: () -> Unit, - onNativeFullscreenClick: () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AndroidView( - modifier = Modifier - .fillMaxSize(), - factory = { context -> - PlayerView(context).apply { - useController = (controlsType == ControlsType.NativeControls) - this.controllerShowTimeoutMs = controllerShowTimeoutMs - this.resizeMode = resizeMode - player = playerManager.exoPlayer - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - - setFullscreenButtonClickListener { - onNativeFullscreenClick() - } + enableRendering: Boolean, + onFullscreenClick: () -> Unit +): PlayerView { + return PlayerView(context).apply { + useController = (controlsType == ControlsType.NativeControls) + this.controllerShowTimeoutMs = controllerShowTimeoutMs + this.resizeMode = resizeMode + player = playerManager.exoPlayer + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - if (enableRendering) { - (videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) - } - } - } - }, - update = { playerView -> - playerView.setFullscreenButtonClickListener { - onNativeFullscreenClick() - } + setFullscreenButtonClickListener { + onFullscreenClick() + } - if (enableRendering) { - (playerView.videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) - } - } - } - ) - // For custom controls, overlay a play/pause button. - if (controlsType == ControlsType.CustomControls) { - FadeVisibility( - visible = isPlayButtonVisible, - duration = CONTROLS_ANIM_DURATION, - modifier = Modifier.align(Alignment.Center) - ) { - Image( - painter = painterResource(icon), - contentDescription = null, - modifier = Modifier - .size(customControlsSize) - .shadow( - elevation = customControlsElevation, - shape = customControlsShape - ) - .noRippleClickable { onTogglePlay() } - ) + if (enableRendering) { + (videoSurfaceView as? SurfaceView)?.let { + playerManager.exoPlayer.setVideoSurfaceView(it) } } } +} + +// Updates an existing PlayerView with new configuration +@OptIn(UnstableApi::class) +private fun updatePlayerView( + playerView: PlayerView, + playerManager: VideoPlayerManager, + enableRendering: Boolean, + onFullscreenClick: () -> Unit +) { + playerView.setFullscreenButtonClickListener { + onFullscreenClick() + } + + if (enableRendering) { + (playerView.videoSurfaceView as? SurfaceView)?.let { + playerManager.exoPlayer.setVideoSurfaceView(it) + } + } } \ No newline at end of file From 873344760e2f8328993d4fdb3a5b97ac16b0577a Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Thu, 1 May 2025 23:11:29 +0400 Subject: [PATCH 04/18] resume video in case back from home screen --- .../core/presentation/components/videoPlayer/Events.kt | 1 + .../components/videoPlayer/VideoPlayer.android.kt | 9 +++++++++ .../components/videoPlayer/VideoPlayerActivity.kt | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index dd892c0d..22b766b8 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch internal sealed class VideoPlayerEvent { data object StoppedPip : VideoPlayerEvent() + data class ReturnedFromFullscreen(val wasPlaying: Boolean) : VideoPlayerEvent() } internal object VideoPlayerEventBroadcaster { diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 64b77406..18c79f3d 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -32,6 +32,7 @@ import com.metacto.core.domain.DiQualifiers import com.metacto.core.presentation.components.visibilities.FadeVisibility import com.metacto.core.utils.extensions.OnLifecycleEvent import com.metacto.core.utils.extensions.noRippleClickable +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject @@ -88,6 +89,14 @@ actual fun VideoPlayer( enableRendering = true } + eventBroadcaster.collectInCompose { event -> + enableRendering = true + if (event.wasPlaying) { + // Resume playback + playerManager.play() + } + } + // Player controller setup val controller = remember(playerManager) { object : VideoPlayerController { diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index fa61e759..f022f037 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -74,7 +74,8 @@ internal class VideoPlayerActivity : AppCompatActivity() { } override fun onStop() { - eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + val wasPlaying = exoPlayer?.isPlaying ?: false + eventBroadcaster.emit(VideoPlayerEvent.ReturnedFromFullscreen(wasPlaying)) exoPlayer?.pause() super.onStop() } From cf2c0dce05338d8116649ebd76a6d30cbc9b1d8c Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 02:05:38 +0400 Subject: [PATCH 05/18] implement auto pip and auto pip size based on video size --- .../components/videoPlayer/Events.kt | 3 +- .../videoPlayer/VideoPlayer.android.kt | 20 ++-- .../videoPlayer/VideoPlayerActivity.kt | 104 ++++++++++++++++-- .../videoPlayer/VideoPlayerManager.kt | 22 +++- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 22b766b8..c92bee99 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -12,7 +12,8 @@ import kotlinx.coroutines.launch internal sealed class VideoPlayerEvent { data object StoppedPip : VideoPlayerEvent() - data class ReturnedFromFullscreen(val wasPlaying: Boolean) : VideoPlayerEvent() + data object StartedPip : VideoPlayerEvent() + data object DisablePip : VideoPlayerEvent() } internal object VideoPlayerEventBroadcaster { diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 18c79f3d..72959341 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -32,7 +32,6 @@ import com.metacto.core.domain.DiQualifiers import com.metacto.core.presentation.components.visibilities.FadeVisibility import com.metacto.core.utils.extensions.OnLifecycleEvent import com.metacto.core.utils.extensions.noRippleClickable -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject @@ -87,16 +86,20 @@ actual fun VideoPlayer( // Event handling eventBroadcaster.collectInCompose { enableRendering = true + playerManager.pause() } - eventBroadcaster.collectInCompose { event -> - enableRendering = true - if (event.wasPlaying) { - // Resume playback - playerManager.play() + eventBroadcaster.collectInCompose { + if (!enablePip) { + //TODO Handle disabling PiP if needed } } + // Add PiP configuration + LaunchedEffect(enablePip) { + playerManager.isPipEnabled = enablePip + } + // Player controller setup val controller = remember(playerManager) { object : VideoPlayerController { @@ -154,9 +157,9 @@ actual fun VideoPlayer( onPlayerCreated?.invoke(controller) } - // Fullscreen handler + // Fullscreen handler with PiP support fun onFullScreen(id: String) { - VideoPlayerActivity.start(context = context, uniqueId = id) + VideoPlayerActivity.start(context = context, uniqueId = id, enablePip = enablePip) enableRendering = false } @@ -234,7 +237,6 @@ actual fun VideoPlayer( // Toggles playback between play and pause states - @OptIn(UnstableApi::class) private fun togglePlayback( isPlaying: Boolean, diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index f022f037..67a5fe1a 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -3,9 +3,14 @@ package com.metacto.core.presentation.components.videoPlayer import android.app.PictureInPictureParams import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.Rect +import android.os.Build import android.os.Bundle import android.util.Rational import android.view.SurfaceView +import android.view.View import android.widget.ImageButton import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity @@ -16,9 +21,12 @@ import com.metacto.core.domain.DiQualifiers import com.metacto.coreApp.R import org.koin.android.ext.android.inject +@OptIn(UnstableApi::class) internal class VideoPlayerActivity : AppCompatActivity() { private val eventBroadcaster: VideoPlayerEventBroadcaster by inject() private var exoPlayer: ExoPlayer? = null + private var isPipSupported = false + private var wasPlayingBeforePip = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -26,6 +34,11 @@ internal class VideoPlayerActivity : AppCompatActivity() { // Set content view setContentView(R.layout.activity_video_player) + // Check PiP support + isPipSupported = + packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + intent?.getBooleanExtra(KEY_ENABLE_PIP, true) == true + // Setup views setupViews() @@ -40,17 +53,38 @@ internal class VideoPlayerActivity : AppCompatActivity() { ibPip.setOnClickListener { enablePip() } + ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE } private fun enablePip() { - val params = PictureInPictureParams.Builder().run { - setAspectRatio(Rational(16, 9)) - build() - } + if (!isPipSupported) return + + wasPlayingBeforePip = exoPlayer?.isPlaying ?: false + + val videoWidth = exoPlayer?.videoSize?.width ?: 16 + val videoHeight = exoPlayer?.videoSize?.height ?: 9 + + val params = PictureInPictureParams.Builder().apply { + setAspectRatio(Rational(videoWidth, videoHeight)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val playerView = findViewById(R.id.player_view) + playerView.videoSurfaceView?.let { surfaceView -> + val rect = Rect() + val coordinates = IntArray(2) + surfaceView.getLocationOnScreen(coordinates) + rect.set( + coordinates[0], + coordinates[1], + coordinates[0] + surfaceView.width, + coordinates[1] + surfaceView.height + ) + setSourceRectHint(rect) + } + } + }.build() - if (isInPictureInPictureMode.not()) { - enterPictureInPictureMode(params) - } + enterPictureInPictureMode(params) } @OptIn(UnstableApi::class) @@ -64,6 +98,51 @@ internal class VideoPlayerActivity : AppCompatActivity() { (playerView.videoSurfaceView as? SurfaceView)?.let { exoPlayer?.setVideoSurfaceView(it) } + + playerManagers[playerId]?.setVideoSizeListener { width, height -> + if (isInPictureInPictureMode && width > 0 && height > 0) { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(width, height)) + .build() + setPictureInPictureParams(params) + } + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + + val playerView = findViewById(R.id.player_view) + + if (isInPictureInPictureMode) { + playerView.useController = false + playerView.hideController() + + if (wasPlayingBeforePip) { + exoPlayer?.play() + } + + eventBroadcaster.emit(VideoPlayerEvent.StartedPip) + } else { + playerView.useController = true + + // Reattach the surface view to fix the freezing issue + (playerView.videoSurfaceView as? SurfaceView)?.let { + exoPlayer?.setVideoSurfaceView(it) + } + + eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (isPipSupported && exoPlayer?.isPlaying == true) { + enablePip() + } } override fun onNewIntent(intent: Intent) { @@ -74,18 +153,21 @@ internal class VideoPlayerActivity : AppCompatActivity() { } override fun onStop() { - val wasPlaying = exoPlayer?.isPlaying ?: false - eventBroadcaster.emit(VideoPlayerEvent.ReturnedFromFullscreen(wasPlaying)) - exoPlayer?.pause() + if (!isInPictureInPictureMode) { + eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + exoPlayer?.pause() + } super.onStop() } companion object { private const val KEY_PLAYER_ID = "player_id" + private const val KEY_ENABLE_PIP = "enable_pip" - fun start(context: Context, uniqueId: String) { + fun start(context: Context, uniqueId: String, enablePip: Boolean = true) { val intent = Intent(context, VideoPlayerActivity::class.java).apply { putExtra(KEY_PLAYER_ID, uniqueId) + putExtra(KEY_ENABLE_PIP, enablePip) } context.startActivity(intent) } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index afb12ae5..634f3f12 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -5,6 +5,7 @@ import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.MediaMetadata import androidx.media3.common.Player +import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession @@ -26,6 +27,8 @@ internal class VideoPlayerManager( private var isMediaMetadataEnabled = false var onVideoLoop: (() -> Unit)? = null var onVideoEnd: (() -> Unit)? = null + var isPipEnabled: Boolean = true + private var videoSizeListener: ((width: Int, height: Int) -> Unit)? = null // Define the exo player val exoPlayer by lazy { @@ -95,6 +98,10 @@ internal class VideoPlayerManager( onVideoEnd?.invoke() } } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + videoSizeListener?.invoke(videoSize.width, videoSize.height) + } }) } @@ -110,8 +117,19 @@ internal class VideoPlayerManager( this.isAutoPlay = isAutoPlay } - fun setAutoRepeat(autoRepeat:Boolean){ - this.exoPlayer.repeatMode = if (autoRepeat) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + fun setAutoRepeat(autoRepeat: Boolean) { + this.exoPlayer.repeatMode = + if (autoRepeat) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + + fun setVideoSizeListener(listener: (width: Int, height: Int) -> Unit) { + this.videoSizeListener = listener + + val currentWidth = exoPlayer.videoSize.width + val currentHeight = exoPlayer.videoSize.height + if (currentWidth > 0 && currentHeight > 0) { + listener(currentWidth, currentHeight) + } } @OptIn(UnstableApi::class) From 17694abbfdf23869ba48bf5aea17bcc64a7be77e Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 11:15:51 +0400 Subject: [PATCH 06/18] handle video continue between pip and fullscreen modes --- coreapp/src/androidMain/AndroidManifest.xml | 2 +- .../components/videoPlayer/Events.kt | 1 + .../videoPlayer/VideoPlayer.android.kt | 74 ++++---- .../videoPlayer/VideoPlayerActivity.kt | 173 ++++++++++++------ 4 files changed, 159 insertions(+), 91 deletions(-) diff --git a/coreapp/src/androidMain/AndroidManifest.xml b/coreapp/src/androidMain/AndroidManifest.xml index fd8ffd55..f96ac5ca 100644 --- a/coreapp/src/androidMain/AndroidManifest.xml +++ b/coreapp/src/androidMain/AndroidManifest.xml @@ -21,7 +21,7 @@ android:name="com.metacto.core.presentation.components.videoPlayer.VideoPlayerActivity" android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout" android:screenOrientation="landscape" - android:launchMode="singleInstance" + android:launchMode="singleTop" android:supportsPictureInPicture="true" android:theme="@style/VideoPlayer" tools:ignore="DiscouragedApi" /> diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index c92bee99..1246a702 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch internal sealed class VideoPlayerEvent { data object StoppedPip : VideoPlayerEvent() data object StartedPip : VideoPlayerEvent() + data object ActivityFinished : VideoPlayerEvent() data object DisablePip : VideoPlayerEvent() } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 72959341..967812a7 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -69,38 +69,41 @@ actual fun VideoPlayer( onVideoLoop: (() -> Unit)?, onVideoEnd: (() -> Unit)? ) { - // Setup context and dependencies val context = LocalContext.current val eventBroadcaster = koinInject() - val playerManagers = - koinInject>(DiQualifiers.videoPlayerManagers) + val playerManagers = koinInject>(DiQualifiers.videoPlayerManagers) val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } - // State management val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } val isVideoEnded = remember { mutableStateOf(false) } var enableRendering by remember { mutableStateOf(true) } + var shouldResumePlayback by remember { mutableStateOf(false) } val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } - // Event handling - eventBroadcaster.collectInCompose { + eventBroadcaster.collectInCompose { enableRendering = true - playerManager.pause() + if (shouldResumePlayback) { + playerManager.play() + shouldResumePlayback = false + } + isPlaying.value = playerManager.exoPlayer.isPlaying } - eventBroadcaster.collectInCompose { - if (!enablePip) { - //TODO Handle disabling PiP if needed - } + eventBroadcaster.collectInCompose { + enableRendering = false + shouldResumePlayback = playerManager.exoPlayer.isPlaying + } + + eventBroadcaster.collectInCompose { + enableRendering = false + shouldResumePlayback = playerManager.exoPlayer.isPlaying } - // Add PiP configuration LaunchedEffect(enablePip) { playerManager.isPipEnabled = enablePip } - // Player controller setup val controller = remember(playerManager) { object : VideoPlayerController { override fun play() = playerManager.play() @@ -108,9 +111,7 @@ actual fun VideoPlayer( } } - // Configure player options LaunchedEffect(key1 = playerManager) { - // Setup player listeners playerManager.exoPlayer.addListener(object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { isPlaying.value = playWhenReady @@ -130,42 +131,34 @@ actual fun VideoPlayer( }) } - // Configure player settings LaunchedEffect( playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl, autoPlay, scaleToCrop, autoRepeat, enableVoice, enableMediaMetadata, onVideoLoop, onVideoEnd, controller, onPlayerCreated ) { - // Media configuration playerManager.setMedia( videoUrl = videoUrl, videoTitle = videoTitle, videoArtist = videoArtist, videoArtworkUrl = videoArtworkUrl ) - - // Player settings playerManager.setAutoPlay(autoPlay) playerManager.setScaleToCrop(scaleToCrop) playerManager.setAutoRepeat(autoRepeat) playerManager.setMediaMetadataEnabled(enableMediaMetadata) playerManager.exoPlayer.volume = if (enableVoice) 1f else 0f - - // Callbacks playerManager.onVideoLoop = onVideoLoop playerManager.onVideoEnd = onVideoEnd onPlayerCreated?.invoke(controller) } - // Fullscreen handler with PiP support fun onFullScreen(id: String) { - VideoPlayerActivity.start(context = context, uniqueId = id, enablePip = enablePip) + shouldResumePlayback = playerManager.exoPlayer.isPlaying enableRendering = false + VideoPlayerActivity.start(context = context, uniqueId = id, enablePip = enablePip) } - // Render video player Box(modifier = modifier) { - // Player view AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> @@ -190,10 +183,9 @@ actual fun VideoPlayer( } ) - // Custom controls overlay if (controlsType == ControlsType.CustomControls) { FadeVisibility( - visible = isPlayButtonVisible, + visible = isPlayButtonVisible && enableRendering, duration = CONTROLS_ANIM_DURATION, modifier = Modifier.align(Alignment.Center) ) { @@ -218,7 +210,6 @@ actual fun VideoPlayer( } } - // Lifecycle management DisposableEffect(Unit) { onDispose { if (handleLifecyclePause) { @@ -226,17 +217,23 @@ actual fun VideoPlayer( } } } + OnLifecycleEvent( onPause = { - if (handleLifecyclePause) { + if (handleLifecyclePause && enableRendering) { + shouldResumePlayback = playerManager.exoPlayer.isPlaying playerManager.pause() } + }, + onResume = { + if (handleLifecyclePause && enableRendering && shouldResumePlayback) { + playerManager.play() + shouldResumePlayback = false + } } ) } - -// Toggles playback between play and pause states @OptIn(UnstableApi::class) private fun togglePlayback( isPlaying: Boolean, @@ -254,7 +251,6 @@ private fun togglePlayback( } } -// Creates a PlayerView with the specified configuration @OptIn(UnstableApi::class) private fun createPlayerView( context: android.content.Context, @@ -279,12 +275,14 @@ private fun createPlayerView( if (enableRendering) { (videoSurfaceView as? SurfaceView)?.let { playerManager.exoPlayer.setVideoSurfaceView(it) + } ?: run { + playerManager.exoPlayer.setVideoSurface(null) + playerManager.exoPlayer.setVideoSurfaceView(videoSurfaceView as? SurfaceView) } } } } -// Updates an existing PlayerView with new configuration @OptIn(UnstableApi::class) private fun updatePlayerView( playerView: PlayerView, @@ -297,8 +295,16 @@ private fun updatePlayerView( } if (enableRendering) { + if (playerView.player != playerManager.exoPlayer) { + playerView.player = playerManager.exoPlayer + } (playerView.videoSurfaceView as? SurfaceView)?.let { playerManager.exoPlayer.setVideoSurfaceView(it) + } ?: run { + playerManager.exoPlayer.setVideoSurface(null) + playerManager.exoPlayer.setVideoSurfaceView(playerView.videoSurfaceView as? SurfaceView) } + } else { + playerManager.exoPlayer.clearVideoSurface() } -} \ No newline at end of file +} diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 67a5fe1a..946a8fcf 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -1,8 +1,10 @@ package com.metacto.core.presentation.components.videoPlayer 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.content.res.Configuration import android.graphics.Rect @@ -14,6 +16,10 @@ import android.view.View import android.widget.ImageButton import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView @@ -22,30 +28,55 @@ import com.metacto.coreApp.R import org.koin.android.ext.android.inject @OptIn(UnstableApi::class) -internal class VideoPlayerActivity : AppCompatActivity() { +internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private val eventBroadcaster: VideoPlayerEventBroadcaster by inject() private var exoPlayer: ExoPlayer? = null private var isPipSupported = false private var wasPlayingBeforePip = false + private var playerView: PlayerView? = null + private var playerId: String? = null + + private val pipActionsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { + CONTROL_TYPE_PLAY_PAUSE -> { + exoPlayer?.playWhenReady = !(exoPlayer?.playWhenReady ?: false) + } + } + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + hideSystemBars() - // Set content view setContentView(R.layout.activity_video_player) + playerView = findViewById(R.id.player_view) - // Check PiP support isPipSupported = packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && intent?.getBooleanExtra(KEY_ENABLE_PIP, true) == true - // Setup views setupViews() - // Config player view intent?.getStringExtra(KEY_PLAYER_ID)?.let { + this.playerId = it configPlayerView(it) } + + registerReceiver(pipActionsReceiver, IntentFilter(ACTION_MEDIA_CONTROL), RECEIVER_EXPORTED) + } + + private fun hideSystemBars() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + + private fun showSystemBars() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) } private fun setupViews() { @@ -61,79 +92,88 @@ internal class VideoPlayerActivity : AppCompatActivity() { wasPlayingBeforePip = exoPlayer?.isPlaying ?: false + val params = updatePipParams() + if (params != null) { + enterPictureInPictureMode(params) + } + } + + private fun updatePipParams(): PictureInPictureParams? { val videoWidth = exoPlayer?.videoSize?.width ?: 16 val videoHeight = exoPlayer?.videoSize?.height ?: 9 - val params = PictureInPictureParams.Builder().apply { - setAspectRatio(Rational(videoWidth, videoHeight)) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val playerView = findViewById(R.id.player_view) - playerView.videoSurfaceView?.let { surfaceView -> - val rect = Rect() - val coordinates = IntArray(2) - surfaceView.getLocationOnScreen(coordinates) - rect.set( - coordinates[0], - coordinates[1], - coordinates[0] + surfaceView.width, - coordinates[1] + surfaceView.height - ) - setSourceRectHint(rect) - } - } - }.build() + val builder = PictureInPictureParams.Builder() + .setAspectRatio(Rational(videoWidth.coerceAtLeast(1), videoHeight.coerceAtLeast(1))) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(true) + builder.setSeamlessResizeEnabled(false) + } - enterPictureInPictureMode(params) + playerView?.videoSurfaceView?.let { surfaceView -> + val rect = Rect() + surfaceView.getGlobalVisibleRect(rect) + builder.setSourceRectHint(rect) + } + + val params = builder.build() + setPictureInPictureParams(params) + return params } @OptIn(UnstableApi::class) private fun configPlayerView(playerId: String) { val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - exoPlayer = playerManagers[playerId]?.exoPlayer ?: return + val playerManager = playerManagers[playerId] + val newPlayer = playerManager?.exoPlayer + + if (newPlayer == null) { + finish() + return + } + + exoPlayer?.removeListener(this) - val playerView = findViewById(R.id.player_view) - playerView.player = exoPlayer + exoPlayer = newPlayer + playerView?.player = exoPlayer + exoPlayer?.addListener(this) - (playerView.videoSurfaceView as? SurfaceView)?.let { + (playerView?.videoSurfaceView as? SurfaceView)?.let { exoPlayer?.setVideoSurfaceView(it) } - playerManagers[playerId]?.setVideoSizeListener { width, height -> - if (isInPictureInPictureMode && width > 0 && height > 0) { - val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(width, height)) - .build() - setPictureInPictureParams(params) + playerManager.setVideoSizeListener { _, _ -> + if (isInPictureInPictureMode) { + updatePipParams() } } } + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isInPictureInPictureMode) { + updatePipParams() + } + } + override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - val playerView = findViewById(R.id.player_view) - if (isInPictureInPictureMode) { - playerView.useController = false - playerView.hideController() - - if (wasPlayingBeforePip) { - exoPlayer?.play() - } - + playerView?.useController = false + playerView?.hideController() eventBroadcaster.emit(VideoPlayerEvent.StartedPip) } else { - playerView.useController = true - - // Reattach the surface view to fix the freezing issue - (playerView.videoSurfaceView as? SurfaceView)?.let { + playerView?.useController = true + showSystemBars() + if (playerView?.player == null) { + playerView?.player = exoPlayer + } + (playerView?.videoSurfaceView as? SurfaceView)?.let { exoPlayer?.setVideoSurfaceView(it) } - eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) } } @@ -148,23 +188,44 @@ internal class VideoPlayerActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) intent.getStringExtra(KEY_PLAYER_ID)?.let { - configPlayerView(it) + if (it != this.playerId) { + this.playerId = it + configPlayerView(it) + } } } override fun onStop() { - if (!isInPictureInPictureMode) { - eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) - exoPlayer?.pause() - } super.onStop() + if (!isInPictureInPictureMode && !isChangingConfigurations) { + if (!isFinishing) { + exoPlayer?.pause() + } + } + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(pipActionsReceiver) + exoPlayer?.removeListener(this) + playerView?.player = null + exoPlayer = null + + eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished) } companion object { private const val KEY_PLAYER_ID = "player_id" private const val KEY_ENABLE_PIP = "enable_pip" - - fun start(context: Context, uniqueId: String, enablePip: Boolean = true) { + private const val ACTION_MEDIA_CONTROL = "media_control" + private const val EXTRA_CONTROL_TYPE = "control_type" + private const val CONTROL_TYPE_PLAY_PAUSE = 1 + + fun start( + context: Context, + uniqueId: String, + enablePip: Boolean = true + ) { val intent = Intent(context, VideoPlayerActivity::class.java).apply { putExtra(KEY_PLAYER_ID, uniqueId) putExtra(KEY_ENABLE_PIP, enablePip) From 3dd1f4937e84402d528d36f1d91e02d8a4aba7a9 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 11:35:23 +0400 Subject: [PATCH 07/18] remove unwanted event from Events.kt --- .../metacto/core/presentation/components/videoPlayer/Events.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 1246a702..2665a997 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -14,7 +14,6 @@ internal sealed class VideoPlayerEvent { data object StoppedPip : VideoPlayerEvent() data object StartedPip : VideoPlayerEvent() data object ActivityFinished : VideoPlayerEvent() - data object DisablePip : VideoPlayerEvent() } internal object VideoPlayerEventBroadcaster { From bf28425922c0a1e27fdd6819a056973ddf1e907f Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 11:44:28 +0400 Subject: [PATCH 08/18] remove pip icon in case pip mode and show again in full screen mode --- .../core/presentation/components/videoPlayer/Events.kt | 8 -------- .../components/videoPlayer/VideoPlayerActivity.kt | 6 ++++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 2665a997..849727c5 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -37,12 +37,4 @@ internal object VideoPlayerEventBroadcaster { } } } - - suspend inline fun collect(crossinline onReceived: (T) -> Unit) { - events - .filter { it is T } - .collectLatest { - onReceived(it as T) - } - } } \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 946a8fcf..9acd9017 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -161,13 +161,19 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + val ibPip = findViewById(R.id.ib_pip) + if (isInPictureInPictureMode) { playerView?.useController = false playerView?.hideController() + // Hide the PiP button when in PiP mode + ibPip.visibility = View.GONE eventBroadcaster.emit(VideoPlayerEvent.StartedPip) } else { playerView?.useController = true showSystemBars() + // Show the PiP button again when exiting PiP mode + ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE if (playerView?.player == null) { playerView?.player = exoPlayer } From 73cb624f1730f3878e930d5165c52e7b70f8b218 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 11:52:56 +0400 Subject: [PATCH 09/18] keep launch mode as singleInstance instead of single top --- coreapp/src/androidMain/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coreapp/src/androidMain/AndroidManifest.xml b/coreapp/src/androidMain/AndroidManifest.xml index f96ac5ca..fd8ffd55 100644 --- a/coreapp/src/androidMain/AndroidManifest.xml +++ b/coreapp/src/androidMain/AndroidManifest.xml @@ -21,7 +21,7 @@ android:name="com.metacto.core.presentation.components.videoPlayer.VideoPlayerActivity" android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout" android:screenOrientation="landscape" - android:launchMode="singleTop" + android:launchMode="singleInstance" android:supportsPictureInPicture="true" android:theme="@style/VideoPlayer" tools:ignore="DiscouragedApi" /> From 41b4d0a2673082d12be28e7dfa119eb6e9b456bd Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 12:05:23 +0400 Subject: [PATCH 10/18] try to handle multi instance of activity creating when move to pip mode by home button and close it --- .../videoPlayer/VideoPlayerActivity.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 9acd9017..7cab4f50 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -11,9 +11,11 @@ import android.graphics.Rect import android.os.Build import android.os.Bundle import android.util.Rational +import android.view.MotionEvent import android.view.SurfaceView import android.view.View import android.widget.ImageButton +import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat @@ -33,6 +35,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private var exoPlayer: ExoPlayer? = null private var isPipSupported = false private var wasPlayingBeforePip = false + private var hasUserInteracted = false private var playerView: PlayerView? = null private var playerId: String? = null @@ -63,8 +66,22 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { this.playerId = it configPlayerView(it) } - registerReceiver(pipActionsReceiver, IntentFilter(ACTION_MEDIA_CONTROL), RECEIVER_EXPORTED) + handleBackPress() + } + + private fun handleBackPress() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + + if (isInPictureInPictureMode) { + finish() + return + } + this.remove() + onBackPressedDispatcher.onBackPressed() + } + }) } private fun hideSystemBars() { @@ -164,6 +181,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { val ibPip = findViewById(R.id.ib_pip) if (isInPictureInPictureMode) { + hasUserInteracted = false playerView?.useController = false playerView?.hideController() // Hide the PiP button when in PiP mode @@ -181,6 +199,10 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { exoPlayer?.setVideoSurfaceView(it) } eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + + if (!isChangingConfigurations && !hasUserInteracted) { + finish() + } } } @@ -220,6 +242,11 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished) } + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + hasUserInteracted = true + return super.dispatchTouchEvent(ev) + } + companion object { private const val KEY_PLAYER_ID = "player_id" private const val KEY_ENABLE_PIP = "enable_pip" @@ -235,6 +262,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { val intent = Intent(context, VideoPlayerActivity::class.java).apply { putExtra(KEY_PLAYER_ID, uniqueId) putExtra(KEY_ENABLE_PIP, enablePip) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } context.startActivity(intent) } From 951754125d99089324db9b5bf914219a4cd45e43 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 2 May 2025 20:52:50 +0400 Subject: [PATCH 11/18] Adding cast manager to start use it in video player manager --- buildSrc/src/main/kotlin/Dependencies.kt | 3 + buildSrc/src/main/kotlin/Versions.kt | 2 + coreapp/build.gradle.kts | 5 + .../components/videoPlayer/CastManager.kt | 450 ++++++++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 315bed2e..10cbf8cc 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -82,6 +82,7 @@ object Dependencies { const val HLS = "androidx.media3:media3-exoplayer-hls:${Versions.EXO_PLAYER}" const val UI = "androidx.media3:media3-ui:${Versions.EXO_PLAYER}" const val SESSION = "androidx.media3:media3-session:${Versions.EXO_PLAYER}" + const val MEDIA3_CAST = "androidx.media3:media3-cast:${Versions.EXO_PLAYER}" } object YoutubePlayer { @@ -115,4 +116,6 @@ object Dependencies { const val SHIMMER = "com.valentinilk.shimmer:compose-shimmer:${Versions.SHIMMER}" const val KMP_NOTIFIER = "io.github.mirzemehdi:kmpnotifier:${Versions.KMP_NOTIFIER}" const val STATELY_COMMON = "co.touchlab:stately-common:2.0.5" + const val MEDIA_ROUTER = "androidx.mediarouter:mediarouter:${Versions.MEDIA_ROUTER_VERSION}" + const val CAST_FRAMEWORK = "com.google.android.gms:play-services-cast-framework:${Versions.PLAY_SERVICES_CAST_VERSION}" } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index ab35d9de..a49b6f53 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -30,6 +30,8 @@ object Versions { const val COMPOTTIE = "1.1.0" const val WEBVIEW = "1.9.40-alpha04" const val EXO_PLAYER = "1.3.1" + const val MEDIA_ROUTER_VERSION = "1.4.0" + const val PLAY_SERVICES_CAST_VERSION = "21.3.0" const val GIT_LIVE = "1.10.4" const val YOUTUBE_PLAYER = "12.1.0" const val SHIMMER = "1.3.1" diff --git a/coreapp/build.gradle.kts b/coreapp/build.gradle.kts index 3fdd6f4d..52279734 100644 --- a/coreapp/build.gradle.kts +++ b/coreapp/build.gradle.kts @@ -118,6 +118,11 @@ kotlin { api(Dependencies.ExoPlayer.HLS) api(Dependencies.ExoPlayer.UI) api(Dependencies.ExoPlayer.SESSION) + api(Dependencies.ExoPlayer.MEDIA3_CAST) + + // Cast dependencies + api(Dependencies.MEDIA_ROUTER) + api(Dependencies.CAST_FRAMEWORK) // Youtube Player api(Dependencies.YoutubePlayer.CORE) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt new file mode 100644 index 00000000..8404e86c --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt @@ -0,0 +1,450 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.content.Context +import android.content.Intent +import androidx.annotation.OptIn +import androidx.media3.cast.CastPlayer +import androidx.media3.cast.SessionAvailabilityListener +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.framework.CastButtonFactory +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.CastState +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.Executors + +/** + * Manages cast functionality for video playback + */ +@OptIn(UnstableApi::class) +class CastManager(private val context: Context) { + + private var castPlayer: CastPlayer? = null + private var castContext: CastContext? = null + private var currentPlayer: Player? = null + private var localExoPlayer: ExoPlayer? = null + + private var currentMediaItem: MediaItem? = null + private var currentUri: String? = null + private var playbackPosition: Long = 0 + private var wasPlaying: Boolean = false + + private val _castAvailable = MutableStateFlow(false) + val castAvailable: StateFlow = _castAvailable.asStateFlow() + + private val _isCasting = MutableStateFlow(false) + val isCasting: StateFlow = _isCasting.asStateFlow() + + private val _errorState = MutableStateFlow(null) + val errorState: StateFlow = _errorState.asStateFlow() + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val coroutineScope = CoroutineScope(Dispatchers.Main) + + private val sessionManagerListener = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) { + // Session is starting + } + + override fun onSessionStarted(session: CastSession, sessionId: String) { + onCastSessionStarted(session) + } + + override fun onSessionStartFailed(session: CastSession, error: Int) { + _isCasting.value = false + _errorState.value = "Cast session failed to start" + } + + override fun onSessionEnding(session: CastSession) { + // Session is ending + } + + override fun onSessionEnded(session: CastSession, error: Int) { + onCastSessionEnded() + } + + override fun onSessionResuming(session: CastSession, sessionId: String) { + // Session is resuming + } + + override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { + onCastSessionStarted(session) + } + + override fun onSessionResumeFailed(session: CastSession, error: Int) { + _isCasting.value = false + _errorState.value = "Cast session failed to resume" + } + + override fun onSessionSuspended(session: CastSession, reason: Int) { + _isCasting.value = false + } + } + + init { + initializeCast() + } + + private fun initializeCast() { + if (!isGooglePlayServicesAvailable()) { + _castAvailable.value = false + _isCasting.value = false + _errorState.value = "Google Play Services not available" + _isInitialized.value = false + return + } + + // Use the non-deprecated method that returns a Task + val executor = Executors.newSingleThreadExecutor() + val castContextTask = CastContext.getSharedInstance(context, executor) + + castContextTask.addOnSuccessListener { castContext -> + // Handle successful CastContext initialization + this.castContext = castContext + + // Add listener for cast state changes + castContext.addCastStateListener { state -> + _castAvailable.value = state != CastState.NO_DEVICES_AVAILABLE + } + + // Create cast player + val player = CastPlayer(castContext) + castPlayer = player + + // Add cast session manager listener + castContext.sessionManager.addSessionManagerListener( + sessionManagerListener, + CastSession::class.java + ) + + // Check initial cast state + _castAvailable.value = castContext.castState != CastState.NO_DEVICES_AVAILABLE + + // Check if already casting + val castSession = castContext.sessionManager.currentCastSession + if (castSession != null && castSession.isConnected) { + onCastSessionStarted(castSession) + } + + _errorState.value = null + _isInitialized.value = true + } + + castContextTask.addOnFailureListener { exception -> + _castAvailable.value = false + _isCasting.value = false + _errorState.value = "Failed to initialize cast: ${exception.message}" + _isInitialized.value = false + } + } + + /** + * Set up a cast button that can be inflated into the layout + */ + fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton): Boolean { + return runCatching { + castContext?.let { + CastButtonFactory.setUpMediaRouteButton(context, mediaRouteButton) + true + } ?: false + }.getOrDefault(false) + } + + /** + * Set the local ExoPlayer instance for playback transfer + */ + fun setLocalPlayer(exoPlayer: ExoPlayer) { + localExoPlayer = exoPlayer + + // If we're not casting, set the current player to the local player + if (!_isCasting.value) { + currentPlayer = exoPlayer + } + } + + /** + * Start casting the current media + */ + fun startCasting(mediaItem: MediaItem): Boolean { + if (castContext == null) { + _errorState.value = "Cast not initialized" + return false + } + + currentMediaItem = mediaItem + currentUri = mediaItem.mediaId + + // Save the current playback position and state + localExoPlayer?.let { exoPlayer -> + playbackPosition = exoPlayer.currentPosition + wasPlaying = exoPlayer.isPlaying + } + + // Create a launch intent for the Cast session + val sessionIntent = Intent(context, context.javaClass) + + return runCatching { + castContext?.sessionManager?.startSession(sessionIntent) + true + }.getOrDefault(false) + } + + /** + * Stop casting and return to local playback + */ + fun stopCasting(): Boolean { + return runCatching { + castContext?.sessionManager?.endCurrentSession(true) + true + }.getOrDefault(false) + } + + /** + * Handle cast session started + */ + private fun onCastSessionStarted(castSession: CastSession) { + // Save the local player state + localExoPlayer?.let { exoPlayer -> + playbackPosition = exoPlayer.currentPosition + wasPlaying = exoPlayer.isPlaying + if (currentMediaItem == null) { + currentMediaItem = exoPlayer.currentMediaItem + } + if (currentUri == null && currentMediaItem != null) { + currentUri = currentMediaItem?.mediaId + } + + // Pause local playback + exoPlayer.pause() + } + + // Set up the cast player + castPlayer?.setSessionAvailabilityListener(object : SessionAvailabilityListener { + override fun onCastSessionAvailable() { + transferPlaybackToCast(castSession) + } + + override fun onCastSessionUnavailable() { + _isCasting.value = false + } + }) + + // Set current player to cast player + currentPlayer = castPlayer + _isCasting.value = true + + // Transfer playback if media item is available + if (currentMediaItem != null || currentUri != null) { + transferPlaybackToCast(castSession) + } + } + + /** + * Handle cast session ended + */ + private fun onCastSessionEnded() { + // Set current player back to local player + currentPlayer = localExoPlayer + _isCasting.value = false + + // Resume local playback + localExoPlayer?.let { exoPlayer -> + currentMediaItem?.let { mediaItem -> + runCatching { + exoPlayer.setMediaItem(mediaItem, playbackPosition) + exoPlayer.prepare() + if (wasPlaying) { + exoPlayer.play() + } + } + } + } + } + + /** + * Transfer playback to cast device + * Returns true if transfer was successful + */ + private fun transferPlaybackToCast(castSession: CastSession): Boolean { + val remoteMediaClient = castSession.remoteMediaClient ?: return false + + // Try to load via Media3 CastPlayer first + val castTransferResult = runCatching { + castPlayer?.let { player -> + currentMediaItem?.let { mediaItem -> + player.setMediaItem(mediaItem, playbackPosition) + player.prepare() + if (wasPlaying) { + player.play() + } + return true + } + } + false + }.getOrDefault(false) + + if (castTransferResult) { + return true + } + + // Fallback to legacy Cast API if Media3 approach fails + return runCatching { + currentUri?.let { uri -> + val mediaInfo = createMediaInfo(uri) + val loadRequestData = MediaLoadRequestData.Builder() + .setMediaInfo(mediaInfo) + .setAutoplay(wasPlaying) + .setCurrentTime(playbackPosition) + .build() + + remoteMediaClient.load(loadRequestData) + true + } ?: false + }.getOrDefault(false) + } + + /** + * Create MediaInfo for legacy Cast API + */ + private fun createMediaInfo(uri: String): MediaInfo { + val contentType = getContentType(uri) + + return MediaInfo.Builder(uri) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(contentType) + .build() + } + + /** + * Get content type from URI + */ + private fun getContentType(uri: String): String { + return when { + uri.contains(".mp4") -> "video/mp4" + uri.contains(".m3u8") -> "application/x-mpegURL" + uri.contains(".mpd") -> "application/dash+xml" + uri.contains(".webm") -> "video/webm" + else -> "video/mp4" // Default + } + } + + /** + * Get the current player for playback control (local or cast) + */ + fun getCurrentPlayer(): Player? { + return currentPlayer + } + + /** + * Check if Google Play Services is available + */ + private fun isGooglePlayServicesAvailable(): Boolean { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } + + /** + * Seek to position in the current player + */ + fun seekTo(positionMs: Long): Boolean { + return runCatching { + currentPlayer?.seekTo(positionMs) + true + }.getOrDefault(false) + } + + /** + * Set playback speed + */ + fun setPlaybackSpeed(speed: Float): Boolean { + return runCatching { + currentPlayer?.setPlaybackSpeed(speed) + true + }.getOrDefault(false) + } + + /** + * Set volume + */ + fun setVolume(volume: Float): Boolean { + return runCatching { + currentPlayer?.volume = volume + true + }.getOrDefault(false) + } + + /** + * Get current position + */ + fun getCurrentPosition(): Long { + return currentPlayer?.currentPosition ?: 0 + } + + /** + * Get content duration + */ + fun getDuration(): Long { + return currentPlayer?.duration ?: 0 + } + + /** + * Check if player is playing + */ + fun isPlaying(): Boolean { + return currentPlayer?.isPlaying ?: false + } + + /** + * Get buffered position + */ + fun getBufferedPosition(): Long { + return currentPlayer?.bufferedPosition ?: 0 + } + + /** + * Get buffered percentage + */ + fun getBufferedPercentage(): Int { + return currentPlayer?.bufferedPercentage ?: 0 + } + + /** + * Clear error state + */ + fun clearErrorState() { + _errorState.value = null + } + + /** + * Release resources + */ + fun release() { + runCatching { + castContext?.sessionManager?.removeSessionManagerListener( + sessionManagerListener, + CastSession::class.java + ) + castPlayer?.setSessionAvailabilityListener(null) + castPlayer?.release() + } + + castPlayer = null + currentPlayer = null + localExoPlayer = null + } +} \ No newline at end of file From dea18188cbb24bd8030e306b54bd462482951f37 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Sat, 3 May 2025 02:11:05 +0400 Subject: [PATCH 12/18] add cast provider using default key and modify classes to handle cast feature --- buildSrc/src/main/kotlin/Versions.kt | 2 +- coreapp/src/androidMain/AndroidManifest.xml | 7 +- .../videoPlayer/CastOptionsProvider.kt | 30 +++++++ .../videoPlayer/VideoPlayer.android.kt | 55 ++++++++++++- .../videoPlayer/VideoPlayerManager.kt | 80 ++++++++++++++++++- 5 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastOptionsProvider.kt diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index a49b6f53..6899aab0 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -31,7 +31,7 @@ object Versions { const val WEBVIEW = "1.9.40-alpha04" const val EXO_PLAYER = "1.3.1" const val MEDIA_ROUTER_VERSION = "1.4.0" - const val PLAY_SERVICES_CAST_VERSION = "21.3.0" + const val PLAY_SERVICES_CAST_VERSION = "22.0.0" const val GIT_LIVE = "1.10.4" const val YOUTUBE_PLAYER = "12.1.0" const val SHIMMER = "1.3.1" diff --git a/coreapp/src/androidMain/AndroidManifest.xml b/coreapp/src/androidMain/AndroidManifest.xml index fd8ffd55..bbd00363 100644 --- a/coreapp/src/androidMain/AndroidManifest.xml +++ b/coreapp/src/androidMain/AndroidManifest.xml @@ -20,8 +20,8 @@ @@ -39,6 +39,9 @@ android:resource="@xml/file_paths" /> - + + \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastOptionsProvider.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastOptionsProvider.kt new file mode 100644 index 00000000..61273c25 --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastOptionsProvider.kt @@ -0,0 +1,30 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.content.Context +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions +import com.google.android.gms.cast.framework.media.NotificationOptions + +class CastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions { + // Using the proper constant for Default Media Receiver + return CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setCastMediaOptions( + CastMediaOptions.Builder() + .setNotificationOptions( + NotificationOptions.Builder() + .build() + ) + .build() + ) + .build() + } + + override fun getAdditionalSessionProviders(context: Context): List? { + return null + } +} \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 967812a7..e7375457 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -5,14 +5,22 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -20,14 +28,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.mediarouter.app.MediaRouteButton import com.metacto.core.domain.DiQualifiers import com.metacto.core.presentation.components.visibilities.FadeVisibility import com.metacto.core.utils.extensions.OnLifecycleEvent @@ -81,6 +92,10 @@ actual fun VideoPlayer( val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } + // Cast support + val castAvailable by playerManager.castAvailable.collectAsState() + val isCasting by playerManager.isCasting.collectAsState() + eventBroadcaster.collectInCompose { enableRendering = true if (shouldResumePlayback) { @@ -183,6 +198,7 @@ actual fun VideoPlayer( } ) + // Custom Controls - Play/Pause button if (controlsType == ControlsType.CustomControls) { FadeVisibility( visible = isPlayButtonVisible && enableRendering, @@ -208,6 +224,43 @@ actual fun VideoPlayer( ) } } + + // Cast Button + if (castAvailable) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + // Add the Cast button as AndroidView + AndroidView( + factory = { ctx -> + MediaRouteButton(ctx).apply { + playerManager.setupCastButton(this) + } + }, + modifier = Modifier.size(48.dp) + ) + } + } + + // Casting Indicator + if (isCasting) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.7f)) + .padding(8.dp) + ) { + // Show a simple "Casting to device" text - you can replace with your own UI + androidx.compose.material3.Text( + text = "Casting to device", + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } + } } DisposableEffect(Unit) { @@ -307,4 +360,4 @@ private fun updatePlayerView( } else { playerManager.exoPlayer.clearVideoSurface() } -} +} \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index 634f3f12..14d25e4f 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -2,7 +2,9 @@ package com.metacto.core.presentation.components.videoPlayer import android.content.Context import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.C +import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.VideoSize @@ -14,7 +16,9 @@ import com.metacto.core.utils.extensions.createMediaSource import com.metacto.core.utils.extensions.getLauncherPendingIntent import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import androidx.core.net.toUri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow @UnstableApi internal class VideoPlayerManager( @@ -25,10 +29,20 @@ internal class VideoPlayerManager( private val playerManagers by inject>(DiQualifiers.videoPlayerManagers) private var isAutoPlay = false private var isMediaMetadataEnabled = false + private var castManager: CastManager? = null + + // Added for cast support + private val _castAvailable = MutableStateFlow(false) + val castAvailable: StateFlow = _castAvailable.asStateFlow() + + private val _isCasting = MutableStateFlow(false) + val isCasting: StateFlow = _isCasting.asStateFlow() + var onVideoLoop: (() -> Unit)? = null var onVideoEnd: (() -> Unit)? = null var isPipEnabled: Boolean = true private var videoSizeListener: ((width: Int, height: Int) -> Unit)? = null + private var myMediaItem: MediaItem? = null // Define the exo player val exoPlayer by lazy { @@ -103,6 +117,35 @@ internal class VideoPlayerManager( videoSizeListener?.invoke(videoSize.width, videoSize.height) } }) + + // Initialize cast manager + initCastManager() + } + + private fun initCastManager() { + try { + castManager = CastManager(context).apply { + // Set the local player for transfer between devices + setLocalPlayer(exoPlayer) + + // Observe cast availability + observeCastStates(this) + } + } catch (e: Exception) { + // Cast not available or failed to initialize + e.printStackTrace() + } + } + + private fun observeCastStates(castManager: CastManager) { + // Collect cast state flows and update our own states + try { + // This is simplified - in a real app, you'd use lifecycleScope or similar + _castAvailable.value = castManager.castAvailable.value + _isCasting.value = castManager.isCasting.value + } catch (e: Exception) { + e.printStackTrace() + } } @OptIn(UnstableApi::class) @@ -160,6 +203,13 @@ internal class VideoPlayerManager( metaData = mediaMetaData ) + // Store the media item for casting + myMediaItem = MediaItem.Builder() + .setUri(videoUrl.toUri()) + .setMediaId(videoUrl) + .setMediaMetadata(mediaMetaData) + .build() + // Set media source and prepare if (isAutoPlay) { playWhenReady = true @@ -170,18 +220,42 @@ internal class VideoPlayerManager( } fun play() { - if (exoPlayer.isPlaying.not()) { + if (_isCasting.value) { + castManager?.getCurrentPlayer()?.play() + } else if (exoPlayer.isPlaying.not()) { exoPlayer.play() } } fun pause() { - if (exoPlayer.isPlaying) { + if (_isCasting.value) { + castManager?.getCurrentPlayer()?.pause() + } else if (exoPlayer.isPlaying) { exoPlayer.pause() } } + fun startCasting() { + myMediaItem?.let { + castManager?.startCasting(it) + } + } + + fun stopCasting() { + castManager?.stopCasting() + } + + fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton) { + castManager?.setupCastButton(mediaRouteButton) + } + fun setMediaMetadataEnabled(isEnabled: Boolean) { isMediaMetadataEnabled = isEnabled } + + fun release() { + castManager?.release() + castManager = null + exoPlayer.release() + } } \ No newline at end of file From e1be955fcb651c560fe795132e1bdfa4343bb9fe Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Tue, 6 May 2025 02:15:48 +0400 Subject: [PATCH 13/18] Change the cast icon and give it padding and enhance the implementation of cast manager --- .../components/videoPlayer/CastManager.kt | 371 +++--------------- .../videoPlayer/VideoPlayer.android.kt | 50 +-- .../videoPlayer/VideoPlayerManager.kt | 47 +-- .../src/androidMain/res/values/strings.xml | 1 + 4 files changed, 101 insertions(+), 368 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt index 8404e86c..5b29d58b 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt @@ -1,7 +1,6 @@ package com.metacto.core.presentation.components.videoPlayer import android.content.Context -import android.content.Intent import androidx.annotation.OptIn import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener @@ -9,8 +8,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer -import com.google.android.gms.cast.MediaInfo -import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastSession @@ -18,16 +15,11 @@ import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.Executors -/** - * Manages cast functionality for video playback - */ @OptIn(UnstableApi::class) class CastManager(private val context: Context) { @@ -37,7 +29,6 @@ class CastManager(private val context: Context) { private var localExoPlayer: ExoPlayer? = null private var currentMediaItem: MediaItem? = null - private var currentUri: String? = null private var playbackPosition: Long = 0 private var wasPlaying: Boolean = false @@ -47,13 +38,6 @@ class CastManager(private val context: Context) { private val _isCasting = MutableStateFlow(false) val isCasting: StateFlow = _isCasting.asStateFlow() - private val _errorState = MutableStateFlow(null) - val errorState: StateFlow = _errorState.asStateFlow() - - private val _isInitialized = MutableStateFlow(false) - val isInitialized: StateFlow = _isInitialized.asStateFlow() - - private val coroutineScope = CoroutineScope(Dispatchers.Main) private val sessionManagerListener = object : SessionManagerListener { override fun onSessionStarting(session: CastSession) { @@ -61,12 +45,11 @@ class CastManager(private val context: Context) { } override fun onSessionStarted(session: CastSession, sessionId: String) { - onCastSessionStarted(session) + onCastSessionStarted() } override fun onSessionStartFailed(session: CastSession, error: Int) { _isCasting.value = false - _errorState.value = "Cast session failed to start" } override fun onSessionEnding(session: CastSession) { @@ -82,12 +65,11 @@ class CastManager(private val context: Context) { } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { - onCastSessionStarted(session) + onCastSessionStarted() } override fun onSessionResumeFailed(session: CastSession, error: Int) { _isCasting.value = false - _errorState.value = "Cast session failed to resume" } override fun onSessionSuspended(session: CastSession, reason: Int) { @@ -96,166 +78,97 @@ class CastManager(private val context: Context) { } init { - initializeCast() - } - - private fun initializeCast() { - if (!isGooglePlayServicesAvailable()) { + if (isGooglePlayServicesAvailable()) { + initializeCast() + } else { _castAvailable.value = false - _isCasting.value = false - _errorState.value = "Google Play Services not available" - _isInitialized.value = false - return } + } - // Use the non-deprecated method that returns a Task + private fun initializeCast() { val executor = Executors.newSingleThreadExecutor() - val castContextTask = CastContext.getSharedInstance(context, executor) + CastContext.getSharedInstance(context, executor) + .addOnSuccessListener { ctx -> + castContext = ctx - castContextTask.addOnSuccessListener { castContext -> - // Handle successful CastContext initialization - this.castContext = castContext - - // Add listener for cast state changes - castContext.addCastStateListener { state -> - _castAvailable.value = state != CastState.NO_DEVICES_AVAILABLE - } + // Add listener for cast state changes + ctx.addCastStateListener { state -> + _castAvailable.value = state != CastState.NO_DEVICES_AVAILABLE + } - // Create cast player - val player = CastPlayer(castContext) - castPlayer = player + // Create cast player + castPlayer = CastPlayer(ctx).apply { + setSessionAvailabilityListener(object : SessionAvailabilityListener { + override fun onCastSessionAvailable() { + _isCasting.value = true + } + + override fun onCastSessionUnavailable() { + _isCasting.value = false + } + }) + } - // Add cast session manager listener - castContext.sessionManager.addSessionManagerListener( - sessionManagerListener, - CastSession::class.java - ) + // Add cast session manager listener + ctx.sessionManager.addSessionManagerListener( + sessionManagerListener, + CastSession::class.java + ) - // Check initial cast state - _castAvailable.value = castContext.castState != CastState.NO_DEVICES_AVAILABLE + // Check initial cast state + _castAvailable.value = ctx.castState != CastState.NO_DEVICES_AVAILABLE - // Check if already casting - val castSession = castContext.sessionManager.currentCastSession - if (castSession != null && castSession.isConnected) { - onCastSessionStarted(castSession) + // Check if already casting + val castSession = ctx.sessionManager.currentCastSession + if (castSession != null && castSession.isConnected) { + onCastSessionStarted() + } + } + .addOnFailureListener { + _castAvailable.value = false + _isCasting.value = false } - - _errorState.value = null - _isInitialized.value = true - } - - castContextTask.addOnFailureListener { exception -> - _castAvailable.value = false - _isCasting.value = false - _errorState.value = "Failed to initialize cast: ${exception.message}" - _isInitialized.value = false - } } - /** - * Set up a cast button that can be inflated into the layout - */ - fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton): Boolean { - return runCatching { - castContext?.let { - CastButtonFactory.setUpMediaRouteButton(context, mediaRouteButton) - true - } ?: false - }.getOrDefault(false) + fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton) { + castContext?.let { + CastButtonFactory.setUpMediaRouteButton(context, mediaRouteButton) + } } - /** - * Set the local ExoPlayer instance for playback transfer - */ fun setLocalPlayer(exoPlayer: ExoPlayer) { localExoPlayer = exoPlayer - // If we're not casting, set the current player to the local player if (!_isCasting.value) { currentPlayer = exoPlayer } } - /** - * Start casting the current media - */ - fun startCasting(mediaItem: MediaItem): Boolean { - if (castContext == null) { - _errorState.value = "Cast not initialized" - return false - } - - currentMediaItem = mediaItem - currentUri = mediaItem.mediaId - - // Save the current playback position and state - localExoPlayer?.let { exoPlayer -> - playbackPosition = exoPlayer.currentPosition - wasPlaying = exoPlayer.isPlaying - } - - // Create a launch intent for the Cast session - val sessionIntent = Intent(context, context.javaClass) - - return runCatching { - castContext?.sessionManager?.startSession(sessionIntent) - true - }.getOrDefault(false) - } - - /** - * Stop casting and return to local playback - */ - fun stopCasting(): Boolean { - return runCatching { - castContext?.sessionManager?.endCurrentSession(true) - true - }.getOrDefault(false) - } - - /** - * Handle cast session started - */ - private fun onCastSessionStarted(castSession: CastSession) { + private fun onCastSessionStarted() { // Save the local player state localExoPlayer?.let { exoPlayer -> playbackPosition = exoPlayer.currentPosition wasPlaying = exoPlayer.isPlaying - if (currentMediaItem == null) { - currentMediaItem = exoPlayer.currentMediaItem - } - if (currentUri == null && currentMediaItem != null) { - currentUri = currentMediaItem?.mediaId - } + currentMediaItem = currentMediaItem ?: exoPlayer.currentMediaItem // Pause local playback exoPlayer.pause() } - // Set up the cast player - castPlayer?.setSessionAvailabilityListener(object : SessionAvailabilityListener { - override fun onCastSessionAvailable() { - transferPlaybackToCast(castSession) - } - - override fun onCastSessionUnavailable() { - _isCasting.value = false - } - }) - // Set current player to cast player currentPlayer = castPlayer _isCasting.value = true // Transfer playback if media item is available - if (currentMediaItem != null || currentUri != null) { - transferPlaybackToCast(castSession) + currentMediaItem?.let { mediaItem -> + castPlayer?.setMediaItem(mediaItem, playbackPosition) + castPlayer?.prepare() + if (wasPlaying) { + castPlayer?.play() + } } } - /** - * Handle cast session ended - */ private fun onCastSessionEnded() { // Set current player back to local player currentPlayer = localExoPlayer @@ -264,184 +177,30 @@ class CastManager(private val context: Context) { // Resume local playback localExoPlayer?.let { exoPlayer -> currentMediaItem?.let { mediaItem -> - runCatching { - exoPlayer.setMediaItem(mediaItem, playbackPosition) - exoPlayer.prepare() - if (wasPlaying) { - exoPlayer.play() - } + exoPlayer.setMediaItem(mediaItem, playbackPosition) + exoPlayer.prepare() + if (wasPlaying) { + exoPlayer.play() } } } } - /** - * Transfer playback to cast device - * Returns true if transfer was successful - */ - private fun transferPlaybackToCast(castSession: CastSession): Boolean { - val remoteMediaClient = castSession.remoteMediaClient ?: return false - - // Try to load via Media3 CastPlayer first - val castTransferResult = runCatching { - castPlayer?.let { player -> - currentMediaItem?.let { mediaItem -> - player.setMediaItem(mediaItem, playbackPosition) - player.prepare() - if (wasPlaying) { - player.play() - } - return true - } - } - false - }.getOrDefault(false) - - if (castTransferResult) { - return true - } - - // Fallback to legacy Cast API if Media3 approach fails - return runCatching { - currentUri?.let { uri -> - val mediaInfo = createMediaInfo(uri) - val loadRequestData = MediaLoadRequestData.Builder() - .setMediaInfo(mediaInfo) - .setAutoplay(wasPlaying) - .setCurrentTime(playbackPosition) - .build() - - remoteMediaClient.load(loadRequestData) - true - } ?: false - }.getOrDefault(false) - } - - /** - * Create MediaInfo for legacy Cast API - */ - private fun createMediaInfo(uri: String): MediaInfo { - val contentType = getContentType(uri) - - return MediaInfo.Builder(uri) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setContentType(contentType) - .build() - } - - /** - * Get content type from URI - */ - private fun getContentType(uri: String): String { - return when { - uri.contains(".mp4") -> "video/mp4" - uri.contains(".m3u8") -> "application/x-mpegURL" - uri.contains(".mpd") -> "application/dash+xml" - uri.contains(".webm") -> "video/webm" - else -> "video/mp4" // Default - } - } - - /** - * Get the current player for playback control (local or cast) - */ - fun getCurrentPlayer(): Player? { - return currentPlayer - } + fun getCurrentPlayer(): Player? = currentPlayer - /** - * Check if Google Play Services is available - */ private fun isGooglePlayServicesAvailable(): Boolean { val googleApiAvailability = GoogleApiAvailability.getInstance() val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) return resultCode == ConnectionResult.SUCCESS } - /** - * Seek to position in the current player - */ - fun seekTo(positionMs: Long): Boolean { - return runCatching { - currentPlayer?.seekTo(positionMs) - true - }.getOrDefault(false) - } - - /** - * Set playback speed - */ - fun setPlaybackSpeed(speed: Float): Boolean { - return runCatching { - currentPlayer?.setPlaybackSpeed(speed) - true - }.getOrDefault(false) - } - - /** - * Set volume - */ - fun setVolume(volume: Float): Boolean { - return runCatching { - currentPlayer?.volume = volume - true - }.getOrDefault(false) - } - - /** - * Get current position - */ - fun getCurrentPosition(): Long { - return currentPlayer?.currentPosition ?: 0 - } - - /** - * Get content duration - */ - fun getDuration(): Long { - return currentPlayer?.duration ?: 0 - } - - /** - * Check if player is playing - */ - fun isPlaying(): Boolean { - return currentPlayer?.isPlaying ?: false - } - - /** - * Get buffered position - */ - fun getBufferedPosition(): Long { - return currentPlayer?.bufferedPosition ?: 0 - } - - /** - * Get buffered percentage - */ - fun getBufferedPercentage(): Int { - return currentPlayer?.bufferedPercentage ?: 0 - } - - /** - * Clear error state - */ - fun clearErrorState() { - _errorState.value = null - } - - /** - * Release resources - */ fun release() { - runCatching { - castContext?.sessionManager?.removeSessionManagerListener( - sessionManagerListener, - CastSession::class.java - ) - castPlayer?.setSessionAvailabilityListener(null) - castPlayer?.release() - } + castContext?.sessionManager?.removeSessionManagerListener( + sessionManagerListener, + CastSession::class.java + ) + castPlayer?.setSessionAvailabilityListener(null) + castPlayer?.release() castPlayer = null currentPlayer = null diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index e7375457..746474aa 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -6,16 +6,17 @@ import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -29,7 +30,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -43,6 +46,7 @@ import com.metacto.core.domain.DiQualifiers import com.metacto.core.presentation.components.visibilities.FadeVisibility import com.metacto.core.utils.extensions.OnLifecycleEvent import com.metacto.core.utils.extensions.noRippleClickable +import com.metacto.coreApp.R import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject @@ -82,7 +86,8 @@ actual fun VideoPlayer( ) { val context = LocalContext.current val eventBroadcaster = koinInject() - val playerManagers = koinInject>(DiQualifiers.videoPlayerManagers) + val playerManagers = + koinInject>(DiQualifiers.videoPlayerManagers) val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } @@ -93,7 +98,6 @@ actual fun VideoPlayer( val isPlayButtonVisible by remember { mutableStateOf(true) } // Cast support - val castAvailable by playerManager.castAvailable.collectAsState() val isCasting by playerManager.isCasting.collectAsState() eventBroadcaster.collectInCompose { @@ -226,22 +230,22 @@ actual fun VideoPlayer( } // Cast Button - if (castAvailable) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - ) { - // Add the Cast button as AndroidView - AndroidView( - factory = { ctx -> - MediaRouteButton(ctx).apply { - playerManager.setupCastButton(this) - } - }, - modifier = Modifier.size(48.dp) - ) - } + Box( + modifier = Modifier + .padding(top = 8.dp) + .padding(end = 8.dp) + .align(Alignment.TopEnd) + .background(color = Color.LightGray, shape = CircleShape) + ) { + AndroidView( + factory = { context -> + MediaRouteButton(context).apply { + // Setup the cast button - this connects it to the CastContext + playerManager.setupCastButton(this) + } + }, + modifier = Modifier.size(48.dp) + ) } // Casting Indicator @@ -253,9 +257,9 @@ actual fun VideoPlayer( .background(Color.Black.copy(alpha = 0.7f)) .padding(8.dp) ) { - // Show a simple "Casting to device" text - you can replace with your own UI - androidx.compose.material3.Text( - text = "Casting to device", + // "Casting to device" text + Text( + text = stringResource(R.string.casting_to_device), color = Color.White, modifier = Modifier.align(Alignment.Center) ) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index 14d25e4f..a7774c64 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -11,6 +11,7 @@ import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession +import androidx.mediarouter.app.MediaRouteButton import com.metacto.core.domain.DiQualifiers import com.metacto.core.utils.extensions.createMediaSource import com.metacto.core.utils.extensions.getLauncherPendingIntent @@ -123,28 +124,12 @@ internal class VideoPlayerManager( } private fun initCastManager() { - try { - castManager = CastManager(context).apply { - // Set the local player for transfer between devices - setLocalPlayer(exoPlayer) - - // Observe cast availability - observeCastStates(this) - } - } catch (e: Exception) { - // Cast not available or failed to initialize - e.printStackTrace() - } - } - - private fun observeCastStates(castManager: CastManager) { - // Collect cast state flows and update our own states - try { - // This is simplified - in a real app, you'd use lifecycleScope or similar - _castAvailable.value = castManager.castAvailable.value - _isCasting.value = castManager.isCasting.value - } catch (e: Exception) { - e.printStackTrace() + castManager = CastManager(context).apply { + // Set the local player + setLocalPlayer(exoPlayer) + // Observe cast states + _castAvailable.value = castAvailable.value + _isCasting.value = isCasting.value } } @@ -235,27 +220,11 @@ internal class VideoPlayerManager( } } - fun startCasting() { - myMediaItem?.let { - castManager?.startCasting(it) - } - } - - fun stopCasting() { - castManager?.stopCasting() - } - - fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton) { + fun setupCastButton(mediaRouteButton: MediaRouteButton) { castManager?.setupCastButton(mediaRouteButton) } fun setMediaMetadataEnabled(isEnabled: Boolean) { isMediaMetadataEnabled = isEnabled } - - fun release() { - castManager?.release() - castManager = null - exoPlayer.release() - } } \ No newline at end of file diff --git a/coreapp/src/androidMain/res/values/strings.xml b/coreapp/src/androidMain/res/values/strings.xml index a7f784fe..f10114e9 100644 --- a/coreapp/src/androidMain/res/values/strings.xml +++ b/coreapp/src/androidMain/res/values/strings.xml @@ -1,4 +1,5 @@ Media Channel Media Channel + Casting to device \ No newline at end of file From bd3768f997b7a54f781a6a9a1377d3ef48f5dbe1 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Tue, 6 May 2025 02:16:45 +0400 Subject: [PATCH 14/18] Change the cast icon and give it padding and enhance the implementation of cast manager --- .../components/videoPlayer/CastManager.kt | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt index 5b29d58b..5613a268 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt @@ -193,17 +193,4 @@ class CastManager(private val context: Context) { val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) return resultCode == ConnectionResult.SUCCESS } - - fun release() { - castContext?.sessionManager?.removeSessionManagerListener( - sessionManagerListener, - CastSession::class.java - ) - castPlayer?.setSessionAvailabilityListener(null) - castPlayer?.release() - - castPlayer = null - currentPlayer = null - localExoPlayer = null - } } \ No newline at end of file From 326e9b8381c62fb50a0dc9ab15592edfa5c903b0 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Wed, 7 May 2025 22:56:33 +0400 Subject: [PATCH 15/18] Implement subtitle and handle it's status between normal , pip and full screen --- .../videoPlayer/SubtitleFileLoader.kt | 110 ++++++++++++ .../videoPlayer/VideoPlayer.android.kt | 96 ++++++++-- .../videoPlayer/VideoPlayerActivity.kt | 84 ++++++++- .../videoPlayer/VideoPlayerManager.kt | 168 ++++++++++++++---- .../res/drawable/circle_background.xml | 5 + .../res/drawable/ic_closed_caption.xml | 10 ++ .../src/androidMain/res/drawable/ic_pip.xml | 16 +- .../res/layout/activity_video_player.xml | 69 +++++-- .../src/androidMain/res/values/strings.xml | 6 + .../src/androidMain/res/xml/file_paths.xml | 1 + .../app/presentation/models/VideoItemInfo.kt | 2 +- 11 files changed, 491 insertions(+), 76 deletions(-) create mode 100644 coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/SubtitleFileLoader.kt create mode 100644 coreapp/src/androidMain/res/drawable/circle_background.xml create mode 100644 coreapp/src/androidMain/res/drawable/ic_closed_caption.xml diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/SubtitleFileLoader.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/SubtitleFileLoader.kt new file mode 100644 index 00000000..ae3994b1 --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/SubtitleFileLoader.kt @@ -0,0 +1,110 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader + +class SubtitleFileLoader(private val context: Context) { + + suspend fun loadSubtitleFile(uri: Uri): Triple? = withContext(Dispatchers.IO) { + val contentResolver = context.contentResolver + + val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex("_display_name") + cursor.moveToFirst() + cursor.getString(nameIndex) + } ?: "subtitle.vtt" + + val languageCode = detectLanguageFromFileName(fileName) + + val stringBuilder = StringBuilder() + contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream, "UTF-8")).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + stringBuilder.append(line) + stringBuilder.append('\n') + } + } + } + + val content = stringBuilder.toString() + if (content.isBlank()) { + return@withContext null + } + + return@withContext Triple(languageCode, fileName, content) + } + + private fun detectLanguageFromFileName(fileName: String): String { + val patterns = listOf( + Regex(".*\\.([a-z]{2})\\.(?:vtt|srt)$"), + Regex(".*_([a-z]{2})\\.(?:vtt|srt)$"), + Regex(".*-([a-z]{2})\\.(?:vtt|srt)$") + ) + + for (pattern in patterns) { + val match = pattern.find(fileName) + if (match != null && match.groupValues.size > 1) { + return match.groupValues[1] + } + } + + return "und" + } + + fun getMimeTypeFromFileName(fileName: String): String { + return when { + fileName.endsWith(".vtt", ignoreCase = true) -> "text/vtt" + fileName.endsWith(".srt", ignoreCase = true) -> "application/x-subrip" + fileName.endsWith(".ttml", ignoreCase = true) -> "application/ttml+xml" + fileName.endsWith(".ssa", ignoreCase = true) -> "text/x-ssa" + fileName.endsWith(".ass", ignoreCase = true) -> "text/x-ssa" + else -> "text/vtt" + } + } +} + +@Composable +fun rememberSubtitleFilePicker( + onSubtitleFileSelected: (language: String, fileName: String, content: String) -> Unit +): () -> Unit { + val context = androidx.compose.ui.platform.LocalContext.current + val coroutineScope = rememberCoroutineScope() + val subtitleLoader = SubtitleFileLoader(context) + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + + coroutineScope.launch { + val result = subtitleLoader.loadSubtitleFile(uri) + if (result != null) { + val (language, fileName, content) = result + onSubtitleFileSelected(language, fileName, content) + } + } + } + } + + return { + launcher.launch(arrayOf( + "text/vtt", + "application/x-subrip", + "text/plain", + "text/x-ssa" + )) + } +} \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 746474aa..a7bb681b 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -1,6 +1,8 @@ package com.metacto.core.presentation.components.videoPlayer +import android.util.TypedValue import android.view.SurfaceView +import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.annotation.OptIn @@ -8,14 +10,18 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -30,7 +36,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -82,7 +87,7 @@ actual fun VideoPlayer( onPlayerCreated: ((VideoPlayerController) -> Unit)?, onDurationCaught: ((Duration) -> Unit)?, onVideoLoop: (() -> Unit)?, - onVideoEnd: (() -> Unit)? + onVideoEnd: (() -> Unit)?, ) { val context = LocalContext.current val eventBroadcaster = koinInject() @@ -96,10 +101,16 @@ actual fun VideoPlayer( var shouldResumePlayback by remember { mutableStateOf(false) } val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } + val playerViewRef = remember { mutableStateOf(null) } // Cast support val isCasting by playerManager.isCasting.collectAsState() + // Subtitle file loading + val subtitleFilePicker = rememberSubtitleFilePicker { language, fileName, content -> + playerManager.addExternalSubtitle(language, fileName, content) + } + eventBroadcaster.collectInCompose { enableRendering = true if (shouldResumePlayback) { @@ -107,16 +118,25 @@ actual fun VideoPlayer( shouldResumePlayback = false } isPlaying.value = playerManager.exoPlayer.isPlaying + + // Ensure subtitles are visible when activity finishes + playerViewRef.value?.subtitleView?.visibility = View.VISIBLE } eventBroadcaster.collectInCompose { enableRendering = false shouldResumePlayback = playerManager.exoPlayer.isPlaying + + // Hide subtitles when entering PIP mode + playerViewRef.value?.subtitleView?.visibility = View.INVISIBLE } eventBroadcaster.collectInCompose { enableRendering = false shouldResumePlayback = playerManager.exoPlayer.isPlaying + + // Show subtitles when exiting PIP mode + playerViewRef.value?.subtitleView?.visibility = View.VISIBLE } LaunchedEffect(enablePip) { @@ -190,9 +210,12 @@ actual fun VideoPlayer( else AspectRatioFrameLayout.RESIZE_MODE_FIT, enableRendering = enableRendering, onFullscreenClick = { onFullScreen(uniqueId) } - ) + ).also { + playerViewRef.value = it + } }, update = { playerView -> + playerViewRef.value = playerView updatePlayerView( playerView = playerView, playerManager = playerManager, @@ -229,23 +252,51 @@ actual fun VideoPlayer( } } - // Cast Button - Box( + // Top control bar with subtitle and cast buttons + FadeVisibility( + visible = isPlayButtonVisible && enableRendering, + duration = CONTROLS_ANIM_DURATION, modifier = Modifier - .padding(top = 8.dp) - .padding(end = 8.dp) .align(Alignment.TopEnd) - .background(color = Color.LightGray, shape = CircleShape) + .padding(top = 8.dp, end = 8.dp) ) { - AndroidView( - factory = { context -> - MediaRouteButton(context).apply { - // Setup the cast button - this connects it to the CastContext - playerManager.setupCastButton(this) - } - }, - modifier = Modifier.size(48.dp) - ) + Row { + // Subtitle button - directly opens file picker + Box( + modifier = Modifier + .size(36.dp) + .background(color = Color.LightGray, shape = CircleShape) + .clickable { subtitleFilePicker() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ClosedCaption, + contentDescription = "Load Subtitle File", + tint = Color.Black, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(36.dp) + .background(color = Color.LightGray, shape = CircleShape) + .clickable { subtitleFilePicker() }, + contentAlignment = Alignment.Center + ) { + // Cast button + AndroidView( + factory = { context -> + MediaRouteButton(context).apply { + playerManager.setupCastButton(this) + } + }, + modifier = Modifier.size(24.dp) + ) + } + } } // Casting Indicator @@ -257,7 +308,6 @@ actual fun VideoPlayer( .background(Color.Black.copy(alpha = 0.7f)) .padding(8.dp) ) { - // "Casting to device" text Text( text = stringResource(R.string.casting_to_device), color = Color.White, @@ -325,6 +375,11 @@ private fun createPlayerView( player = playerManager.exoPlayer layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + subtitleView?.setPaddingRelative(0, 0, 0, 20) + subtitleView?.setApplyEmbeddedStyles(true) + subtitleView?.setCues(null) + setFullscreenButtonClickListener { onFullscreenClick() } @@ -351,6 +406,9 @@ private fun updatePlayerView( onFullscreenClick() } + playerView.subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + playerView.subtitleView?.setPaddingRelative(0, 0, 0, 20) + if (enableRendering) { if (playerView.player != playerManager.exoPlayer) { playerView.player = playerManager.exoPlayer diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 7cab4f50..5c9f4f82 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -14,19 +14,24 @@ import android.util.Rational import android.view.MotionEvent import android.view.SurfaceView import android.view.View +import android.widget.FrameLayout import android.widget.ImageButton +import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.metacto.core.domain.DiQualifiers import com.metacto.coreApp.R +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject @OptIn(UnstableApi::class) @@ -49,6 +54,35 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } } + // Subtitle picker launcher + private val subtitlePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val uri = result.data?.data ?: return@registerForActivityResult + + // Take persistent permission + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + // Use SubtitleFileLoader to load the file + lifecycleScope.launch { + val subtitleLoader = SubtitleFileLoader(this@VideoPlayerActivity) + val result = subtitleLoader.loadSubtitleFile(uri) + + if (result != null) { + val (language, fileName, content) = result + + // Get the player manager and add the subtitle + val playerManagers by inject>(DiQualifiers.videoPlayerManagers) + playerManagers[playerId]?.addExternalSubtitle(language, fileName, content) + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) hideSystemBars() @@ -68,6 +102,28 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } registerReceiver(pipActionsReceiver, IntentFilter(ACTION_MEDIA_CONTROL), RECEIVER_EXPORTED) handleBackPress() + setupSubtitleButton() + } + + private fun setupSubtitleButton() { + val subtitleButton = findViewById(R.id.ib_subtitle) + subtitleButton.setOnClickListener { + openSubtitleFilePicker() + } + } + + private fun openSubtitleFilePicker() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf( + "text/vtt", + "application/x-subrip", + "text/plain", + "text/x-ssa" + )) + } + subtitlePickerLauncher.launch(intent) } private fun handleBackPress() { @@ -102,6 +158,11 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { enablePip() } ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE + + // Set up Cast Button + val castButton = findViewById(R.id.cast_button) + val playerManagers by inject>(DiQualifiers.videoPlayerManagers) + playerId?.let { playerManagers[it]?.setupCastButton(castButton) } } private fun enablePip() { @@ -159,11 +220,15 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { exoPlayer?.setVideoSurfaceView(it) } - playerManager.setVideoSizeListener { _, _ -> + playerManager.setVideoSizeListener { width, height -> if (isInPictureInPictureMode) { updatePipParams() } } + + // Set up Cast Button + val castButton = findViewById(R.id.cast_button) + playerManager.setupCastButton(castButton) } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -178,20 +243,31 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - val ibPip = findViewById(R.id.ib_pip) + val pipContainer = findViewById(R.id.pip_container) + val topControlsContainer = findViewById(R.id.top_controls_container) if (isInPictureInPictureMode) { hasUserInteracted = false playerView?.useController = false playerView?.hideController() // Hide the PiP button when in PiP mode - ibPip.visibility = View.GONE + pipContainer.visibility = View.GONE + topControlsContainer.visibility = View.GONE + + // Hide subtitles in PIP mode + playerView?.subtitleView?.visibility = View.INVISIBLE + eventBroadcaster.emit(VideoPlayerEvent.StartedPip) } else { playerView?.useController = true showSystemBars() // Show the PiP button again when exiting PiP mode - ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE + pipContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE + topControlsContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE + + // Show subtitles again when exiting PIP mode + playerView?.subtitleView?.visibility = View.VISIBLE + if (playerView?.player == null) { playerView?.player = exoPlayer } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index a7774c64..36e02a46 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -10,6 +10,7 @@ import androidx.media3.common.Player import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.session.MediaSession import androidx.mediarouter.app.MediaRouteButton import com.metacto.core.domain.DiQualifiers @@ -32,7 +33,12 @@ internal class VideoPlayerManager( private var isMediaMetadataEnabled = false private var castManager: CastManager? = null - // Added for cast support + private val trackSelector = DefaultTrackSelector(context).apply { + parameters = DefaultTrackSelector.Parameters.Builder(context!!) + .setPreferredTextLanguage("en") + .build() + } + private val _castAvailable = MutableStateFlow(false) val castAvailable: StateFlow = _castAvailable.asStateFlow() @@ -43,17 +49,15 @@ internal class VideoPlayerManager( var onVideoEnd: (() -> Unit)? = null var isPipEnabled: Boolean = true private var videoSizeListener: ((width: Int, height: Int) -> Unit)? = null - private var myMediaItem: MediaItem? = null - // Define the exo player val exoPlayer by lazy { ExoPlayer.Builder(context) + .setTrackSelector(trackSelector) .setSeekBackIncrementMs(10_000L) .setSeekForwardIncrementMs(10_000L) .build() } - // Define the media session private val mediaSession by lazy { MediaSession.Builder(context, exoPlayer).run { this.setId(uniqueId) @@ -64,7 +68,6 @@ internal class VideoPlayerManager( } } - // Define the notification manager private val notificationManager by lazy { MediaNotificationManager( context = context, @@ -74,26 +77,17 @@ internal class VideoPlayerManager( } init { - // Add play listener to exo player exoPlayer.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { - // Skip if the feature is not enabled if (isMediaMetadataEnabled.not()) return - - // Skip if not playing if (isPlaying.not()) return - // Pause other players playerManagers.values.forEach { - // Skip if it's current player if (it.uniqueId == uniqueId) return@forEach - - // Pause the player and hide the notification it.exoPlayer.pause() it.notificationManager.hideNotification() } - // Show the notification for current exo player notificationManager.showNotificationForPlayer(exoPlayer) } @@ -103,7 +97,6 @@ internal class VideoPlayerManager( reason: Int ) { if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - // Video has looped onVideoLoop?.invoke() } } @@ -117,17 +110,18 @@ internal class VideoPlayerManager( override fun onVideoSizeChanged(videoSize: VideoSize) { videoSizeListener?.invoke(videoSize.width, videoSize.height) } + + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + updateSubtitleTracks() + } }) - // Initialize cast manager initCastManager() } private fun initCastManager() { castManager = CastManager(context).apply { - // Set the local player setLocalPlayer(exoPlayer) - // Observe cast states _castAvailable.value = castAvailable.value _isCasting.value = isCasting.value } @@ -167,13 +161,11 @@ internal class VideoPlayerManager( videoArtist: String?, videoArtworkUrl: String? ) { - // If the player is already playing the same video, skip if (exoPlayer.currentMediaItem?.mediaId == videoUrl) { return } exoPlayer.apply { - // Create the metadata val mediaMetaData = MediaMetadata.Builder() .setTitle(videoTitle.orEmpty()) .setArtist(videoArtist.orEmpty()) @@ -181,29 +173,135 @@ internal class VideoPlayerManager( .setArtworkUri(videoArtworkUrl?.toUri()) .build() - // Create the media item - val mediaItem = createMediaSource( + val mediaSource = createMediaSource( context = context, url = videoUrl, metaData = mediaMetaData ) - // Store the media item for casting - myMediaItem = MediaItem.Builder() - .setUri(videoUrl.toUri()) - .setMediaId(videoUrl) - .setMediaMetadata(mediaMetaData) - .build() - - // Set media source and prepare if (isAutoPlay) { playWhenReady = true } - setMediaSource(mediaItem) + setMediaSource(mediaSource) prepare() } } + fun addExternalSubtitle(language: String, fileName: String, content: String) { + val uniqueFileName = "${System.currentTimeMillis()}_$fileName" + + val file = java.io.File(context.cacheDir, uniqueFileName) + file.writeText(content, Charsets.UTF_8) + + if (!file.exists() || file.length() == 0.toLong()) { + return + } + + val authority = "${context.packageName}.fileprovider" + + val subtitleLoader = SubtitleFileLoader(context) + val mimeType = subtitleLoader.getMimeTypeFromFileName(fileName) + + val subtitleUri = androidx.core.content.FileProvider.getUriForFile( + context, + authority, + file + ) + + context.grantUriPermission( + context.packageName, + subtitleUri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val currentItem = exoPlayer.currentMediaItem ?: return + val mediaMetadata = currentItem.mediaMetadata + + val newSubtitleConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri) + .setMimeType(mimeType) + .setLanguage(language) + .setLabel(fileName) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build() + + val currentConfigs = ArrayList(currentItem.localConfiguration?.subtitleConfigurations ?: emptyList()) + currentConfigs.add(newSubtitleConfig) + + val updatedItem = MediaItem.Builder() + .setUri(currentItem.localConfiguration?.uri) + .setMediaId(currentItem.mediaId) + .setMediaMetadata(mediaMetadata) + .setSubtitleConfigurations(currentConfigs) + .build() + + val currentPosition = exoPlayer.currentPosition + exoPlayer.setMediaItem(updatedItem, currentPosition) + exoPlayer.prepare() + + updateSubtitleTracks() + val parameters = trackSelector.parameters.buildUpon() + .setPreferredTextLanguage(language) + .setRendererDisabled(C.TRACK_TYPE_TEXT, false) + .setSelectUndeterminedTextLanguage(true) + .build() + + trackSelector.setParameters(parameters) + } + + private fun updateSubtitleTracks() { + val tracks = exoPlayer.currentTracks + val subtitleTracks = mutableListOf() + + subtitleTracks.add(SubtitleTrack("none", "None", false)) + + var hasSelectedTrack = false + + for (group in tracks.groups) { + if (group.type == C.TRACK_TYPE_TEXT) { + for (i in 0 until group.length) { + val format = group.getTrackFormat(i) + val language = format.language ?: "unknown" + val label = format.label ?: language.uppercase() + val isSelected = group.isTrackSelected(i) + + val track = SubtitleTrack(language, label, isSelected) + subtitleTracks.add(track) + + if (isSelected) { + hasSelectedTrack = true + } + } + } + } + + if (subtitleTracks.size > 1 && !hasSelectedTrack) { + val firstTrack = subtitleTracks.firstOrNull { it.languageCode != "none" } + if (firstTrack != null) { + selectSubtitleTrack(firstTrack) + } + } + } + + private fun selectSubtitleTrack(track: SubtitleTrack?) { + val parameters = if (track == null || track.languageCode == "none") { + trackSelector.parameters.buildUpon() + .setPreferredTextLanguage(null) + .setRendererDisabled(C.TRACK_TYPE_TEXT, true) + .build() + } else { + trackSelector.parameters.buildUpon() + .setPreferredTextLanguage(track.languageCode) + .setRendererDisabled(C.TRACK_TYPE_TEXT, false) + .setSelectUndeterminedTextLanguage(true) + .build() + } + + trackSelector.setParameters(parameters) + + val currentPos = exoPlayer.currentPosition + exoPlayer.seekTo(currentPos) + } + fun play() { if (_isCasting.value) { castManager?.getCurrentPlayer()?.play() @@ -227,4 +325,10 @@ internal class VideoPlayerManager( fun setMediaMetadataEnabled(isEnabled: Boolean) { isMediaMetadataEnabled = isEnabled } -} \ No newline at end of file +} + +data class SubtitleTrack( + val languageCode: String, + val displayName: String, + val isSelected: Boolean +) \ No newline at end of file diff --git a/coreapp/src/androidMain/res/drawable/circle_background.xml b/coreapp/src/androidMain/res/drawable/circle_background.xml new file mode 100644 index 00000000..9fbe3a66 --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/circle_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/coreapp/src/androidMain/res/drawable/ic_closed_caption.xml b/coreapp/src/androidMain/res/drawable/ic_closed_caption.xml new file mode 100644 index 00000000..483cfa46 --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/ic_closed_caption.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/coreapp/src/androidMain/res/drawable/ic_pip.xml b/coreapp/src/androidMain/res/drawable/ic_pip.xml index 34904f6a..bf046d99 100644 --- a/coreapp/src/androidMain/res/drawable/ic_pip.xml +++ b/coreapp/src/androidMain/res/drawable/ic_pip.xml @@ -1,9 +1,11 @@ + + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="#fff"> - + android:fillColor="#fff" + android:pathData="M21,18.5c0,0.83 -0.67,1.5 -1.5,1.5h-15C3.67,20 3,19.33 3,18.5v-13C3,4.67 3.67,4 4.5,4h15C20.33,4 21,4.67 21,5.5V18.5zM19,5.8H5v12.4h14V5.8zM17,11v5h-6v-5H17z"/> + \ No newline at end of file diff --git a/coreapp/src/androidMain/res/layout/activity_video_player.xml b/coreapp/src/androidMain/res/layout/activity_video_player.xml index 4508e508..0203e89f 100644 --- a/coreapp/src/androidMain/res/layout/activity_video_player.xml +++ b/coreapp/src/androidMain/res/layout/activity_video_player.xml @@ -1,23 +1,66 @@ + android:layout_height="match_parent"> + android:layout_height="match_parent" /> - + + + + + + + - + android:orientation="horizontal"> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/coreapp/src/androidMain/res/values/strings.xml b/coreapp/src/androidMain/res/values/strings.xml index f10114e9..70d26b16 100644 --- a/coreapp/src/androidMain/res/values/strings.xml +++ b/coreapp/src/androidMain/res/values/strings.xml @@ -2,4 +2,10 @@ Media Channel Media Channel Casting to device + Subtitle Settings + Off + On + Text Size + Language + Style \ No newline at end of file diff --git a/coreapp/src/androidMain/res/xml/file_paths.xml b/coreapp/src/androidMain/res/xml/file_paths.xml index 0e688d0b..3de4df56 100644 --- a/coreapp/src/androidMain/res/xml/file_paths.xml +++ b/coreapp/src/androidMain/res/xml/file_paths.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/models/VideoItemInfo.kt b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/models/VideoItemInfo.kt index d00eb87f..c4d90c3c 100644 --- a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/models/VideoItemInfo.kt +++ b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/models/VideoItemInfo.kt @@ -4,5 +4,5 @@ data class VideoItemInfo( val url: String, val artist: String? = null, val title: String? = null, - val artworkUrl: String? = null + val artworkUrl: String? = null, ) \ No newline at end of file From 456bc60311f02580b01a54c8f294225706a42cb8 Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Thu, 8 May 2025 02:00:53 +0400 Subject: [PATCH 16/18] try to prevent multi app instance from creation and also handle pause pip when another video start --- .../components/videoPlayer/Events.kt | 15 +++--- .../videoPlayer/VideoPlayer.android.kt | 5 +- .../videoPlayer/VideoPlayerActivity.kt | 53 ++++++++----------- .../videoPlayer/VideoPlayerManager.kt | 9 ++++ 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 849727c5..6b5c4691 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -14,27 +14,26 @@ internal sealed class VideoPlayerEvent { data object StoppedPip : VideoPlayerEvent() data object StartedPip : VideoPlayerEvent() data object ActivityFinished : VideoPlayerEvent() + data class PlayerStarted(val playerId: String) : VideoPlayerEvent() } internal object VideoPlayerEventBroadcaster { private val events = MutableSharedFlow() + private var pipActivePlayerId: String? = null + + fun getPipActivePlayerId(): String? = pipActivePlayerId + fun setPipActivePlayerId(id: String?) { pipActivePlayerId = id } @OptIn(DelicateCoroutinesApi::class) fun emit(event: VideoPlayerEvent) { - GlobalScope.launch { - events.emit(event) - } + GlobalScope.launch { events.emit(event) } } @SuppressLint("ComposableNaming") @Composable inline fun collectInCompose(crossinline onReceived: (T) -> Unit) { LaunchedEffect(Unit) { - events - .filter { it is T } - .collectLatest { - onReceived(it as T) - } + events.filter { it is T }.collectLatest { onReceived(it as T) } } } } \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index a7bb681b..4f7d6ada 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -145,7 +145,10 @@ actual fun VideoPlayer( val controller = remember(playerManager) { object : VideoPlayerController { - override fun play() = playerManager.play() + override fun play() { + playerManager.play() + eventBroadcaster.emit(VideoPlayerEvent.PlayerStarted(uniqueId)) + } override fun pause() = playerManager.pause() } } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 5c9f4f82..1d8f6e78 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -11,7 +11,6 @@ import android.graphics.Rect import android.os.Build import android.os.Bundle import android.util.Rational -import android.view.MotionEvent import android.view.SurfaceView import android.view.View import android.widget.FrameLayout @@ -40,7 +39,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private var exoPlayer: ExoPlayer? = null private var isPipSupported = false private var wasPlayingBeforePip = false - private var hasUserInteracted = false private var playerView: PlayerView? = null private var playerId: String? = null @@ -54,28 +52,22 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } } - // Subtitle picker launcher private val subtitlePickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { val uri = result.data?.data ?: return@registerForActivityResult - - // Take persistent permission contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) - // Use SubtitleFileLoader to load the file lifecycleScope.launch { val subtitleLoader = SubtitleFileLoader(this@VideoPlayerActivity) - val result = subtitleLoader.loadSubtitleFile(uri) - - if (result != null) { - val (language, fileName, content) = result + val fileLoaderResult = subtitleLoader.loadSubtitleFile(uri) - // Get the player manager and add the subtitle + if (fileLoaderResult != null) { + val (language, fileName, content) = fileLoaderResult val playerManagers by inject>(DiQualifiers.videoPlayerManagers) playerManagers[playerId]?.addExternalSubtitle(language, fileName, content) } @@ -129,7 +121,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private fun handleBackPress() { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (isInPictureInPictureMode) { finish() return @@ -159,7 +150,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE - // Set up Cast Button val castButton = findViewById(R.id.cast_button) val playerManagers by inject>(DiQualifiers.videoPlayerManagers) playerId?.let { playerManagers[it]?.setupCastButton(castButton) } @@ -167,9 +157,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private fun enablePip() { if (!isPipSupported) return - wasPlayingBeforePip = exoPlayer?.isPlaying ?: false - val params = updatePipParams() if (params != null) { enterPictureInPictureMode(params) @@ -226,7 +214,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } } - // Set up Cast Button val castButton = findViewById(R.id.cast_button) playerManager.setupCastButton(castButton) } @@ -247,25 +234,31 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { val topControlsContainer = findViewById(R.id.top_controls_container) if (isInPictureInPictureMode) { - hasUserInteracted = false + eventBroadcaster.setPipActivePlayerId(playerId) + + val playerManagers by inject>(DiQualifiers.videoPlayerManagers) + playerManagers.forEach { (id, manager) -> + if (id != playerId) { + manager.pause() + } + } + playerView?.useController = false playerView?.hideController() - // Hide the PiP button when in PiP mode pipContainer.visibility = View.GONE topControlsContainer.visibility = View.GONE - - // Hide subtitles in PIP mode playerView?.subtitleView?.visibility = View.INVISIBLE eventBroadcaster.emit(VideoPlayerEvent.StartedPip) } else { + if (eventBroadcaster.getPipActivePlayerId() == playerId) { + eventBroadcaster.setPipActivePlayerId(null) + } + playerView?.useController = true showSystemBars() - // Show the PiP button again when exiting PiP mode pipContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE topControlsContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE - - // Show subtitles again when exiting PIP mode playerView?.subtitleView?.visibility = View.VISIBLE if (playerView?.player == null) { @@ -275,10 +268,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { exoPlayer?.setVideoSurfaceView(it) } eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) - - if (!isChangingConfigurations && !hasUserInteracted) { - finish() - } } } @@ -315,12 +304,11 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { playerView?.player = null exoPlayer = null - eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished) - } + if (eventBroadcaster.getPipActivePlayerId() == playerId) { + eventBroadcaster.setPipActivePlayerId(null) + } - override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - hasUserInteracted = true - return super.dispatchTouchEvent(ev) + eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished) } companion object { @@ -329,6 +317,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private const val ACTION_MEDIA_CONTROL = "media_control" private const val EXTRA_CONTROL_TYPE = "control_type" private const val CONTROL_TYPE_PLAY_PAUSE = 1 + private const val RECEIVER_EXPORTED = Context.RECEIVER_EXPORTED fun start( context: Context, diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index 36e02a46..79393605 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -303,6 +303,14 @@ internal class VideoPlayerManager( } fun play() { + val eventBroadcaster = VideoPlayerEventBroadcaster + val pipPlayerId = eventBroadcaster.getPipActivePlayerId() + + if (pipPlayerId != null && pipPlayerId != uniqueId) { + val playerManagers by inject>(DiQualifiers.videoPlayerManagers) + playerManagers[pipPlayerId]?.pause() + } + if (_isCasting.value) { castManager?.getCurrentPlayer()?.play() } else if (exoPlayer.isPlaying.not()) { @@ -310,6 +318,7 @@ internal class VideoPlayerManager( } } + fun pause() { if (_isCasting.value) { castManager?.getCurrentPlayer()?.pause() From d53b7a220e77bac42239950682bc44da1c49151f Mon Sep 17 00:00:00 2001 From: ahmedkaram <1ahmedkaram1@gmail.com> Date: Thu, 8 May 2025 23:37:42 +0400 Subject: [PATCH 17/18] fix problem of transition between video modes pip, normal and full screen --- .../components/videoPlayer/Events.kt | 8 +- .../videoPlayer/VideoPlayer.android.kt | 213 ++++++++---------- .../videoPlayer/VideoPlayerActivity.kt | 158 +++++++------ .../res/layout/activity_video_player.xml | 8 +- 4 files changed, 192 insertions(+), 195 deletions(-) diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 6b5c4691..78ffb54e 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch internal sealed class VideoPlayerEvent { - data object StoppedPip : VideoPlayerEvent() - data object StartedPip : VideoPlayerEvent() - data object ActivityFinished : VideoPlayerEvent() + data class StoppedPip(val playerId: String) : VideoPlayerEvent() // Added playerId + data class StartedPip(val playerId: String) : VideoPlayerEvent() // Added playerId + data class ActivityFinished(val playerId: String) : VideoPlayerEvent() // Added playerId data class PlayerStarted(val playerId: String) : VideoPlayerEvent() } @@ -36,4 +36,4 @@ internal object VideoPlayerEventBroadcaster { events.filter { it is T }.collectLatest { onReceived(it as T) } } } -} \ No newline at end of file +} diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 4f7d6ada..13b14ad8 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -3,8 +3,6 @@ package com.metacto.core.presentation.components.videoPlayer import android.util.TypedValue import android.view.SurfaceView import android.view.View -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -95,48 +93,60 @@ actual fun VideoPlayer( koinInject>(DiQualifiers.videoPlayerManagers) val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } + // State management val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } val isVideoEnded = remember { mutableStateOf(false) } - var enableRendering by remember { mutableStateOf(true) } + var enableRendering by remember { mutableStateOf(true) } // Default to true for composable player var shouldResumePlayback by remember { mutableStateOf(false) } + var surfaceRecreationTrigger by remember { mutableStateOf(false) } val icon = if (isPlaying.value) pauseIconRes else playIconRes val isPlayButtonVisible by remember { mutableStateOf(true) } val playerViewRef = remember { mutableStateOf(null) } - // Cast support val isCasting by playerManager.isCasting.collectAsState() - // Subtitle file loading val subtitleFilePicker = rememberSubtitleFilePicker { language, fileName, content -> playerManager.addExternalSubtitle(language, fileName, content) } + // Listen for Activity finishing to re-enable rendering in the composable eventBroadcaster.collectInCompose { - enableRendering = true - if (shouldResumePlayback) { - playerManager.play() - shouldResumePlayback = false + if (it.playerId == uniqueId) { + enableRendering = true + if (shouldResumePlayback && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.play() + shouldResumePlayback = false + } + isPlaying.value = playerManager.exoPlayer.isPlaying + playerViewRef.value?.subtitleView?.visibility = View.VISIBLE + surfaceRecreationTrigger = !surfaceRecreationTrigger } - isPlaying.value = playerManager.exoPlayer.isPlaying - - // Ensure subtitles are visible when activity finishes - playerViewRef.value?.subtitleView?.visibility = View.VISIBLE } + // Listen for PiP start to disable rendering in the composable eventBroadcaster.collectInCompose { - enableRendering = false - shouldResumePlayback = playerManager.exoPlayer.isPlaying - - // Hide subtitles when entering PIP mode - playerViewRef.value?.subtitleView?.visibility = View.INVISIBLE + if (it.playerId == uniqueId) { + shouldResumePlayback = playerManager.exoPlayer.isPlaying + enableRendering = false + playerViewRef.value?.subtitleView?.visibility = View.INVISIBLE + surfaceRecreationTrigger = !surfaceRecreationTrigger + } } + // Listen for PiP stop eventBroadcaster.collectInCompose { - enableRendering = false - shouldResumePlayback = playerManager.exoPlayer.isPlaying - - // Show subtitles when exiting PIP mode - playerViewRef.value?.subtitleView?.visibility = View.VISIBLE + if (it.playerId == uniqueId) { + if (eventBroadcaster.getPipActivePlayerId() == null) { + enableRendering = true + if (shouldResumePlayback && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.play() + shouldResumePlayback = false + } + isPlaying.value = playerManager.exoPlayer.isPlaying + playerViewRef.value?.subtitleView?.visibility = View.VISIBLE + surfaceRecreationTrigger = !surfaceRecreationTrigger + } + } } LaunchedEffect(enablePip) { @@ -149,10 +159,12 @@ actual fun VideoPlayer( playerManager.play() eventBroadcaster.emit(VideoPlayerEvent.PlayerStarted(uniqueId)) } + override fun pause() = playerManager.pause() } } + // Player listener setup LaunchedEffect(key1 = playerManager) { playerManager.exoPlayer.addListener(object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { @@ -173,6 +185,7 @@ actual fun VideoPlayer( }) } + // Initialize player with media info LaunchedEffect( playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl, autoPlay, scaleToCrop, autoRepeat, enableVoice, enableMediaMetadata, @@ -197,38 +210,57 @@ actual fun VideoPlayer( fun onFullScreen(id: String) { shouldResumePlayback = playerManager.exoPlayer.isPlaying enableRendering = false + surfaceRecreationTrigger = !surfaceRecreationTrigger VideoPlayerActivity.start(context = context, uniqueId = id, enablePip = enablePip) } Box(modifier = modifier) { + // Video player view AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> - createPlayerView( - context = ctx, - playerManager = playerManager, - controlsType = controlsType, - controllerShowTimeoutMs = controllerShowTimeoutMs, - resizeMode = if (scaleToCrop) AspectRatioFrameLayout.RESIZE_MODE_FILL - else AspectRatioFrameLayout.RESIZE_MODE_FIT, - enableRendering = enableRendering, - onFullscreenClick = { onFullScreen(uniqueId) } - ).also { + PlayerView(ctx).apply { + useController = (controlsType == ControlsType.NativeControls) + this.controllerShowTimeoutMs = controllerShowTimeoutMs + this.resizeMode = if (scaleToCrop) AspectRatioFrameLayout.RESIZE_MODE_ZOOM + else AspectRatioFrameLayout.RESIZE_MODE_FIT + subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + subtitleView?.setPaddingRelative(0, 0, 0, 20) + subtitleView?.setApplyEmbeddedStyles(true) + subtitleView?.setCues(null) + + setFullscreenButtonClickListener { + onFullScreen(uniqueId) + } + }.also { playerViewRef.value = it } }, - update = { playerView -> - playerViewRef.value = playerView - updatePlayerView( - playerView = playerView, - playerManager = playerManager, - enableRendering = enableRendering, - onFullscreenClick = { onFullScreen(uniqueId) } - ) + update = { view -> + playerViewRef.value = view + if (enableRendering) { + if (view.player != playerManager.exoPlayer) { + view.player = null + view.player = playerManager.exoPlayer + } else { + (view.videoSurfaceView as? SurfaceView)?.let { + playerManager.exoPlayer.setVideoSurfaceView(it) + } + } + view.subtitleView?.visibility = View.VISIBLE + if (playerManager.exoPlayer.playWhenReady && !playerManager.exoPlayer.isPlaying && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.exoPlayer.play() + } + } else { + if (view.player != null) { + view.player = null + } + view.subtitleView?.visibility = View.INVISIBLE + } } ) - // Custom Controls - Play/Pause button + // Custom play/pause control if (controlsType == ControlsType.CustomControls) { FadeVisibility( visible = isPlayButtonVisible && enableRendering, @@ -255,7 +287,7 @@ actual fun VideoPlayer( } } - // Top control bar with subtitle and cast buttons + // Top row controls (subtitle and cast buttons) FadeVisibility( visible = isPlayButtonVisible && enableRendering, duration = CONTROLS_ANIM_DURATION, @@ -264,7 +296,6 @@ actual fun VideoPlayer( .padding(top = 8.dp, end = 8.dp) ) { Row { - // Subtitle button - directly opens file picker Box( modifier = Modifier .size(36.dp) @@ -285,14 +316,12 @@ actual fun VideoPlayer( Box( modifier = Modifier .size(36.dp) - .background(color = Color.LightGray, shape = CircleShape) - .clickable { subtitleFilePicker() }, + .background(color = Color.LightGray, shape = CircleShape), contentAlignment = Alignment.Center ) { - // Cast button AndroidView( - factory = { context -> - MediaRouteButton(context).apply { + factory = { ctx -> + MediaRouteButton(ctx).apply { playerManager.setupCastButton(this) } }, @@ -302,7 +331,7 @@ actual fun VideoPlayer( } } - // Casting Indicator + // Casting indicator if (isCasting) { Box( modifier = Modifier @@ -320,30 +349,34 @@ actual fun VideoPlayer( } } + // Cleanup when component is removed DisposableEffect(Unit) { - onDispose { - if (handleLifecyclePause) { - playerManager.pause() - } - } + onDispose { } } + // Handle app lifecycle events OnLifecycleEvent( onPause = { if (handleLifecyclePause && enableRendering) { - shouldResumePlayback = playerManager.exoPlayer.isPlaying + if (!playerManager.exoPlayer.isPlaying && shouldResumePlayback) { + } else { + shouldResumePlayback = playerManager.exoPlayer.isPlaying + } playerManager.pause() } }, onResume = { if (handleLifecyclePause && enableRendering && shouldResumePlayback) { - playerManager.play() + if (playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.play() + } shouldResumePlayback = false } } ) } +// Utility function for toggling play/pause @OptIn(UnstableApi::class) private fun togglePlayback( isPlaying: Boolean, @@ -359,70 +392,4 @@ private fun togglePlayback( } playerManager.play() } -} - -@OptIn(UnstableApi::class) -private fun createPlayerView( - context: android.content.Context, - playerManager: VideoPlayerManager, - controlsType: ControlsType, - controllerShowTimeoutMs: Int, - resizeMode: Int, - enableRendering: Boolean, - onFullscreenClick: () -> Unit -): PlayerView { - return PlayerView(context).apply { - useController = (controlsType == ControlsType.NativeControls) - this.controllerShowTimeoutMs = controllerShowTimeoutMs - this.resizeMode = resizeMode - player = playerManager.exoPlayer - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - - subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - subtitleView?.setPaddingRelative(0, 0, 0, 20) - subtitleView?.setApplyEmbeddedStyles(true) - subtitleView?.setCues(null) - - setFullscreenButtonClickListener { - onFullscreenClick() - } - - if (enableRendering) { - (videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) - } ?: run { - playerManager.exoPlayer.setVideoSurface(null) - playerManager.exoPlayer.setVideoSurfaceView(videoSurfaceView as? SurfaceView) - } - } - } -} - -@OptIn(UnstableApi::class) -private fun updatePlayerView( - playerView: PlayerView, - playerManager: VideoPlayerManager, - enableRendering: Boolean, - onFullscreenClick: () -> Unit -) { - playerView.setFullscreenButtonClickListener { - onFullscreenClick() - } - - playerView.subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - playerView.subtitleView?.setPaddingRelative(0, 0, 0, 20) - - if (enableRendering) { - if (playerView.player != playerManager.exoPlayer) { - playerView.player = playerManager.exoPlayer - } - (playerView.videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) - } ?: run { - playerManager.exoPlayer.setVideoSurface(null) - playerManager.exoPlayer.setVideoSurfaceView(playerView.videoSurfaceView as? SurfaceView) - } - } else { - playerManager.exoPlayer.clearVideoSurface() - } } \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index 1d8f6e78..d2e939fd 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -1,5 +1,6 @@ package com.metacto.core.presentation.components.videoPlayer +import androidx.mediarouter.app.MediaRouteButton import android.app.PictureInPictureParams import android.content.BroadcastReceiver import android.content.Context @@ -38,10 +39,11 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private val eventBroadcaster: VideoPlayerEventBroadcaster by inject() private var exoPlayer: ExoPlayer? = null private var isPipSupported = false - private var wasPlayingBeforePip = false private var playerView: PlayerView? = null private var playerId: String? = null + private var wasPlayingBeforePipEnter: Boolean = false + // Receiver for PiP action controls private val pipActionsReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { @@ -52,6 +54,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } } + // File picker for subtitles private val subtitlePickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -88,10 +91,17 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { setupViews() - intent?.getStringExtra(KEY_PLAYER_ID)?.let { - this.playerId = it - configPlayerView(it) + // Get player ID from intent + val newPlayerId = intent?.getStringExtra(KEY_PLAYER_ID) + if (newPlayerId != null) { + this.playerId = newPlayerId + configPlayerView(newPlayerId) + } else { + finish() + return } + + // Register receiver for PiP controls registerReceiver(pipActionsReceiver, IntentFilter(ACTION_MEDIA_CONTROL), RECEIVER_EXPORTED) handleBackPress() setupSubtitleButton() @@ -109,10 +119,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" putExtra(Intent.EXTRA_MIME_TYPES, arrayOf( - "text/vtt", - "application/x-subrip", - "text/plain", - "text/x-ssa" + "text/vtt", "application/x-subrip", "application/ttml+xml", "text/x-ssa" )) } subtitlePickerLauncher.launch(intent) @@ -121,16 +128,12 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private fun handleBackPress() { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (isInPictureInPictureMode) { - finish() - return - } - this.remove() - onBackPressedDispatcher.onBackPressed() + finish() } }) } + // Hide system UI for fullscreen experience private fun hideSystemBars() { val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = @@ -149,22 +152,21 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { enablePip() } ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE - - val castButton = findViewById(R.id.cast_button) - val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - playerId?.let { playerManagers[it]?.setupCastButton(castButton) } } + // Enable Picture-in-Picture mode private fun enablePip() { if (!isPipSupported) return - wasPlayingBeforePip = exoPlayer?.isPlaying ?: false - val params = updatePipParams() - if (params != null) { - enterPictureInPictureMode(params) + + wasPlayingBeforePipEnter = exoPlayer?.isPlaying ?: false + updatePipParams()?.let { + enterPictureInPictureMode(it) } } + // Update PiP parameters based on video dimensions private fun updatePipParams(): PictureInPictureParams? { + val videoWidth = exoPlayer?.videoSize?.width ?: 16 val videoHeight = exoPlayer?.videoSize?.height ?: 9 @@ -173,13 +175,14 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(true) - builder.setSeamlessResizeEnabled(false) } - playerView?.videoSurfaceView?.let { surfaceView -> - val rect = Rect() - surfaceView.getGlobalVisibleRect(rect) - builder.setSourceRectHint(rect) + playerView?.let { + val visibleRect = Rect() + it.getGlobalVisibleRect(visibleRect) + if (!visibleRect.isEmpty) { + builder.setSourceRectHint(visibleRect) + } } val params = builder.build() @@ -187,34 +190,44 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { return params } + // Configure player view with the appropriate player manager @OptIn(UnstableApi::class) - private fun configPlayerView(playerId: String) { + private fun configPlayerView(currentPlayerId: String) { val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - val playerManager = playerManagers[playerId] - val newPlayer = playerManager?.exoPlayer + val playerManager = playerManagers[currentPlayerId] + val newPlayerInstanceFromManager = playerManager?.exoPlayer - if (newPlayer == null) { + if (newPlayerInstanceFromManager == null) { finish() return } - exoPlayer?.removeListener(this) + if (this.exoPlayer != null && this.exoPlayer != newPlayerInstanceFromManager) { + this.exoPlayer?.removeListener(this) + } - exoPlayer = newPlayer - playerView?.player = exoPlayer - exoPlayer?.addListener(this) + this.exoPlayer = newPlayerInstanceFromManager - (playerView?.videoSurfaceView as? SurfaceView)?.let { - exoPlayer?.setVideoSurfaceView(it) + playerView?.let { + if (it.player != this.exoPlayer) { + it.player = null + it.player = this.exoPlayer + } else { + (it.videoSurfaceView as? SurfaceView)?.let { surfaceView -> + this.exoPlayer?.setVideoSurfaceView(surfaceView) + } + } } - playerManager.setVideoSizeListener { width, height -> + this.exoPlayer?.addListener(this) + + playerManager.setVideoSizeListener { _, _ -> if (isInPictureInPictureMode) { updatePipParams() } } - val castButton = findViewById(R.id.cast_button) + val castButton = findViewById(R.id.cast_button) playerManager.setupCastButton(castButton) } @@ -224,6 +237,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } } + // Handle PiP mode changes override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration @@ -234,40 +248,31 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { val topControlsContainer = findViewById(R.id.top_controls_container) if (isInPictureInPictureMode) { - eventBroadcaster.setPipActivePlayerId(playerId) - - val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - playerManagers.forEach { (id, manager) -> - if (id != playerId) { - manager.pause() - } - } - + playerId?.let { eventBroadcaster.setPipActivePlayerId(it) } playerView?.useController = false playerView?.hideController() pipContainer.visibility = View.GONE topControlsContainer.visibility = View.GONE playerView?.subtitleView?.visibility = View.INVISIBLE - - eventBroadcaster.emit(VideoPlayerEvent.StartedPip) + playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.StartedPip(it)) } } else { - if (eventBroadcaster.getPipActivePlayerId() == playerId) { + if (playerId != null && eventBroadcaster.getPipActivePlayerId() == playerId) { eventBroadcaster.setPipActivePlayerId(null) } - playerView?.useController = true showSystemBars() pipContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE topControlsContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE playerView?.subtitleView?.visibility = View.VISIBLE - if (playerView?.player == null) { - playerView?.player = exoPlayer - } - (playerView?.videoSurfaceView as? SurfaceView)?.let { - exoPlayer?.setVideoSurfaceView(it) + // When exiting PiP, ensure player is configured and playing if it was before + playerId?.let { + configPlayerView(it) + if (wasPlayingBeforePipEnter && exoPlayer?.isPlaying == false) { + exoPlayer?.play() + } } - eventBroadcaster.emit(VideoPlayerEvent.StoppedPip) + playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.StoppedPip(it)) } } } @@ -280,10 +285,37 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) intent.getStringExtra(KEY_PLAYER_ID)?.let { if (it != this.playerId) { this.playerId = it configPlayerView(it) + } else { + // If same player ID but activity brought to front + configPlayerView(it) + } + } + } + + override fun onStart() { + super.onStart() + // Ensure player is configured when activity starts/restarts + playerId?.let { + configPlayerView(it) + // If it was playing before PiP and we're not in PiP mode anymore, resume + if (wasPlayingBeforePipEnter && !isInPictureInPictureMode && exoPlayer?.isPlaying == false) { + exoPlayer?.play() + } + } + } + + override fun onResume() { + super.onResume() + // Similar to onStart, ensure player is configured and ready + playerId?.let { + configPlayerView(it) + if (wasPlayingBeforePipEnter && !isInPictureInPictureMode && exoPlayer?.isPlaying == false) { + exoPlayer?.play() } } } @@ -302,13 +334,10 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { unregisterReceiver(pipActionsReceiver) exoPlayer?.removeListener(this) playerView?.player = null - exoPlayer = null - - if (eventBroadcaster.getPipActivePlayerId() == playerId) { + if (playerId != null && eventBroadcaster.getPipActivePlayerId() == playerId) { eventBroadcaster.setPipActivePlayerId(null) } - - eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished) + playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished(it)) } } companion object { @@ -317,7 +346,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private const val ACTION_MEDIA_CONTROL = "media_control" private const val EXTRA_CONTROL_TYPE = "control_type" private const val CONTROL_TYPE_PLAY_PAUSE = 1 - private const val RECEIVER_EXPORTED = Context.RECEIVER_EXPORTED fun start( context: Context, @@ -327,7 +355,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { val intent = Intent(context, VideoPlayerActivity::class.java).apply { putExtra(KEY_PLAYER_ID, uniqueId) putExtra(KEY_ENABLE_PIP, enablePip) - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT } context.startActivity(intent) } diff --git a/coreapp/src/androidMain/res/layout/activity_video_player.xml b/coreapp/src/androidMain/res/layout/activity_video_player.xml index 0203e89f..70e09c8d 100644 --- a/coreapp/src/androidMain/res/layout/activity_video_player.xml +++ b/coreapp/src/androidMain/res/layout/activity_video_player.xml @@ -14,7 +14,8 @@ android:layout_width="36dp" android:layout_height="36dp" android:layout_gravity="top|start" - android:layout_margin="16dp" + android:layout_marginStart="16dp" + android:layout_marginTop="32dp" android:background="@drawable/circle_background"> @@ -45,7 +47,7 @@ android:src="@drawable/ic_closed_caption" /> From 68717ccd2cc4cefc9d86f72da21aab5ad7375fae Mon Sep 17 00:00:00 2001 From: karam <1ahmedkaram1@gmail.com> Date: Fri, 9 May 2025 14:00:03 +0400 Subject: [PATCH 18/18] Fix the problem of playing multi videos with pip option --- .../components/videoPlayer/Events.kt | 12 +- .../videoPlayer/VideoPlayer.android.kt | 48 ++-- .../videoPlayer/VideoPlayerActivity.kt | 243 ++++++++++++------ .../videoPlayer/VideoPlayerManager.kt | 106 ++++++-- .../src/androidMain/res/drawable/ic_pause.xml | 11 + .../src/androidMain/res/drawable/ic_play.xml | 11 + .../home/components/HomeContent.kt | 44 ++++ 7 files changed, 353 insertions(+), 122 deletions(-) create mode 100644 coreapp/src/androidMain/res/drawable/ic_pause.xml create mode 100644 coreapp/src/androidMain/res/drawable/ic_play.xml diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt index 78ffb54e..c520aa19 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -11,14 +11,14 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch internal sealed class VideoPlayerEvent { - data class StoppedPip(val playerId: String) : VideoPlayerEvent() // Added playerId - data class StartedPip(val playerId: String) : VideoPlayerEvent() // Added playerId - data class ActivityFinished(val playerId: String) : VideoPlayerEvent() // Added playerId - data class PlayerStarted(val playerId: String) : VideoPlayerEvent() + data class StoppedPip(val playerId: String) : VideoPlayerEvent() + data class StartedPip(val playerId: String) : VideoPlayerEvent() + data class ActivityFinished(val playerId: String) : VideoPlayerEvent() } internal object VideoPlayerEventBroadcaster { - private val events = MutableSharedFlow() + // Changed from private to internal to allow access from VideoPlayerActivity + internal val events = MutableSharedFlow() private var pipActivePlayerId: String? = null fun getPipActivePlayerId(): String? = pipActivePlayerId @@ -36,4 +36,4 @@ internal object VideoPlayerEventBroadcaster { events.filter { it is T }.collectLatest { onReceived(it as T) } } } -} +} \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt index 13b14ad8..914538aa 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayer.android.kt @@ -93,10 +93,9 @@ actual fun VideoPlayer( koinInject>(DiQualifiers.videoPlayerManagers) val playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } - // State management val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } val isVideoEnded = remember { mutableStateOf(false) } - var enableRendering by remember { mutableStateOf(true) } // Default to true for composable player + var enableRendering by remember { mutableStateOf(true) } var shouldResumePlayback by remember { mutableStateOf(false) } var surfaceRecreationTrigger by remember { mutableStateOf(false) } val icon = if (isPlaying.value) pauseIconRes else playIconRes @@ -109,39 +108,52 @@ actual fun VideoPlayer( playerManager.addExternalSubtitle(language, fileName, content) } - // Listen for Activity finishing to re-enable rendering in the composable eventBroadcaster.collectInCompose { if (it.playerId == uniqueId) { - enableRendering = true - if (shouldResumePlayback && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { - playerManager.play() - shouldResumePlayback = false + val isPipActive = eventBroadcaster.getPipActivePlayerId() == uniqueId + + if (!isPipActive) { + enableRendering = true + + if (shouldResumePlayback && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.play() + shouldResumePlayback = false + } else { + playerManager.restoreState() + } + + isPlaying.value = playerManager.exoPlayer.isPlaying + playerViewRef.value?.subtitleView?.visibility = View.VISIBLE + surfaceRecreationTrigger = !surfaceRecreationTrigger } - isPlaying.value = playerManager.exoPlayer.isPlaying - playerViewRef.value?.subtitleView?.visibility = View.VISIBLE - surfaceRecreationTrigger = !surfaceRecreationTrigger } } - // Listen for PiP start to disable rendering in the composable eventBroadcaster.collectInCompose { if (it.playerId == uniqueId) { shouldResumePlayback = playerManager.exoPlayer.isPlaying + playerManager.saveState() + enableRendering = false playerViewRef.value?.subtitleView?.visibility = View.INVISIBLE surfaceRecreationTrigger = !surfaceRecreationTrigger } } - // Listen for PiP stop eventBroadcaster.collectInCompose { if (it.playerId == uniqueId) { - if (eventBroadcaster.getPipActivePlayerId() == null) { + if (eventBroadcaster.getPipActivePlayerId() == null || + eventBroadcaster.getPipActivePlayerId() != uniqueId) { + enableRendering = true + if (shouldResumePlayback && playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { playerManager.play() shouldResumePlayback = false + } else { + playerManager.restoreState() } + isPlaying.value = playerManager.exoPlayer.isPlaying playerViewRef.value?.subtitleView?.visibility = View.VISIBLE surfaceRecreationTrigger = !surfaceRecreationTrigger @@ -157,14 +169,12 @@ actual fun VideoPlayer( object : VideoPlayerController { override fun play() { playerManager.play() - eventBroadcaster.emit(VideoPlayerEvent.PlayerStarted(uniqueId)) } override fun pause() = playerManager.pause() } } - // Player listener setup LaunchedEffect(key1 = playerManager) { playerManager.exoPlayer.addListener(object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { @@ -185,7 +195,6 @@ actual fun VideoPlayer( }) } - // Initialize player with media info LaunchedEffect( playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl, autoPlay, scaleToCrop, autoRepeat, enableVoice, enableMediaMetadata, @@ -215,7 +224,6 @@ actual fun VideoPlayer( } Box(modifier = modifier) { - // Video player view AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> @@ -260,7 +268,6 @@ actual fun VideoPlayer( } ) - // Custom play/pause control if (controlsType == ControlsType.CustomControls) { FadeVisibility( visible = isPlayButtonVisible && enableRendering, @@ -287,7 +294,6 @@ actual fun VideoPlayer( } } - // Top row controls (subtitle and cast buttons) FadeVisibility( visible = isPlayButtonVisible && enableRendering, duration = CONTROLS_ANIM_DURATION, @@ -331,7 +337,6 @@ actual fun VideoPlayer( } } - // Casting indicator if (isCasting) { Box( modifier = Modifier @@ -349,12 +354,10 @@ actual fun VideoPlayer( } } - // Cleanup when component is removed DisposableEffect(Unit) { onDispose { } } - // Handle app lifecycle events OnLifecycleEvent( onPause = { if (handleLifecyclePause && enableRendering) { @@ -376,7 +379,6 @@ actual fun VideoPlayer( ) } -// Utility function for toggling play/pause @OptIn(UnstableApi::class) private fun togglePlayback( isPlaying: Boolean, diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt index d2e939fd..ef4f7e5f 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -1,7 +1,9 @@ package com.metacto.core.presentation.components.videoPlayer +import android.app.PendingIntent import androidx.mediarouter.app.MediaRouteButton import android.app.PictureInPictureParams +import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -9,6 +11,7 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Rect +import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.util.Rational @@ -25,6 +28,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -42,19 +46,44 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { private var playerView: PlayerView? = null private var playerId: String? = null private var wasPlayingBeforePipEnter: Boolean = false + private val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - // Receiver for PiP action controls private val pipActionsReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { + val controlType = intent?.getIntExtra(EXTRA_CONTROL_TYPE, 0) + val receivedPlayerId = intent?.getStringExtra(KEY_PLAYER_ID_PIP_ACTION) + + when (controlType) { CONTROL_TYPE_PLAY_PAUSE -> { - exoPlayer?.playWhenReady = !(exoPlayer?.playWhenReady ?: false) + val currentPipActivePlayerId = eventBroadcaster.getPipActivePlayerId() + + if (currentPipActivePlayerId != null && receivedPlayerId == currentPipActivePlayerId && isInPictureInPictureMode) { + playerManagers[currentPipActivePlayerId]?.let { pipManager -> + val pipExoPlayer = pipManager.exoPlayer + val wasPlaying = pipExoPlayer.isPlaying + + if (wasPlaying) { + pipManager.pause() + } else { + pipManager.play() + } + + // Add a small delay to ensure the player state has updated + lifecycleScope.launch { + kotlinx.coroutines.delay(50) + updatePipParams() + + if (pipManager.getMediaMetadataEnabled()) { + pipManager.notificationManager.showNotificationForPlayer(pipExoPlayer) + } + } + } + } } } } } - // File picker for subtitles private val subtitlePickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -71,8 +100,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { if (fileLoaderResult != null) { val (language, fileName, content) = fileLoaderResult - val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - playerManagers[playerId]?.addExternalSubtitle(language, fileName, content) + playerManagers[this@VideoPlayerActivity.playerId]?.addExternalSubtitle(language, fileName, content) } } } @@ -91,17 +119,15 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { setupViews() - // Get player ID from intent - val newPlayerId = intent?.getStringExtra(KEY_PLAYER_ID) - if (newPlayerId != null) { - this.playerId = newPlayerId - configPlayerView(newPlayerId) + val newPlayerIdFromIntent = intent?.getStringExtra(KEY_PLAYER_ID) + if (newPlayerIdFromIntent != null) { + this.playerId = newPlayerIdFromIntent + configPlayerView(newPlayerIdFromIntent) } else { finish() return } - // Register receiver for PiP controls registerReceiver(pipActionsReceiver, IntentFilter(ACTION_MEDIA_CONTROL), RECEIVER_EXPORTED) handleBackPress() setupSubtitleButton() @@ -133,7 +159,6 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { }) } - // Hide system UI for fullscreen experience private fun hideSystemBars() { val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = @@ -154,25 +179,63 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE } - // Enable Picture-in-Picture mode private fun enablePip() { - if (!isPipSupported) return + if (!isPipSupported || this.playerId == null) return + + val currentPlayerManager = playerManagers[this.playerId!!] + if (currentPlayerManager == null || !currentPlayerManager.isPipEnabled) return + + wasPlayingBeforePipEnter = currentPlayerManager.exoPlayer.isPlaying + currentPlayerManager.saveState() - wasPlayingBeforePipEnter = exoPlayer?.isPlaying ?: false updatePipParams()?.let { enterPictureInPictureMode(it) } } - // Update PiP parameters based on video dimensions + private fun createPipRemoteActions(): List { + val pipPlayerId = this.playerId ?: return emptyList() + val playerManager = playerManagers[pipPlayerId] ?: return emptyList() + + val isPlaying = playerManager.exoPlayer.isPlaying + + val playPauseIntent = Intent(ACTION_MEDIA_CONTROL).apply { + putExtra(EXTRA_CONTROL_TYPE, CONTROL_TYPE_PLAY_PAUSE) + putExtra(KEY_PLAYER_ID_PIP_ACTION, pipPlayerId) + } + + val pendingIntent = PendingIntent.getBroadcast( + this, + CONTROL_TYPE_PLAY_PAUSE, + playPauseIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val iconResId = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play + val title = "" + + return listOf( + RemoteAction( + Icon.createWithResource(this, iconResId), + title, + title, + pendingIntent + ) + ) + } + private fun updatePipParams(): PictureInPictureParams? { + val currentPlayerManager = playerManagers[this.playerId ?: return null] + val currentExoPlayer = currentPlayerManager?.exoPlayer ?: return null - val videoWidth = exoPlayer?.videoSize?.width ?: 16 - val videoHeight = exoPlayer?.videoSize?.height ?: 9 + val videoWidth = currentExoPlayer.videoSize.width + val videoHeight = currentExoPlayer.videoSize.height val builder = PictureInPictureParams.Builder() .setAspectRatio(Rational(videoWidth.coerceAtLeast(1), videoHeight.coerceAtLeast(1))) + builder.setActions(createPipRemoteActions()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(true) } @@ -190,23 +253,22 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { return params } - // Configure player view with the appropriate player manager @OptIn(UnstableApi::class) - private fun configPlayerView(currentPlayerId: String) { - val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - val playerManager = playerManagers[currentPlayerId] - val newPlayerInstanceFromManager = playerManager?.exoPlayer - - if (newPlayerInstanceFromManager == null) { + private fun configPlayerView(targetPlayerId: String) { + val playerManager = playerManagers[targetPlayerId] + if (playerManager == null) { finish() return } - if (this.exoPlayer != null && this.exoPlayer != newPlayerInstanceFromManager) { + val newPlayerInstance = playerManager.exoPlayer + + if (this.exoPlayer != null && this.exoPlayer != newPlayerInstance) { this.exoPlayer?.removeListener(this) } - this.exoPlayer = newPlayerInstanceFromManager + this.exoPlayer = newPlayerInstance + this.playerId = targetPlayerId playerView?.let { if (it.player != this.exoPlayer) { @@ -222,7 +284,7 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { this.exoPlayer?.addListener(this) playerManager.setVideoSizeListener { _, _ -> - if (isInPictureInPictureMode) { + if (isInPictureInPictureMode && eventBroadcaster.getPipActivePlayerId() == this.playerId) { updatePipParams() } } @@ -232,90 +294,116 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { } override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isInPictureInPictureMode) { + super.onIsPlayingChanged(isPlaying) + + if (isInPictureInPictureMode && eventBroadcaster.getPipActivePlayerId() == this.playerId) { + updatePipParams() + } + } + + override fun onPlaybackStateChanged(state: Int) { + super.onPlaybackStateChanged(state) + + if (isInPictureInPictureMode && eventBroadcaster.getPipActivePlayerId() == this.playerId) { updatePipParams() } } - // Handle PiP mode changes - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration - ) { + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + updatePipParams() + } + val currentActivityPlayerId = this.playerId ?: return + val currentManager = playerManagers[currentActivityPlayerId] + val pipContainer = findViewById(R.id.pip_container) val topControlsContainer = findViewById(R.id.top_controls_container) if (isInPictureInPictureMode) { - playerId?.let { eventBroadcaster.setPipActivePlayerId(it) } + eventBroadcaster.setPipActivePlayerId(currentActivityPlayerId) + playerView?.useController = false playerView?.hideController() pipContainer.visibility = View.GONE topControlsContainer.visibility = View.GONE playerView?.subtitleView?.visibility = View.INVISIBLE - playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.StartedPip(it)) } + + eventBroadcaster.emit(VideoPlayerEvent.StartedPip(currentActivityPlayerId)) } else { - if (playerId != null && eventBroadcaster.getPipActivePlayerId() == playerId) { + val previouslyPipPlayerId = eventBroadcaster.getPipActivePlayerId() + + if (previouslyPipPlayerId == currentActivityPlayerId) { eventBroadcaster.setPipActivePlayerId(null) + + currentManager?.let { + if (!it.isExplicitlyPaused()) { + it.switchFromPip() + } else { + it.setExplicitlyPaused(false) + } + } } + playerView?.useController = true showSystemBars() pipContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE topControlsContainer.visibility = if (isPipSupported) View.VISIBLE else View.GONE playerView?.subtitleView?.visibility = View.VISIBLE - // When exiting PiP, ensure player is configured and playing if it was before - playerId?.let { - configPlayerView(it) - if (wasPlayingBeforePipEnter && exoPlayer?.isPlaying == false) { - exoPlayer?.play() - } + configPlayerView(currentActivityPlayerId) + + if (wasPlayingBeforePipEnter && currentManager?.isExplicitlyPaused() == false && !currentManager.exoPlayer.isPlaying) { + currentManager.exoPlayer.play() } - playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.StoppedPip(it)) } + wasPlayingBeforePipEnter = false + + eventBroadcaster.emit(VideoPlayerEvent.StoppedPip(currentActivityPlayerId)) } } override fun onUserLeaveHint() { super.onUserLeaveHint() - if (isPipSupported && exoPlayer?.isPlaying == true) { + if (this.playerId != null && playerManagers[this.playerId!!]?.isPipEnabled == true && playerManagers[this.playerId!!]?.exoPlayer?.isPlaying == true) { enablePip() } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + val oldPlayerId = this.playerId setIntent(intent) - intent.getStringExtra(KEY_PLAYER_ID)?.let { - if (it != this.playerId) { - this.playerId = it - configPlayerView(it) + + val newPlayerIdFromIntent = intent.getStringExtra(KEY_PLAYER_ID) + if (newPlayerIdFromIntent != null) { + if (newPlayerIdFromIntent != oldPlayerId) { + this.playerId = newPlayerIdFromIntent + configPlayerView(newPlayerIdFromIntent) } else { - // If same player ID but activity brought to front - configPlayerView(it) + configPlayerView(newPlayerIdFromIntent) } } } override fun onStart() { super.onStart() - // Ensure player is configured when activity starts/restarts - playerId?.let { + this.playerId?.let { configPlayerView(it) - // If it was playing before PiP and we're not in PiP mode anymore, resume - if (wasPlayingBeforePipEnter && !isInPictureInPictureMode && exoPlayer?.isPlaying == false) { - exoPlayer?.play() + val activePipId = eventBroadcaster.getPipActivePlayerId() + if (it == activePipId && !isInPictureInPictureMode) { + playerManagers[it]?.switchFromPip() } } } override fun onResume() { super.onResume() - // Similar to onStart, ensure player is configured and ready - playerId?.let { + this.playerId?.let { configPlayerView(it) - if (wasPlayingBeforePipEnter && !isInPictureInPictureMode && exoPlayer?.isPlaying == false) { - exoPlayer?.play() + val activePipId = eventBroadcaster.getPipActivePlayerId() + if (it == activePipId && !isInPictureInPictureMode) { + playerManagers[it]?.switchFromPip() } } } @@ -324,7 +412,12 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { super.onStop() if (!isInPictureInPictureMode && !isChangingConfigurations) { if (!isFinishing) { - exoPlayer?.pause() + this.playerId?.let { + playerManagers[it]?.saveState() + if (playerManagers[it]?.exoPlayer?.isPlaying == true && playerManagers[it]?.isCasting?.value == false) { + playerManagers[it]?.exoPlayer?.pause() + } + } } } } @@ -332,30 +425,30 @@ internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { override fun onDestroy() { super.onDestroy() unregisterReceiver(pipActionsReceiver) - exoPlayer?.removeListener(this) + this.exoPlayer?.removeListener(this) playerView?.player = null - if (playerId != null && eventBroadcaster.getPipActivePlayerId() == playerId) { + + if (!isChangingConfigurations && this.playerId != null && eventBroadcaster.getPipActivePlayerId() == this.playerId) { eventBroadcaster.setPipActivePlayerId(null) } - playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished(it)) } + + this.playerId?.let { eventBroadcaster.emit(VideoPlayerEvent.ActivityFinished(it)) } + this.exoPlayer = null + this.playerId = null } companion object { private const val KEY_PLAYER_ID = "player_id" private const val KEY_ENABLE_PIP = "enable_pip" - private const val ACTION_MEDIA_CONTROL = "media_control" - private const val EXTRA_CONTROL_TYPE = "control_type" - private const val CONTROL_TYPE_PLAY_PAUSE = 1 - - fun start( - context: Context, - uniqueId: String, - enablePip: Boolean = true - ) { + const val ACTION_MEDIA_CONTROL = "com.metacto.core.presentation.components.videoPlayer.ACTION_MEDIA_CONTROL" + const val EXTRA_CONTROL_TYPE = "control_type" + const val CONTROL_TYPE_PLAY_PAUSE = 1 + const val KEY_PLAYER_ID_PIP_ACTION = "player_id_pip_action" + + fun start(context: Context, uniqueId: String, enablePip: Boolean) { val intent = Intent(context, VideoPlayerActivity::class.java).apply { putExtra(KEY_PLAYER_ID, uniqueId) putExtra(KEY_ENABLE_PIP, enablePip) - flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT } context.startActivity(intent) } diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt index 79393605..835ba552 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerManager.kt @@ -32,6 +32,9 @@ internal class VideoPlayerManager( private var isAutoPlay = false private var isMediaMetadataEnabled = false private var castManager: CastManager? = null + private var savedPosition: Long = 0 + private var wasPlayingBeforePause: Boolean = false + private var explicitlyPaused: Boolean = false private val trackSelector = DefaultTrackSelector(context).apply { parameters = DefaultTrackSelector.Parameters.Builder(context!!) @@ -68,7 +71,7 @@ internal class VideoPlayerManager( } } - private val notificationManager by lazy { + val notificationManager by lazy { MediaNotificationManager( context = context, sessionToken = mediaSession.token, @@ -79,16 +82,25 @@ internal class VideoPlayerManager( init { exoPlayer.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isMediaMetadataEnabled.not()) return - if (isPlaying.not()) return + super.onIsPlayingChanged(isPlaying) - playerManagers.values.forEach { - if (it.uniqueId == uniqueId) return@forEach - it.exoPlayer.pause() - it.notificationManager.hideNotification() + if (isMediaMetadataEnabled) { + notificationManager.showNotificationForPlayer(exoPlayer) } - notificationManager.showNotificationForPlayer(exoPlayer) + if (isPlaying) { + playerManagers.values.forEach { otherManager -> + if (otherManager.uniqueId != uniqueId) { + if (otherManager.exoPlayer.isPlaying) { + otherManager.pause() + otherManager.setExplicitlyPaused(true) + if (otherManager.getMediaMetadataEnabled()) { + otherManager.notificationManager.showNotificationForPlayer(otherManager.exoPlayer) + } + } + } + } + } } override fun onPositionDiscontinuity( @@ -105,6 +117,9 @@ internal class VideoPlayerManager( if (state == Player.STATE_ENDED) { onVideoEnd?.invoke() } + if (isMediaMetadataEnabled) { + notificationManager.showNotificationForPlayer(exoPlayer) + } } override fun onVideoSizeChanged(videoSize: VideoSize) { @@ -161,7 +176,7 @@ internal class VideoPlayerManager( videoArtist: String?, videoArtworkUrl: String? ) { - if (exoPlayer.currentMediaItem?.mediaId == videoUrl) { + if (exoPlayer.currentMediaItem?.mediaId == videoUrl && exoPlayer.playbackState != Player.STATE_IDLE && exoPlayer.playbackState != Player.STATE_ENDED) { return } @@ -198,10 +213,8 @@ internal class VideoPlayerManager( } val authority = "${context.packageName}.fileprovider" - val subtitleLoader = SubtitleFileLoader(context) val mimeType = subtitleLoader.getMimeTypeFromFileName(fileName) - val subtitleUri = androidx.core.content.FileProvider.getUriForFile( context, authority, @@ -224,7 +237,8 @@ internal class VideoPlayerManager( .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) .build() - val currentConfigs = ArrayList(currentItem.localConfiguration?.subtitleConfigurations ?: emptyList()) + val currentConfigs = + ArrayList(currentItem.localConfiguration?.subtitleConfigurations ?: emptyList()) currentConfigs.add(newSubtitleConfig) val updatedItem = MediaItem.Builder() @@ -304,35 +318,91 @@ internal class VideoPlayerManager( fun play() { val eventBroadcaster = VideoPlayerEventBroadcaster - val pipPlayerId = eventBroadcaster.getPipActivePlayerId() + val activePipPlayerId = eventBroadcaster.getPipActivePlayerId() - if (pipPlayerId != null && pipPlayerId != uniqueId) { - val playerManagers by inject>(DiQualifiers.videoPlayerManagers) - playerManagers[pipPlayerId]?.pause() + if (activePipPlayerId != null && uniqueId != activePipPlayerId) { + playerManagers[activePipPlayerId]?.let { pipManager -> + if (pipManager.exoPlayer.isPlaying) { + pipManager.pause() + } + pipManager.setExplicitlyPaused(true) + if (pipManager.isMediaMetadataEnabled) { + pipManager.notificationManager.showNotificationForPlayer(pipManager.exoPlayer) + } + } } if (_isCasting.value) { castManager?.getCurrentPlayer()?.play() - } else if (exoPlayer.isPlaying.not()) { + } else { + if (exoPlayer.playbackState == Player.STATE_IDLE || exoPlayer.playbackState == Player.STATE_ENDED) { + exoPlayer.prepare() + } exoPlayer.play() } } - fun pause() { if (_isCasting.value) { castManager?.getCurrentPlayer()?.pause() } else if (exoPlayer.isPlaying) { + saveState() exoPlayer.pause() } } + fun saveState() { + savedPosition = exoPlayer.currentPosition + wasPlayingBeforePause = exoPlayer.isPlaying + } + + fun restoreState() { + if (savedPosition > 0) { + exoPlayer.seekTo(savedPosition) + if (wasPlayingBeforePause) { + if (!explicitlyPaused) { + exoPlayer.play() + } + } + } + } + fun setupCastButton(mediaRouteButton: MediaRouteButton) { castManager?.setupCastButton(mediaRouteButton) } fun setMediaMetadataEnabled(isEnabled: Boolean) { isMediaMetadataEnabled = isEnabled + if (!isEnabled) { + notificationManager.hideNotification() + } else { + notificationManager.showNotificationForPlayer(exoPlayer) + } + } + + fun getMediaMetadataEnabled(): Boolean { + return isMediaMetadataEnabled + } + + fun switchFromPip() { + val eventBroadcaster = VideoPlayerEventBroadcaster + if (eventBroadcaster.getPipActivePlayerId() == uniqueId) { + eventBroadcaster.setPipActivePlayerId(null) + + if (!explicitlyPaused) { + restoreState() + } else { + explicitlyPaused = false + } + } + } + + fun setExplicitlyPaused(paused: Boolean) { + explicitlyPaused = paused + } + + fun isExplicitlyPaused(): Boolean { + return explicitlyPaused } } diff --git a/coreapp/src/androidMain/res/drawable/ic_pause.xml b/coreapp/src/androidMain/res/drawable/ic_pause.xml new file mode 100644 index 00000000..5ada8e1c --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/ic_pause.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/coreapp/src/androidMain/res/drawable/ic_play.xml b/coreapp/src/androidMain/res/drawable/ic_play.xml new file mode 100644 index 00000000..29949b5a --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/ic_play.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt index 7359a8a0..9ebc7cd0 100644 --- a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt +++ b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt @@ -3,6 +3,7 @@ package com.sampleApp.app.presentation.home.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Work import androidx.compose.material3.Text @@ -22,6 +23,7 @@ import com.metacto.core.presentation.components.images.AppImage import com.metacto.core.presentation.components.inputFields.OutlinedOtpInputField import com.metacto.core.presentation.components.inputFields.PickerInputField import com.metacto.core.presentation.components.inputFields.PrimaryTextInputField +import com.metacto.core.presentation.components.videoPlayer.VideoPlayer import com.metacto.core.utils.contacts.rememberContactsCollectorOptionsFactory import com.metacto.core.utils.language.English import com.metacto.core.utils.language.ILanguageManager @@ -29,6 +31,7 @@ import com.metacto.core.utils.language.Language import com.metacto.core.utils.phoneNumber.IPhoneNumberManager import com.sampleApp.app.presentation.home.HomeContract.Event import com.sampleApp.app.presentation.home.HomeContract.State +import com.sampleApp.app.presentation.models.VideoItemInfo import com.sampleApp.app.presentation.test2.test2.Test2Screen import com.sampleApp.app.presentation.theme.AppTheme import com.sampleApp.app.resources.Res @@ -54,6 +57,16 @@ internal fun HomeContent( val languageManager = koinInject() val navManager = koinInject() + val videoInfo = remember { + VideoItemInfo( + url = "https://storage.sardius.media/-KrXWhrxRAYPfu44QPJ0/archives/DAA6A5576Dd5Ee41CBd6B68696F6/media/playlist.m3u8?feedId=27d824FCdF&vttUrl=https%253A%252F%252Fstorage.sardius.media%252F-KrXWhrxRAYPfu44QPJ0%252Farchives%252FDAA6A5576Dd5Ee41CBd6B68696F6%252Fstatic%252F1730996105358-1.vtt", +// url = FileResources.intro_video.getUri(), + title = "Random Video Title", + artist = "Random Artist", + artworkUrl = "https://yurielkaim.com/wp-content/uploads/2016/03/Happiness-Habits-10-Things-Happy-People-Do-Before-Bed-1200x900.jpg" + ) + } + ScreenColumn( isScrollable = true, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -68,6 +81,37 @@ internal fun HomeContent( println("HomeContent -- onScrollDown") }, ) { + + VideoPlayer( +// uniqueId = "profile_video_player", +// videoUrl = Res.file.intro_video.getUri(), +// autoPlay = true, +// scaleToCrop = true, +// enableVoice = false, +// enablePip = false, +// enableMediaMetadata = false, +// autoRepeat = true, +// controlsType = ControlsType.HideControls, +// onVideoLoop = { +// println("Video looped") +// }, +// onVideoEnd = { +// println("Video ended") +// }, + uniqueId = "home_video_player", + videoUrl = videoInfo.url, + videoTitle = videoInfo.title, + videoArtist = videoInfo.artist, + videoArtworkUrl = videoInfo.artworkUrl, + autoPlay = false, + scaleToCrop = false, + enablePip = true, + handleLifecyclePause = false, + controllerShowTimeoutMs = 2000, + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + ) var otp by remember { mutableStateOf("") } OutlinedOtpInputField( pinCount = 6,