diff --git a/JetStreamCompose/jetstream/src/main/assets/movies.json b/JetStreamCompose/jetstream/src/main/assets/movies.json index 8f2d6d4ee..c5e49740b 100644 --- a/JetStreamCompose/jetstream/src/main/assets/movies.json +++ b/JetStreamCompose/jetstream/src/main/assets/movies.json @@ -24,7 +24,7 @@ }, { "id": "6f251d94cc5c2", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 3, "rankUpDown": "+23", @@ -70,7 +70,7 @@ }, { "id": "51df9ee9a5c29", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 5, "rankUpDown": "+27", @@ -116,7 +116,7 @@ }, { "id": "040fab3d5e08e", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 7, "rankUpDown": "+57", @@ -162,7 +162,7 @@ }, { "id": "c4278acc58c31", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 9, "rankUpDown": "+29", @@ -208,7 +208,7 @@ }, { "id": "feee7e2119c28", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 11, "rankUpDown": "+36", @@ -254,7 +254,7 @@ }, { "id": "66f35ba9d671d", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 13, "rankUpDown": "+16", @@ -300,7 +300,7 @@ }, { "id": "523d5cdae88f7", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 15, "rankUpDown": "+17", @@ -346,7 +346,7 @@ }, { "id": "c10133062b2aa", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 18, "rankUpDown": "+33", @@ -392,7 +392,7 @@ }, { "id": "af69b8b439cb9", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 20, "rankUpDown": "+41", @@ -438,7 +438,7 @@ }, { "id": "5d428a566a71c", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 22, "rankUpDown": "+28", @@ -461,7 +461,7 @@ }, { "id": "84e74ee74bfc", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 23, "rankUpDown": "+48", @@ -507,7 +507,7 @@ }, { "id": "aa723f7cb6d5d", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 25, "rankUpDown": "+35", @@ -553,7 +553,7 @@ }, { "id": "f1b81e90f812", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 27, "rankUpDown": "+22", @@ -599,7 +599,7 @@ }, { "id": "504431d1aca8", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 29, "rankUpDown": "+39", @@ -645,7 +645,7 @@ }, { "id": "c08e5ae6ecc9f", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 31, "rankUpDown": "+42", @@ -691,7 +691,7 @@ }, { "id": "40353aa9623af", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 33, "rankUpDown": "+51", @@ -714,7 +714,7 @@ }, { "id": "64e067f836ca2", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 34, "rankUpDown": "+31", @@ -760,7 +760,7 @@ }, { "id": "61946ea9ede15", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 36, "rankUpDown": "+21", @@ -829,7 +829,7 @@ }, { "id": "08ef353fd4def", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 39, "rankUpDown": "+55", @@ -875,7 +875,7 @@ }, { "id": "43e5a062e2bfc", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 41, "rankUpDown": "+48", @@ -921,7 +921,7 @@ }, { "id": "73ce574852058", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 43, "rankUpDown": "+19", @@ -967,7 +967,7 @@ }, { "id": "defa276de73e5", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 45, "rankUpDown": "+54", @@ -990,7 +990,7 @@ }, { "id": "d84978bdf9622", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 46, "rankUpDown": "+33", @@ -1036,7 +1036,7 @@ }, { "id": "834ce43565946", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 48, "rankUpDown": "+57", @@ -1082,7 +1082,7 @@ }, { "id": "07c92f3a31737", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 50, "rankUpDown": "+30", @@ -1128,7 +1128,7 @@ }, { "id": "b995170bc926", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 52, "rankUpDown": "+46", @@ -1174,7 +1174,7 @@ }, { "id": "5be58f705ee35", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 54, "rankUpDown": "+54", diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/MovieDetails.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/MovieDetails.kt index 84a9d80af..96028ad5c 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/MovieDetails.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/MovieDetails.kt @@ -36,5 +36,5 @@ data class MovieDetails( val budget: String, val revenue: String, val similarMovies: MovieList, - val reviewsAndRatings: List + val reviewsAndRatings: List, ) diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/repositories/MovieRepositoryImpl.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/repositories/MovieRepositoryImpl.kt index a1f13361a..d817f33c4 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/repositories/MovieRepositoryImpl.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/repositories/MovieRepositoryImpl.kt @@ -80,7 +80,7 @@ class MovieRepositoryImpl @Inject constructor( override suspend fun getMovieDetails(movieId: String): MovieDetails { val movieList = movieDataSource.getMovieList() val movie = movieList.find { it.id == movieId } ?: movieList.first() - val similarMovieList = movieList.shuffled().subList(0, 2) + val similarMovieList = movieList.subList(1, 4) val castList = movieCastDataSource.getMovieCastList() return MovieDetails( @@ -116,7 +116,7 @@ class MovieRepositoryImpl @Inject constructor( reviewCount = DefaultCount, reviewRating = DefaultRating ), - ) + ), ) } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/util/StringConstants.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/util/StringConstants.kt index 70df94c0b..8b844d05d 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/util/StringConstants.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/util/StringConstants.kt @@ -125,5 +125,11 @@ object StringConstants { const val VideoPlayerControlSettingsButton = "Playlist Button" const val VideoPlayerControlPlayPauseButton = "Playlist Button" const val VideoPlayerControlForward = "Fast forward 10 seconds" + const val VideoPlayerControlSkipNextButton = "Skip to the next movie" + const val VideoPlayerControlSkipPreviousButton = "Skip to the previous movie" + const val VideoPlayerControlRepeatAll = "Repeat all movies" + const val VideoPlayerControlRepeatOne = "Repeat movie" + const val VideoPlayerControlRepeatNone = "No repeat" + const val VideoPlayerControlRepeatButton = "Repeat Button" } } 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 dbf408e9b..b9bcc6955 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 @@ -24,9 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf 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 @@ -41,6 +39,7 @@ import androidx.media3.exoplayer.ExoPlayer 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.Movie import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.presentation.common.Error import com.google.jetstream.presentation.common.Loading @@ -55,7 +54,6 @@ import com.google.jetstream.presentation.screens.videoPlayer.components.remember 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 kotlinx.coroutines.delay object VideoPlayerScreen { const val MovieIdBundleKey = "movieId" @@ -96,43 +94,19 @@ fun VideoPlayerScreen( @androidx.annotation.OptIn(UnstableApi::class) @Composable fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Unit) { - val exoPlayer = rememberPlayer(LocalContext.current) + val context = LocalContext.current + val exoPlayer = rememberPlayer(context) val videoPlayerState = rememberVideoPlayerState( - exoPlayer = exoPlayer, hideSeconds = 4, ) LaunchedEffect(exoPlayer, movieDetails) { - exoPlayer.setMediaItem( - MediaItem.Builder() - .setUri(movieDetails.videoUri) - .setSubtitleConfigurations( - if (movieDetails.subtitleUri == null) { - emptyList() - } else { - listOf( - MediaItem.SubtitleConfiguration - .Builder(Uri.parse(movieDetails.subtitleUri)) - .setMimeType("application/vtt") - .setLanguage("en") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - ) - } - ).build() - ) - exoPlayer.prepare() - } - - var contentCurrentPosition by remember { mutableLongStateOf(0L) } - - // TODO: Update in a more thoughtful manner - LaunchedEffect(Unit) { - while (true) { - delay(300) - contentCurrentPosition = exoPlayer.currentPosition + exoPlayer.addMediaItem(movieDetails.intoMediaItem()) + movieDetails.similarMovies.forEach { + exoPlayer.addMediaItem(it.intoMediaItem()) } + exoPlayer.prepare() } BackHandler(onBack = onBackPressed) @@ -161,21 +135,17 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un VideoPlayerOverlay( modifier = Modifier.align(Alignment.BottomCenter), focusRequester = focusRequester, - isPlaying = videoPlayerState.isPlaying, + isPlaying = exoPlayer.isPlaying, isControlsVisible = videoPlayerState.isControlsVisible, centerButton = { VideoPlayerPulse(pulseState) }, subtitles = { /* TODO Implement subtitles */ }, showControls = videoPlayerState::showControls, controls = { VideoPlayerControls( + player = exoPlayer, 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 + onShowControls = { videoPlayerState.showControls(exoPlayer.isPlaying) }, ) } ) @@ -206,3 +176,42 @@ private fun Modifier.dPadEvents( videoPlayerState.showControls() } ) + +private fun MovieDetails.intoMediaItem(): MediaItem { + return MediaItem.Builder() + .setUri(videoUri) + .setSubtitleConfigurations( + if (subtitleUri == null) { + emptyList() + } else { + listOf( + MediaItem.SubtitleConfiguration + .Builder(Uri.parse(subtitleUri)) + .setMimeType("application/vtt") + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build() + ) + } + ).build() +} + +private fun Movie.intoMediaItem(): MediaItem { + return MediaItem.Builder() + .setUri(videoUri) + .setSubtitleConfigurations( + if (subtitleUri == null) { + emptyList() + } else { + listOf( + MediaItem.SubtitleConfiguration + .Builder(Uri.parse(subtitleUri)) + .setMimeType("application/vtt") + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build() + ) + } + ) + .build() +} diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt index 5a79da150..3eb778be6 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt @@ -51,7 +51,7 @@ class VideoPlayerScreenViewModel @Inject constructor( @Immutable sealed class VideoPlayerScreenUiState { - object Loading : VideoPlayerScreenUiState() - object Error : VideoPlayerScreenUiState() + data object Loading : VideoPlayerScreenUiState() + data object Error : VideoPlayerScreenUiState() data class Done(val movieDetails: MovieDetails) : VideoPlayerScreenUiState() } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/NextButton.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/NextButton.kt new file mode 100644 index 000000000..5937a122c --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/NextButton.kt @@ -0,0 +1,47 @@ +/* + * 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.annotation.OptIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.NextButtonState +import androidx.media3.ui.compose.state.rememberNextButtonState +import com.google.jetstream.data.util.StringConstants + +@OptIn(UnstableApi::class) +@Composable +fun NextButton( + player: Player, + modifier: Modifier = Modifier, + state: NextButtonState = rememberNextButtonState(player), + onShowControls: () -> Unit = {}, +) { + VideoPlayerControlsIcon( + icon = Icons.Default.SkipNext, + isPlaying = player.isPlaying, + contentDescription = + StringConstants.Composable.VideoPlayerControlSkipNextButton, + onShowControls = onShowControls, + onClick = state::onClick, + modifier = modifier + ) +} diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/PreviousButton.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/PreviousButton.kt new file mode 100644 index 000000000..546e40289 --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/PreviousButton.kt @@ -0,0 +1,47 @@ +/* + * 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.annotation.OptIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.PreviousButtonState +import androidx.media3.ui.compose.state.rememberPreviousButtonState +import com.google.jetstream.data.util.StringConstants + +@OptIn(UnstableApi::class) +@Composable +fun PreviousButton( + player: Player, + modifier: Modifier = Modifier, + state: PreviousButtonState = rememberPreviousButtonState(player), + onShowControls: () -> Unit = {}, +) { + VideoPlayerControlsIcon( + icon = Icons.Default.SkipPrevious, + isPlaying = player.isPlaying, + contentDescription = + StringConstants.Composable.VideoPlayerControlSkipPreviousButton, + onShowControls = onShowControls, + onClick = state::onClick, + modifier = modifier, + ) +} 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 index 41baf289b..9c5dd0e4b 100644 --- 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 @@ -39,6 +39,6 @@ fun rememberPlayer(context: Context) = remember { .build() .apply { playWhenReady = true - repeatMode = Player.REPEAT_MODE_ONE + repeatMode = Player.REPEAT_MODE_OFF } } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RepeatButton.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RepeatButton.kt new file mode 100644 index 000000000..334573c4d --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/RepeatButton.kt @@ -0,0 +1,68 @@ +/* + * 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.annotation.OptIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.RepeatButtonState +import androidx.media3.ui.compose.state.rememberRepeatButtonState +import androidx.tv.material3.LocalContentColor +import com.google.jetstream.data.util.StringConstants + +@OptIn(UnstableApi::class) +@Composable +fun RepeatButton( + player: Player, + modifier: Modifier = Modifier, + state: RepeatButtonState = rememberRepeatButtonState(player), + contentDescription: String? = StringConstants.Composable.VideoPlayerControlRepeatButton, + onShowControls: () -> Unit, +) { + val repeatMode = state.repeatModeState + val isRepeating = repeatMode != Player.REPEAT_MODE_OFF + val color = LocalContentColor.current + + VideoPlayerControlsIcon( + icon = when (repeatMode) { + Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne + else -> Icons.Default.Repeat + }, + isPlaying = player.isPlaying, + contentDescription = contentDescription, + onShowControls = onShowControls, + onClick = state::onClick, + modifier = modifier.drawBehind { + if (isRepeating) { + val radius = 2.dp.toPx() + drawCircle( + color = color, + radius = radius, + center = Offset((size.width - radius) / 2, size.height - radius * 3) + ) + } + } + ) +} 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 index 1755a17cc..bf3a70370 100644 --- 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 @@ -16,6 +16,7 @@ package com.google.jetstream.presentation.screens.videoPlayer.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -27,21 +28,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp +import androidx.media3.common.Player import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.data.util.StringConstants -import kotlin.time.Duration.Companion.milliseconds @Composable fun VideoPlayerControls( + player: Player, movieDetails: MovieDetails, - contentCurrentPosition: Long, - contentDuration: Long, - isPlaying: Boolean, focusRequester: FocusRequester, - onPlayPauseToggle: () -> Unit = {}, - onSeek: (Float) -> Unit = {}, - onShowControls: () -> Unit = {} + onShowControls: () -> Unit = {}, ) { + val isPlaying = player.isPlaying + VideoPlayerMainFrame( mediaTitle = { VideoPlayerMediaTitle( @@ -54,8 +53,21 @@ fun VideoPlayerControls( mediaActions = { Row( modifier = Modifier.padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { + PreviousButton( + player = player, + onShowControls = onShowControls + ) + NextButton( + player = player, + onShowControls = onShowControls + ) + RepeatButton( + player = player, + onShowControls = onShowControls, + ) VideoPlayerControlsIcon( icon = Icons.Default.AutoAwesomeMotion, isPlaying = isPlaying, @@ -64,7 +76,6 @@ fun VideoPlayerControls( onShowControls = onShowControls ) VideoPlayerControlsIcon( - modifier = Modifier.padding(start = 12.dp), icon = Icons.Default.ClosedCaption, isPlaying = isPlaying, contentDescription = @@ -72,7 +83,6 @@ fun VideoPlayerControls( onShowControls = onShowControls ) VideoPlayerControlsIcon( - modifier = Modifier.padding(start = 12.dp), icon = Icons.Default.Settings, isPlaying = isPlaying, contentDescription = @@ -83,13 +93,9 @@ fun VideoPlayerControls( }, seeker = { VideoPlayerSeeker( + player = player, 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/VideoPlayerSeeker.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt index b6140ffb3..6ab168d80 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 @@ -16,30 +16,56 @@ package com.google.jetstream.presentation.screens.videoPlayer.components +import androidx.annotation.OptIn import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.focus.focusRequester +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.PlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.google.jetstream.data.util.StringConstants -import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +@OptIn(UnstableApi::class) @Composable fun VideoPlayerSeeker( + player: Player, focusRequester: FocusRequester, - isPlaying: Boolean, - contentProgress: Duration, - contentDuration: Duration, - onPlayPauseToggle: () -> Unit, - onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + state: PlayPauseButtonState = rememberPlayPauseButtonState(player), + onSeek: (Float) -> Unit = { + player.seekTo(player.duration.times(it).toLong()) + }, onShowControls: () -> Unit = {}, ) { + val contentDuration = player.contentDuration.milliseconds + + var currentPositionMs by remember(player) { mutableLongStateOf(0L) } + val currentPosition = currentPositionMs.milliseconds + + // TODO: Update in a more thoughtful manner + LaunchedEffect(Unit) { + while (true) { + delay(300) + currentPositionMs = player.currentPosition + } + } + val contentProgressString = - contentProgress.toComponents { h, m, s, _ -> + currentPosition.toComponents { h, m, s, _ -> if (h > 0) { "$h:${m.padStartWith0()}:${s.padStartWith0()}" } else { @@ -56,20 +82,21 @@ fun VideoPlayerSeeker( } Row( + modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { VideoPlayerControlsIcon( modifier = Modifier.focusRequester(focusRequester), - icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, - onClick = onPlayPauseToggle, - isPlaying = isPlaying, + icon = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause, + onClick = state::onClick, + isPlaying = player.isPlaying, contentDescription = StringConstants .Composable .VideoPlayerControlPlayPauseButton ) VideoPlayerControllerText(text = contentProgressString) VideoPlayerControllerIndicator( - progress = (contentProgress / contentDuration).toFloat(), + progress = (currentPosition / contentDuration).toFloat(), onSeek = onSeek, onShowControls = onShowControls ) 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 66787f7b5..d1d773f8c 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 @@ -24,9 +24,6 @@ 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 @@ -37,19 +34,11 @@ import kotlinx.coroutines.flow.debounce class VideoPlayerState( @IntRange(from = 0) private val hideSeconds: Int, - val playPauseButtonState: PlayPauseButtonState, ) { var isControlsVisible by mutableStateOf(true) private set - val isPlaying - get() = !playPauseButtonState.showPlay - - fun togglePlayPause() { - playPauseButtonState.onClick() - } - - fun showControls() { + fun showControls(isPlaying: Boolean = true) { if (isPlaying) { updateControlVisibility() } else { @@ -81,14 +70,11 @@ class VideoPlayerState( @androidx.annotation.OptIn(UnstableApi::class) @Composable fun rememberVideoPlayerState( - exoPlayer: ExoPlayer, @IntRange(from = 0) hideSeconds: Int = 2 ): VideoPlayerState { - val playPauseButtonState = rememberPlayPauseButtonState(exoPlayer) - return remember(playPauseButtonState) { + return remember { VideoPlayerState( hideSeconds = hideSeconds, - playPauseButtonState = playPauseButtonState ) } .also { LaunchedEffect(it) { it.observe() } }