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..6899aab0 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 = "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/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/AndroidManifest.xml b/coreapp/src/androidMain/AndroidManifest.xml index 1ff38829..bbd00363 100644 --- a/coreapp/src/androidMain/AndroidManifest.xml +++ b/coreapp/src/androidMain/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + - + + \ No newline at end of file diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/di/CorePlatformModule.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/di/CorePlatformModule.kt index 73e94d6b..ea6eeb67 100644 --- a/coreapp/src/androidMain/kotlin/com/metacto/core/di/CorePlatformModule.kt +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/di/CorePlatformModule.kt @@ -34,6 +34,7 @@ import com.metacto.strapikmm.repos.AppConfigurationRepository import com.mmk.kmpnotifier.notification.NotifierManager import com.mmk.kmpnotifier.notification.configuration.NotificationPlatformConfiguration import dev.gitlive.firebase.Firebase +import com.metacto.core.presentation.components.videoPlayer.VideoPlayerEventBroadcaster import dev.gitlive.firebase.remoteconfig.remoteConfig import io.michaelrocks.libphonenumber.kotlin.MetadataLoader import io.michaelrocks.libphonenumber.kotlin.metadata.source.AssetsMetadataLoader @@ -73,6 +74,10 @@ actual fun corePlatformModule( EventBroadcaster } + single { + VideoPlayerEventBroadcaster + } + single { Firebase.remoteConfig } 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..5613a268 --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/CastManager.kt @@ -0,0 +1,196 @@ +package com.metacto.core.presentation.components.videoPlayer + +import android.content.Context +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.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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.Executors + +@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 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 sessionManagerListener = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) { + // Session is starting + } + + override fun onSessionStarted(session: CastSession, sessionId: String) { + onCastSessionStarted() + } + + override fun onSessionStartFailed(session: CastSession, error: Int) { + _isCasting.value = false + } + + 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() + } + + override fun onSessionResumeFailed(session: CastSession, error: Int) { + _isCasting.value = false + } + + override fun onSessionSuspended(session: CastSession, reason: Int) { + _isCasting.value = false + } + } + + init { + if (isGooglePlayServicesAvailable()) { + initializeCast() + } else { + _castAvailable.value = false + } + } + + private fun initializeCast() { + val executor = Executors.newSingleThreadExecutor() + CastContext.getSharedInstance(context, executor) + .addOnSuccessListener { ctx -> + castContext = ctx + + // Add listener for cast state changes + ctx.addCastStateListener { state -> + _castAvailable.value = state != CastState.NO_DEVICES_AVAILABLE + } + + // 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 + ctx.sessionManager.addSessionManagerListener( + sessionManagerListener, + CastSession::class.java + ) + + // Check initial cast state + _castAvailable.value = ctx.castState != CastState.NO_DEVICES_AVAILABLE + + // Check if already casting + val castSession = ctx.sessionManager.currentCastSession + if (castSession != null && castSession.isConnected) { + onCastSessionStarted() + } + } + .addOnFailureListener { + _castAvailable.value = false + _isCasting.value = false + } + } + + fun setupCastButton(mediaRouteButton: androidx.mediarouter.app.MediaRouteButton) { + castContext?.let { + CastButtonFactory.setUpMediaRouteButton(context, mediaRouteButton) + } + } + + fun setLocalPlayer(exoPlayer: ExoPlayer) { + localExoPlayer = exoPlayer + // If we're not casting, set the current player to the local player + if (!_isCasting.value) { + currentPlayer = exoPlayer + } + } + + private fun onCastSessionStarted() { + // Save the local player state + localExoPlayer?.let { exoPlayer -> + playbackPosition = exoPlayer.currentPosition + wasPlaying = exoPlayer.isPlaying + currentMediaItem = currentMediaItem ?: exoPlayer.currentMediaItem + + // Pause local playback + exoPlayer.pause() + } + + // Set current player to cast player + currentPlayer = castPlayer + _isCasting.value = true + + // Transfer playback if media item is available + currentMediaItem?.let { mediaItem -> + castPlayer?.setMediaItem(mediaItem, playbackPosition) + castPlayer?.prepare() + if (wasPlaying) { + castPlayer?.play() + } + } + } + + private fun onCastSessionEnded() { + // Set current player back to local player + currentPlayer = localExoPlayer + _isCasting.value = false + + // Resume local playback + localExoPlayer?.let { exoPlayer -> + currentMediaItem?.let { mediaItem -> + exoPlayer.setMediaItem(mediaItem, playbackPosition) + exoPlayer.prepare() + if (wasPlaying) { + exoPlayer.play() + } + } + } + } + + fun getCurrentPlayer(): Player? = currentPlayer + + private fun isGooglePlayServicesAvailable(): Boolean { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } +} \ 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/Events.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt new file mode 100644 index 00000000..c520aa19 --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/Events.kt @@ -0,0 +1,39 @@ +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 class StoppedPip(val playerId: String) : VideoPlayerEvent() + data class StartedPip(val playerId: String) : VideoPlayerEvent() + data class ActivityFinished(val playerId: String) : VideoPlayerEvent() +} + +internal object VideoPlayerEventBroadcaster { + // Changed from private to internal to allow access from VideoPlayerActivity + internal 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) } + } + + @SuppressLint("ComposableNaming") + @Composable + inline fun collectInCompose(crossinline onReceived: (T) -> Unit) { + LaunchedEffect(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/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 fdff867f..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 @@ -1,46 +1,55 @@ package com.metacto.core.presentation.components.videoPlayer -import android.annotation.SuppressLint -import android.content.res.Configuration +import android.util.TypedValue import android.view.SurfaceView -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout +import android.view.View import androidx.annotation.OptIn -import androidx.appcompat.app.AppCompatActivity 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.ClosedCaption +import androidx.compose.material3.Icon +import androidx.compose.material3.Text 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.collectAsState 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.graphics.Color 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 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.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 com.metacto.coreApp.R import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject @@ -76,72 +85,97 @@ actual fun VideoPlayer( onPlayerCreated: ((VideoPlayerController) -> Unit)?, onDurationCaught: ((Duration) -> Unit)?, onVideoLoop: (() -> Unit)?, - onVideoEnd: (() -> Unit)? + 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 playerManager = playerManagers.getOrPut(uniqueId) { VideoPlayerManager(uniqueId) } + val isPlaying = remember { mutableStateOf(playerManager.exoPlayer.isPlaying) } + val isVideoEnded = remember { mutableStateOf(false) } + 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 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 + val playerViewRef = remember { mutableStateOf(null) } + + val isCasting by playerManager.isCasting.collectAsState() + + val subtitleFilePicker = rememberSubtitleFilePicker { language, fileName, content -> + playerManager.addExternalSubtitle(language, fileName, content) + } + + eventBroadcaster.collectInCompose { + if (it.playerId == uniqueId) { + 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 + } } } - // Set fullscreen by default when move to landscape - LaunchedEffect(isLandscape) { - if (isLandscape) { - isFullScreen.value = true + eventBroadcaster.collectInCompose { + if (it.playerId == uniqueId) { + shouldResumePlayback = playerManager.exoPlayer.isPlaying + playerManager.saveState() + + enableRendering = false + playerViewRef.value?.subtitleView?.visibility = View.INVISIBLE + surfaceRecreationTrigger = !surfaceRecreationTrigger } } - // Handle fullscreen changes - LaunchedEffect(isFullScreen.value) { - if (isFullScreen.value) { - activity?.setUnspecifiedOrientation() - } else { - activity?.setPortraitOrientation() + eventBroadcaster.collectInCompose { + if (it.playerId == uniqueId) { + 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 + } } } - // 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 - ) + LaunchedEffect(enablePip) { + playerManager.isPipEnabled = enablePip + } + + val controller = remember(playerManager) { + object : VideoPlayerController { + override fun play() { + playerManager.play() } - } else { - // Dismiss current fullscreen dialog if exists - activity?.dismissFullScreenDialog() + + override fun pause() = playerManager.pause() } } - // Listen for player state changes. - LaunchedEffect(playerManager.exoPlayer) { + LaunchedEffect(key1 = playerManager) { playerManager.exoPlayer.addListener(object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { isPlaying.value = playWhenReady @@ -161,243 +195,82 @@ 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) { + LaunchedEffect( + playerManager, videoUrl, videoTitle, videoArtist, videoArtworkUrl, + autoPlay, scaleToCrop, autoRepeat, enableVoice, enableMediaMetadata, + onVideoLoop, onVideoEnd, controller, onPlayerCreated + ) { playerManager.setMedia( videoUrl = videoUrl, videoTitle = videoTitle, videoArtist = videoArtist, videoArtworkUrl = videoArtworkUrl ) - } - LaunchedEffect(enableMediaMetadata) { + playerManager.setAutoPlay(autoPlay) + playerManager.setScaleToCrop(scaleToCrop) + playerManager.setAutoRepeat(autoRepeat) 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() - } - } - - LaunchedEffect(controller, onPlayerCreated) { - onPlayerCreated?.invoke(controller) - } - - // Video loop configuration - LaunchedEffect(onVideoLoop) { + playerManager.exoPlayer.volume = if (enableVoice) 1f else 0f playerManager.onVideoLoop = onVideoLoop - } - - // Video end configuration - LaunchedEffect(onVideoEnd) { playerManager.onVideoEnd = onVideoEnd + onPlayerCreated?.invoke(controller) } - // 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 - ) - } - - // Pause the player when the composable is disposed or when the lifecycle pauses. - DisposableEffect(Unit) { - onDispose { - if (handleLifecyclePause) { - playerManager.pause() - } - } - } - OnLifecycleEvent( - onPause = { - if (handleLifecyclePause) { - playerManager.pause() - } - } - ) -} - -@SuppressLint("UnsafeOptInUsageError") -@Composable -private fun NormalVideoPlayer( - modifier: Modifier, - playerManager: VideoPlayerManager, - isPlayButtonVisible: Boolean, - icon: DrawableResource, - isPlaying: MutableState, - isVideoEnded: MutableState, - isFullScreen: MutableState, - scaleToCrop: Boolean, - controllerShowTimeoutMs: Int, - controlsType: ControlsType, - customControlsSize: Dp, - customControlsElevation: Dp, - customControlsShape: RoundedCornerShape -) { - Box( - modifier = modifier - ) { - VideoPlayerContent( - playerManager = playerManager, - 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 = { - 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, - onTogglePlay = { - if (isPlaying.value) { - playerManager.pause() - } else { - if (isVideoEnded.value) { - playerManager.exoPlayer.seekTo(0) - isVideoEnded.value = false - } - playerManager.play() - isPlaying.value = true - } - }, - onNativeFullscreenClick = { - isFullScreen.value = isFullScreen.value.not() - } - ) + fun onFullScreen(id: String) { + shouldResumePlayback = playerManager.exoPlayer.isPlaying + enableRendering = false + surfaceRecreationTrigger = !surfaceRecreationTrigger + VideoPlayerActivity.start(context = context, uniqueId = id, enablePip = enablePip) } -} -@OptIn(UnstableApi::class) -@Composable -private fun VideoPlayerContent( - playerManager: VideoPlayerManager, - controlsType: ControlsType, - controllerShowTimeoutMs: Int, - resizeMode: Int, - isPlayButtonVisible: Boolean, - icon: DrawableResource, - customControlsSize: Dp, - customControlsElevation: Dp, - customControlsShape: RoundedCornerShape, - onTogglePlay: () -> Unit, - onNativeFullscreenClick: () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = modifier) { AndroidView( - modifier = Modifier - .fillMaxSize(), - factory = { context -> - PlayerView(context).apply { + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PlayerView(ctx).apply { useController = (controlsType == ControlsType.NativeControls) this.controllerShowTimeoutMs = controllerShowTimeoutMs - this.resizeMode = resizeMode - player = playerManager.exoPlayer - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + 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) - (videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) - } - if (controlsType == ControlsType.NativeControls) { - // Leverage native full screen button. - setFullscreenButtonClickListener { onNativeFullscreenClick() } + setFullscreenButtonClickListener { + onFullScreen(uniqueId) } + }.also { + playerViewRef.value = it } }, - update = { playerView -> - (playerView.videoSurfaceView as? SurfaceView)?.let { - playerManager.exoPlayer.setVideoSurfaceView(it) + 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 } } ) - // For custom controls, overlay a play/pause button. + if (controlsType == ControlsType.CustomControls) { FadeVisibility( - visible = isPlayButtonVisible, + visible = isPlayButtonVisible && enableRendering, duration = CONTROLS_ANIM_DURATION, modifier = Modifier.align(Alignment.Center) ) { @@ -410,9 +283,115 @@ private fun VideoPlayerContent( elevation = customControlsElevation, shape = customControlsShape ) - .noRippleClickable { onTogglePlay() } + .noRippleClickable { + togglePlayback( + isPlaying = isPlaying.value, + isVideoEnded = isVideoEnded, + playerManager = playerManager + ) + } ) } } + + FadeVisibility( + visible = isPlayButtonVisible && enableRendering, + duration = CONTROLS_ANIM_DURATION, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + ) { + Row { + 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), + contentAlignment = Alignment.Center + ) { + AndroidView( + factory = { ctx -> + MediaRouteButton(ctx).apply { + playerManager.setupCastButton(this) + } + }, + modifier = Modifier.size(24.dp) + ) + } + } + } + + if (isCasting) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.7f)) + .padding(8.dp) + ) { + Text( + text = stringResource(R.string.casting_to_device), + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + + DisposableEffect(Unit) { + onDispose { } + } + + OnLifecycleEvent( + onPause = { + if (handleLifecyclePause && enableRendering) { + if (!playerManager.exoPlayer.isPlaying && shouldResumePlayback) { + } else { + shouldResumePlayback = playerManager.exoPlayer.isPlaying + } + playerManager.pause() + } + }, + onResume = { + if (handleLifecyclePause && enableRendering && shouldResumePlayback) { + if (playerManager.exoPlayer.playbackState != Player.STATE_ENDED) { + playerManager.play() + } + shouldResumePlayback = false + } + } + ) +} + +@OptIn(UnstableApi::class) +private fun togglePlayback( + isPlaying: Boolean, + isVideoEnded: MutableState, + playerManager: VideoPlayerManager +) { + if (isPlaying) { + playerManager.pause() + } else { + if (isVideoEnded.value) { + playerManager.exoPlayer.seekTo(0) + isVideoEnded.value = false + } + playerManager.play() } } \ 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..ef4f7e5f --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/videoPlayer/VideoPlayerActivity.kt @@ -0,0 +1,456 @@ +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 +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 +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.lifecycle.repeatOnLifecycle +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) +internal class VideoPlayerActivity : AppCompatActivity(), Player.Listener { + private val eventBroadcaster: VideoPlayerEventBroadcaster by inject() + private var exoPlayer: ExoPlayer? = null + private var isPipSupported = false + private var playerView: PlayerView? = null + private var playerId: String? = null + private var wasPlayingBeforePipEnter: Boolean = false + private val playerManagers by inject>(DiQualifiers.videoPlayerManagers) + + private val pipActionsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val controlType = intent?.getIntExtra(EXTRA_CONTROL_TYPE, 0) + val receivedPlayerId = intent?.getStringExtra(KEY_PLAYER_ID_PIP_ACTION) + + when (controlType) { + CONTROL_TYPE_PLAY_PAUSE -> { + 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) + } + } + } + } + } + } + } + } + + private val subtitlePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val uri = result.data?.data ?: return@registerForActivityResult + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + lifecycleScope.launch { + val subtitleLoader = SubtitleFileLoader(this@VideoPlayerActivity) + val fileLoaderResult = subtitleLoader.loadSubtitleFile(uri) + + if (fileLoaderResult != null) { + val (language, fileName, content) = fileLoaderResult + playerManagers[this@VideoPlayerActivity.playerId]?.addExternalSubtitle(language, fileName, content) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideSystemBars() + + setContentView(R.layout.activity_video_player) + playerView = findViewById(R.id.player_view) + + isPipSupported = + packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + intent?.getBooleanExtra(KEY_ENABLE_PIP, true) == true + + setupViews() + + val newPlayerIdFromIntent = intent?.getStringExtra(KEY_PLAYER_ID) + if (newPlayerIdFromIntent != null) { + this.playerId = newPlayerIdFromIntent + configPlayerView(newPlayerIdFromIntent) + } else { + finish() + return + } + + 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", "application/ttml+xml", "text/x-ssa" + )) + } + subtitlePickerLauncher.launch(intent) + } + + private fun handleBackPress() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + finish() + } + }) + } + + 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() { + val ibPip = findViewById(R.id.ib_pip) + ibPip.setOnClickListener { + enablePip() + } + ibPip.visibility = if (isPipSupported) View.VISIBLE else View.GONE + } + + private fun enablePip() { + if (!isPipSupported || this.playerId == null) return + + val currentPlayerManager = playerManagers[this.playerId!!] + if (currentPlayerManager == null || !currentPlayerManager.isPipEnabled) return + + wasPlayingBeforePipEnter = currentPlayerManager.exoPlayer.isPlaying + currentPlayerManager.saveState() + + updatePipParams()?.let { + enterPictureInPictureMode(it) + } + } + + 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 = 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) + } + + playerView?.let { + val visibleRect = Rect() + it.getGlobalVisibleRect(visibleRect) + if (!visibleRect.isEmpty) { + builder.setSourceRectHint(visibleRect) + } + } + + val params = builder.build() + setPictureInPictureParams(params) + return params + } + + @OptIn(UnstableApi::class) + private fun configPlayerView(targetPlayerId: String) { + val playerManager = playerManagers[targetPlayerId] + if (playerManager == null) { + finish() + return + } + + val newPlayerInstance = playerManager.exoPlayer + + if (this.exoPlayer != null && this.exoPlayer != newPlayerInstance) { + this.exoPlayer?.removeListener(this) + } + + this.exoPlayer = newPlayerInstance + this.playerId = targetPlayerId + + 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) + } + } + } + + this.exoPlayer?.addListener(this) + + playerManager.setVideoSizeListener { _, _ -> + if (isInPictureInPictureMode && eventBroadcaster.getPipActivePlayerId() == this.playerId) { + updatePipParams() + } + } + + val castButton = findViewById(R.id.cast_button) + playerManager.setupCastButton(castButton) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + 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() + } + } + + 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) { + eventBroadcaster.setPipActivePlayerId(currentActivityPlayerId) + + playerView?.useController = false + playerView?.hideController() + pipContainer.visibility = View.GONE + topControlsContainer.visibility = View.GONE + playerView?.subtitleView?.visibility = View.INVISIBLE + + eventBroadcaster.emit(VideoPlayerEvent.StartedPip(currentActivityPlayerId)) + } else { + 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 + + configPlayerView(currentActivityPlayerId) + + if (wasPlayingBeforePipEnter && currentManager?.isExplicitlyPaused() == false && !currentManager.exoPlayer.isPlaying) { + currentManager.exoPlayer.play() + } + wasPlayingBeforePipEnter = false + + eventBroadcaster.emit(VideoPlayerEvent.StoppedPip(currentActivityPlayerId)) + } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + 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) + + val newPlayerIdFromIntent = intent.getStringExtra(KEY_PLAYER_ID) + if (newPlayerIdFromIntent != null) { + if (newPlayerIdFromIntent != oldPlayerId) { + this.playerId = newPlayerIdFromIntent + configPlayerView(newPlayerIdFromIntent) + } else { + configPlayerView(newPlayerIdFromIntent) + } + } + } + + override fun onStart() { + super.onStart() + this.playerId?.let { + configPlayerView(it) + val activePipId = eventBroadcaster.getPipActivePlayerId() + if (it == activePipId && !isInPictureInPictureMode) { + playerManagers[it]?.switchFromPip() + } + } + } + + override fun onResume() { + super.onResume() + this.playerId?.let { + configPlayerView(it) + val activePipId = eventBroadcaster.getPipActivePlayerId() + if (it == activePipId && !isInPictureInPictureMode) { + playerManagers[it]?.switchFromPip() + } + } + } + + override fun onStop() { + super.onStop() + if (!isInPictureInPictureMode && !isChangingConfigurations) { + if (!isFinishing) { + this.playerId?.let { + playerManagers[it]?.saveState() + if (playerManagers[it]?.exoPlayer?.isPlaying == true && playerManagers[it]?.isCasting?.value == false) { + playerManagers[it]?.exoPlayer?.pause() + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(pipActionsReceiver) + this.exoPlayer?.removeListener(this) + playerView?.player = null + + if (!isChangingConfigurations && this.playerId != null && eventBroadcaster.getPipActivePlayerId() == this.playerId) { + eventBroadcaster.setPipActivePlayerId(null) + } + + 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" + 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) + } + context.startActivity(intent) + } + } +} \ 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 afb12ae5..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 @@ -2,18 +2,25 @@ 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 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 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( @@ -24,18 +31,36 @@ internal class VideoPlayerManager( private val playerManagers by inject>(DiQualifiers.videoPlayerManagers) 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!!) + .setPreferredTextLanguage("en") + .build() + } + + 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 - // 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) @@ -46,8 +71,7 @@ internal class VideoPlayerManager( } } - // Define the notification manager - private val notificationManager by lazy { + val notificationManager by lazy { MediaNotificationManager( context = context, sessionToken = mediaSession.token, @@ -56,27 +80,27 @@ 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 + super.onIsPlayingChanged(isPlaying) - // 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() + if (isMediaMetadataEnabled) { + notificationManager.showNotificationForPlayer(exoPlayer) } - // Show the notification for current exo player - 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( @@ -85,7 +109,6 @@ internal class VideoPlayerManager( reason: Int ) { if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - // Video has looped onVideoLoop?.invoke() } } @@ -94,8 +117,29 @@ internal class VideoPlayerManager( if (state == Player.STATE_ENDED) { onVideoEnd?.invoke() } + if (isMediaMetadataEnabled) { + notificationManager.showNotificationForPlayer(exoPlayer) + } + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + videoSizeListener?.invoke(videoSize.width, videoSize.height) + } + + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + updateSubtitleTracks() } }) + + initCastManager() + } + + private fun initCastManager() { + castManager = CastManager(context).apply { + setLocalPlayer(exoPlayer) + _castAvailable.value = castAvailable.value + _isCasting.value = isCasting.value + } } @OptIn(UnstableApi::class) @@ -110,8 +154,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) @@ -121,13 +176,11 @@ internal class VideoPlayerManager( videoArtist: String?, videoArtworkUrl: String? ) { - // If the player is already playing the same video, skip - if (exoPlayer.currentMediaItem?.mediaId == videoUrl) { + if (exoPlayer.currentMediaItem?.mediaId == videoUrl && exoPlayer.playbackState != Player.STATE_IDLE && exoPlayer.playbackState != Player.STATE_ENDED) { return } exoPlayer.apply { - // Create the metadata val mediaMetaData = MediaMetadata.Builder() .setTitle(videoTitle.orEmpty()) .setArtist(videoArtist.orEmpty()) @@ -135,35 +188,226 @@ internal class VideoPlayerManager( .setArtworkUri(videoArtworkUrl?.toUri()) .build() - // Create the media item - val mediaItem = createMediaSource( + val mediaSource = createMediaSource( context = context, url = videoUrl, metaData = mediaMetaData ) - // 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 (exoPlayer.isPlaying.not()) { + val eventBroadcaster = VideoPlayerEventBroadcaster + val activePipPlayerId = eventBroadcaster.getPipActivePlayerId() + + 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.playbackState == Player.STATE_IDLE || exoPlayer.playbackState == Player.STATE_ENDED) { + exoPlayer.prepare() + } exoPlayer.play() } } fun pause() { - if (exoPlayer.isPlaying) { + 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) + } } -} \ No newline at end of file + + 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 + } +} + +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_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_pip.xml b/coreapp/src/androidMain/res/drawable/ic_pip.xml new file mode 100644 index 00000000..bf046d99 --- /dev/null +++ b/coreapp/src/androidMain/res/drawable/ic_pip.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/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..70e09c8d --- /dev/null +++ b/coreapp/src/androidMain/res/layout/activity_video_player.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..70d26b16 100644 --- a/coreapp/src/androidMain/res/values/strings.xml +++ b/coreapp/src/androidMain/res/values/strings.xml @@ -1,4 +1,11 @@ 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/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 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/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, 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