diff --git a/JetStreamCompose/.gitignore b/JetStreamCompose/.gitignore index 48f734565..5b88ad68e 100644 --- a/JetStreamCompose/.gitignore +++ b/JetStreamCompose/.gitignore @@ -7,3 +7,4 @@ .cxx /.idea/ local.properties +.kotlin diff --git a/JetStreamCompose/benchmark/build.gradle.kts b/JetStreamCompose/benchmark/build.gradle.kts index 1b8d855fa..c032743d9 100644 --- a/JetStreamCompose/benchmark/build.gradle.kts +++ b/JetStreamCompose/benchmark/build.gradle.kts @@ -70,6 +70,9 @@ dependencies { // Use 1.2.0-alpha03 or above versions for benchmarking TV apps implementation(libs.androidx.benchmark.macro.junit4) implementation(libs.androidx.rules) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) } androidComponents { diff --git a/JetStreamCompose/gradle/libs.versions.toml b/JetStreamCompose/gradle/libs.versions.toml index cc26274b4..8cb5ae4f0 100644 --- a/JetStreamCompose/gradle/libs.versions.toml +++ b/JetStreamCompose/gradle/libs.versions.toml @@ -1,24 +1,23 @@ [versions] -activity-compose = "1.10.0" -android-gradle-plugin = "8.7.3" -android-test-plugin = "8.7.3" +activity-compose = "1.10.1" +android-gradle-plugin = "8.8.2" +android-test-plugin = "8.8.2" androidx-baselineprofile = "1.3.3" benchmark-macro-junit4 = "1.3.3" coil-compose = "2.7.0" -compose-bom = "2025.01.00" +compose-bom = "2025.02.00" tv-material = "1.0.0" core-ktx = "1.15.0" core-splashscreen = "1.0.1" hilt-navigation-compose = "1.2.0" -hilt-android = "2.52" +hilt-android = "2.54" junit = "1.2.1" kotlin-android = "2.1.0" -kotlinx-serialization = "1.6.3" -ksp = "2.0.20-1.0.24" +kotlinx-serialization = "1.8.0" +ksp = "2.1.0-1.0.29" lifecycle-runtime-ktx = "2.8.7" -media3-ui = "1.5.1" -media3-exoplayer = "1.5.1" -navigation-compose = "2.8.5" +media3 = "1.6.0-beta01" +navigation-compose = "2.8.8" profileinstaller = "1.4.1" uiautomator = "2.3.0" rules = "1.6.1" @@ -29,6 +28,7 @@ androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } @@ -37,8 +37,8 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } -androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3-ui" } -androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3-exoplayer" } +androidx-media3-ui = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" } diff --git a/JetStreamCompose/gradle/wrapper/gradle-wrapper.properties b/JetStreamCompose/gradle/wrapper/gradle-wrapper.properties index 171d8761b..5c40527d4 100644 --- a/JetStreamCompose/gradle/wrapper/gradle-wrapper.properties +++ b/JetStreamCompose/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/JetStreamCompose/jetstream/build.gradle.kts b/JetStreamCompose/jetstream/build.gradle.kts index b422d1cb6..9b1a54ca5 100644 --- a/JetStreamCompose/jetstream/build.gradle.kts +++ b/JetStreamCompose/jetstream/build.gradle.kts @@ -65,6 +65,10 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + kotlinOptions { + jvmTarget = "17" + } } dependencies { diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt index ebb072905..dbf408e9b 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt @@ -16,60 +16,45 @@ package com.google.jetstream.presentation.screens.videoPlayer -import android.content.Context import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AutoAwesomeMotion -import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf -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.focus.FocusRequester +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import androidx.media3.ui.PlayerView +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import androidx.media3.ui.compose.modifiers.resizeWithContentScale import com.google.jetstream.data.entities.MovieDetails -import com.google.jetstream.data.util.StringConstants import com.google.jetstream.presentation.common.Error import com.google.jetstream.presentation.common.Loading -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControlsIcon -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMainFrame -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMediaTitle -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMediaTitleType +import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControls import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerOverlay import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.BACK import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.FORWARD import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulseState -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerSeeker import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerState +import com.google.jetstream.presentation.screens.videoPlayer.components.rememberPlayer import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerPulseState import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerState import com.google.jetstream.presentation.utils.handleDPadKeyEvents -import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay object VideoPlayerScreen { @@ -94,9 +79,11 @@ fun VideoPlayerScreen( is VideoPlayerScreenUiState.Loading -> { Loading(modifier = Modifier.fillMaxSize()) } + is VideoPlayerScreenUiState.Error -> { Error(modifier = Modifier.fillMaxSize()) } + is VideoPlayerScreenUiState.Done -> { VideoPlayerScreenContent( movieDetails = s.movieDetails, @@ -109,11 +96,13 @@ fun VideoPlayerScreen( @androidx.annotation.OptIn(UnstableApi::class) @Composable fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Unit) { - val context = LocalContext.current - val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) + val exoPlayer = rememberPlayer(LocalContext.current) + + val videoPlayerState = rememberVideoPlayerState( + exoPlayer = exoPlayer, + hideSeconds = 4, + ) - // TODO: Move to ViewModel for better reuse - val exoPlayer = rememberExoPlayer(context) LaunchedEffect(exoPlayer, movieDetails) { exoPlayer.setMediaItem( MediaItem.Builder() @@ -137,13 +126,12 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un } var contentCurrentPosition by remember { mutableLongStateOf(0L) } - var isPlaying: Boolean by remember { mutableStateOf(exoPlayer.isPlaying) } + // TODO: Update in a more thoughtful manner LaunchedEffect(Unit) { while (true) { delay(300) contentCurrentPosition = exoPlayer.currentPosition - isPlaying = exoPlayer.isPlaying } } @@ -160,140 +148,53 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un ) .focusable() ) { - AndroidView( - factory = { - PlayerView(context).apply { useController = false } - }, - update = { it.player = exoPlayer }, - onRelease = { exoPlayer.release() } + PlayerSurface( + player = exoPlayer, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, + modifier = Modifier.resizeWithContentScale( + contentScale = ContentScale.Fit, + sourceSizeDp = null + ) ) val focusRequester = remember { FocusRequester() } VideoPlayerOverlay( modifier = Modifier.align(Alignment.BottomCenter), focusRequester = focusRequester, - state = videoPlayerState, - isPlaying = isPlaying, + isPlaying = videoPlayerState.isPlaying, + isControlsVisible = videoPlayerState.isControlsVisible, centerButton = { VideoPlayerPulse(pulseState) }, subtitles = { /* TODO Implement subtitles */ }, + showControls = videoPlayerState::showControls, controls = { VideoPlayerControls( - movieDetails, - isPlaying, - contentCurrentPosition, - exoPlayer, - videoPlayerState, - focusRequester + movieDetails = movieDetails, + contentCurrentPosition = contentCurrentPosition, + contentDuration = exoPlayer.duration, + isPlaying = videoPlayerState.isPlaying, + focusRequester = focusRequester, + onShowControls = videoPlayerState::showControls, + onSeek = { exoPlayer.seekTo(exoPlayer.duration.times(it).toLong()) }, + onPlayPauseToggle = videoPlayerState::togglePlayPause ) } ) } } -@Composable -fun VideoPlayerControls( - movieDetails: MovieDetails, - isPlaying: Boolean, - contentCurrentPosition: Long, - exoPlayer: ExoPlayer, - state: VideoPlayerState, - focusRequester: FocusRequester -) { - val onPlayPauseToggle = { shouldPlay: Boolean -> - if (shouldPlay) { - exoPlayer.play() - } else { - exoPlayer.pause() - } - } - - VideoPlayerMainFrame( - mediaTitle = { - VideoPlayerMediaTitle( - title = movieDetails.name, - secondaryText = movieDetails.releaseDate, - tertiaryText = movieDetails.director, - type = VideoPlayerMediaTitleType.DEFAULT - ) - }, - mediaActions = { - Row( - modifier = Modifier.padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - VideoPlayerControlsIcon( - icon = Icons.Default.AutoAwesomeMotion, - state = state, - isPlaying = isPlaying, - contentDescription = StringConstants - .Composable - .VideoPlayerControlPlaylistButton - ) - VideoPlayerControlsIcon( - modifier = Modifier.padding(start = 12.dp), - icon = Icons.Default.ClosedCaption, - state = state, - isPlaying = isPlaying, - contentDescription = StringConstants - .Composable - .VideoPlayerControlClosedCaptionsButton - ) - VideoPlayerControlsIcon( - modifier = Modifier.padding(start = 12.dp), - icon = Icons.Default.Settings, - state = state, - isPlaying = isPlaying, - contentDescription = StringConstants - .Composable - .VideoPlayerControlSettingsButton - ) - } - }, - seeker = { - VideoPlayerSeeker( - focusRequester, - state, - isPlaying, - onPlayPauseToggle, - onSeek = { exoPlayer.seekTo(exoPlayer.duration.times(it).toLong()) }, - contentProgress = contentCurrentPosition.milliseconds, - contentDuration = exoPlayer.duration.milliseconds - ) - }, - more = null - ) -} - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -private fun rememberExoPlayer(context: Context) = remember { - ExoPlayer.Builder(context) - .setSeekForwardIncrementMs(10) - .setSeekBackIncrementMs(10) - .setMediaSourceFactory( - ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) - ) - .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) - .build() - .apply { - playWhenReady = true - repeatMode = Player.REPEAT_MODE_ONE - } -} - private fun Modifier.dPadEvents( exoPlayer: ExoPlayer, videoPlayerState: VideoPlayerState, pulseState: VideoPlayerPulseState ): Modifier = this.handleDPadKeyEvents( onLeft = { - if (!videoPlayerState.controlsVisible) { + if (!videoPlayerState.isControlsVisible) { exoPlayer.seekBack() pulseState.setType(BACK) } }, onRight = { - if (!videoPlayerState.controlsVisible) { + if (!videoPlayerState.isControlsVisible) { exoPlayer.seekForward() pulseState.setType(FORWARD) } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RememberPlayer.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RememberPlayer.kt new file mode 100644 index 000000000..41baf289b --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RememberPlayer.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.videoPlayer.components + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun rememberPlayer(context: Context) = remember { + ExoPlayer.Builder(context) + .setSeekForwardIncrementMs(10) + .setSeekBackIncrementMs(10) + .setMediaSourceFactory( + ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) + ) + .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) + .build() + .apply { + playWhenReady = true + repeatMode = Player.REPEAT_MODE_ONE + } +} diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControls.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControls.kt new file mode 100644 index 000000000..1755a17cc --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControls.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.videoPlayer.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesomeMotion +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.entities.MovieDetails +import com.google.jetstream.data.util.StringConstants +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun VideoPlayerControls( + movieDetails: MovieDetails, + contentCurrentPosition: Long, + contentDuration: Long, + isPlaying: Boolean, + focusRequester: FocusRequester, + onPlayPauseToggle: () -> Unit = {}, + onSeek: (Float) -> Unit = {}, + onShowControls: () -> Unit = {} +) { + VideoPlayerMainFrame( + mediaTitle = { + VideoPlayerMediaTitle( + title = movieDetails.name, + secondaryText = movieDetails.releaseDate, + tertiaryText = movieDetails.director, + type = VideoPlayerMediaTitleType.DEFAULT + ) + }, + mediaActions = { + Row( + modifier = Modifier.padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + VideoPlayerControlsIcon( + icon = Icons.Default.AutoAwesomeMotion, + isPlaying = isPlaying, + contentDescription = + StringConstants.Composable.VideoPlayerControlPlaylistButton, + onShowControls = onShowControls + ) + VideoPlayerControlsIcon( + modifier = Modifier.padding(start = 12.dp), + icon = Icons.Default.ClosedCaption, + isPlaying = isPlaying, + contentDescription = + StringConstants.Composable.VideoPlayerControlClosedCaptionsButton, + onShowControls = onShowControls + ) + VideoPlayerControlsIcon( + modifier = Modifier.padding(start = 12.dp), + icon = Icons.Default.Settings, + isPlaying = isPlaying, + contentDescription = + StringConstants.Composable.VideoPlayerControlSettingsButton, + onShowControls = onShowControls + ) + } + }, + seeker = { + VideoPlayerSeeker( + focusRequester = focusRequester, + isPlaying = isPlaying, + onPlayPauseToggle = onPlayPauseToggle, + onSeek = onSeek, + onShowControls = onShowControls, + contentProgress = contentCurrentPosition.milliseconds, + contentDuration = contentDuration.milliseconds, + ) + }, + more = null + ) +} diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControlsIcon.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControlsIcon.kt index 9939b2ffc..8e0a5b8c7 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControlsIcon.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControlsIcon.kt @@ -37,11 +37,11 @@ import androidx.tv.material3.Surface @Composable fun VideoPlayerControlsIcon( - modifier: Modifier = Modifier, - state: VideoPlayerState, isPlaying: Boolean, icon: ImageVector, + modifier: Modifier = Modifier, contentDescription: String? = null, + onShowControls: () -> Unit = {}, onClick: () -> Unit = {} ) { val interactionSource = remember { MutableInteractionSource() } @@ -49,7 +49,7 @@ fun VideoPlayerControlsIcon( LaunchedEffect(isFocused && isPlaying) { if (isFocused && isPlaying) { - state.showControls() + onShowControls() } } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerIndicator.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerIndicator.kt index 6cdd9a6f8..d64b5f1f4 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerIndicator.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerIndicator.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -43,7 +44,7 @@ import com.google.jetstream.presentation.utils.ifElse fun RowScope.VideoPlayerControllerIndicator( progress: Float, onSeek: (seekProgress: Float) -> Unit, - state: VideoPlayerState + onShowControls: () -> Unit = {}, ) { val interactionSource = remember { MutableInteractionSource() } var isSelected by remember { mutableStateOf(false) } @@ -55,14 +56,10 @@ fun RowScope.VideoPlayerControllerIndicator( val animatedIndicatorHeight by animateDpAsState( targetValue = 4.dp.times((if (isFocused) 2.5f else 1f)) ) - var seekProgress by remember { mutableStateOf(0f) } + var seekProgress by remember { mutableFloatStateOf(0f) } LaunchedEffect(isSelected) { - if (isSelected) { - state.showControls(seconds = Int.MAX_VALUE) - } else { - state.showControls() - } + onShowControls() } val handleSeekEventModifier = Modifier.handleDPadKeyEvents( diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt index e4b164bd7..c7abfba3d 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt @@ -49,31 +49,28 @@ import com.google.jetstream.presentation.theme.JetStreamTheme fun VideoPlayerOverlay( isPlaying: Boolean, modifier: Modifier = Modifier, - state: VideoPlayerState = rememberVideoPlayerState(), + isControlsVisible: Boolean = true, focusRequester: FocusRequester = remember { FocusRequester() }, + showControls: () -> Unit = {}, centerButton: @Composable () -> Unit = {}, subtitles: @Composable () -> Unit = {}, controls: @Composable () -> Unit = {} ) { - LaunchedEffect(state.controlsVisible) { - if (state.controlsVisible) { + LaunchedEffect(isControlsVisible) { + if (isControlsVisible) { focusRequester.requestFocus() } } LaunchedEffect(isPlaying) { - if (!isPlaying) { - state.showControls(seconds = Int.MAX_VALUE) - } else { - state.showControls() - } + showControls() } Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - AnimatedVisibility(state.controlsVisible, Modifier, fadeIn(), fadeOut()) { + AnimatedVisibility(isControlsVisible, Modifier, fadeIn(), fadeOut()) { CinematicBackground(Modifier.fillMaxSize()) } @@ -86,7 +83,7 @@ fun VideoPlayerOverlay( } AnimatedVisibility( - state.controlsVisible, + isControlsVisible, Modifier, slideInVertically { it }, slideOutVertically { it } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt index 976b05fbf..b6140ffb3 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt @@ -31,12 +31,12 @@ import kotlin.time.Duration @Composable fun VideoPlayerSeeker( focusRequester: FocusRequester, - state: VideoPlayerState, isPlaying: Boolean, - onPlayPauseToggle: (Boolean) -> Unit, - onSeek: (Float) -> Unit, contentProgress: Duration, - contentDuration: Duration + contentDuration: Duration, + onPlayPauseToggle: () -> Unit, + onSeek: (Float) -> Unit, + onShowControls: () -> Unit = {}, ) { val contentProgressString = contentProgress.toComponents { h, m, s, _ -> @@ -61,8 +61,7 @@ fun VideoPlayerSeeker( VideoPlayerControlsIcon( modifier = Modifier.focusRequester(focusRequester), icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, - onClick = { onPlayPauseToggle(!isPlaying) }, - state = state, + onClick = onPlayPauseToggle, isPlaying = isPlaying, contentDescription = StringConstants .Composable @@ -72,7 +71,7 @@ fun VideoPlayerSeeker( VideoPlayerControllerIndicator( progress = (contentProgress / contentDuration).toFloat(), onSeek = onSeek, - state = state + onShowControls = onShowControls ) VideoPlayerControllerText(text = contentDurationString) } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerState.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerState.kt index 1dcd80f6a..66787f7b5 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerState.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerState.kt @@ -23,21 +23,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.compose.state.PlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.debounce -class VideoPlayerState internal constructor( +@androidx.annotation.OptIn(UnstableApi::class) +class VideoPlayerState( @IntRange(from = 0) - private val hideSeconds: Int + private val hideSeconds: Int, + val playPauseButtonState: PlayPauseButtonState, ) { - private var _controlsVisible by mutableStateOf(true) - val controlsVisible get() = _controlsVisible + var isControlsVisible by mutableStateOf(true) + private set - fun showControls(seconds: Int = hideSeconds) { - _controlsVisible = true + val isPlaying + get() = !playPauseButtonState.showPlay + + fun togglePlayPause() { + playPauseButtonState.onClick() + } + + fun showControls() { + if (isPlaying) { + updateControlVisibility() + } else { + updateControlVisibility(seconds = Int.MAX_VALUE) + } + } + + private fun updateControlVisibility(seconds: Int = hideSeconds) { + isControlsVisible = true channel.trySend(seconds) } @@ -47,7 +68,7 @@ class VideoPlayerState internal constructor( suspend fun observe() { channel.consumeAsFlow() .debounce { it.toLong() * 1000 } - .collect { _controlsVisible = false } + .collect { isControlsVisible = false } } } @@ -57,7 +78,18 @@ class VideoPlayerState internal constructor( * @return A remembered instance of [VideoPlayerState]. * @param hideSeconds How many seconds should the controls be visible before being hidden. * */ +@androidx.annotation.OptIn(UnstableApi::class) @Composable -fun rememberVideoPlayerState(@IntRange(from = 0) hideSeconds: Int = 2) = - remember { VideoPlayerState(hideSeconds = hideSeconds) } +fun rememberVideoPlayerState( + exoPlayer: ExoPlayer, + @IntRange(from = 0) hideSeconds: Int = 2 +): VideoPlayerState { + val playPauseButtonState = rememberPlayPauseButtonState(exoPlayer) + return remember(playPauseButtonState) { + VideoPlayerState( + hideSeconds = hideSeconds, + playPauseButtonState = playPauseButtonState + ) + } .also { LaunchedEffect(it) { it.observe() } } +}