From d49f09a9d6ace5399afb80f65eee1388baaf8357 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Thu, 23 Nov 2023 15:25:44 +0000 Subject: [PATCH 1/8] Improve the player experience, add title. --- JetStreamCompose/gradle/libs.versions.toml | 2 + JetStreamCompose/jetstream/build.gradle.kts | 3 + .../java/com/google/jetstream/MainActivity.kt | 15 +- .../jetstream/presentation/screens/Screens.kt | 3 +- .../screens/dashboard/DashboardScreen.kt | 6 +- .../screens/home/FeaturedMoviesCarousel.kt | 6 +- .../presentation/screens/home/HomeScreen.kt | 4 +- .../screens/videoPlayer/VideoPlayerScreen.kt | 310 +++++++++++++----- .../videoPlayer/VideoPlayerScreenViewModel.kt | 55 ++++ .../components/VideoPlayerControls.kt | 197 ----------- .../components/VideoPlayerMainFrame.kt | 121 +++++++ .../components/VideoPlayerMediaTitle.kt | 130 ++++++++ .../components/VideoPlayerOverlay.kt | 107 ++++++ .../components/VideoPlayerState.kt | 10 +- .../jetstream/src/main/res/values/strings.xml | 2 + 15 files changed, 687 insertions(+), 284 deletions(-) create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt delete mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControls.kt create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt diff --git a/JetStreamCompose/gradle/libs.versions.toml b/JetStreamCompose/gradle/libs.versions.toml index 29fbbcc9..90ec0e11 100644 --- a/JetStreamCompose/gradle/libs.versions.toml +++ b/JetStreamCompose/gradle/libs.versions.toml @@ -21,6 +21,7 @@ media3-exoplayer = "1.1.1" navigation-compose = "2.7.4" profileinstaller = "1.3.1" uiautomator = "2.2.0" +ui-tooling = "1.5.4" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } @@ -47,6 +48,7 @@ coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } diff --git a/JetStreamCompose/jetstream/build.gradle.kts b/JetStreamCompose/jetstream/build.gradle.kts index b49b131e..7ca7034b 100644 --- a/JetStreamCompose/jetstream/build.gradle.kts +++ b/JetStreamCompose/jetstream/build.gradle.kts @@ -123,4 +123,7 @@ dependencies { // Baseline profile installer implementation(libs.androidx.profileinstaller) + + // Compose Previews + debugImplementation(libs.androidx.ui.tooling) } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/MainActivity.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/MainActivity.kt index c0565f77..25acdabf 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/MainActivity.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/MainActivity.kt @@ -122,8 +122,10 @@ private fun MainActivity.App() { Screens.MovieDetails.withArgs(movieId) ) }, - openVideoPlayer = { - navController.navigate(Screens.VideoPlayer()) + openVideoPlayer = { movieId -> + navController.navigate( + Screens.VideoPlayer.withArgs(movieId) + ) }, onBackPressed = onBackPressedDispatcher::onBackPressed, isComingBackFromDifferentScreen = isComingBackFromDifferentScreen, @@ -132,7 +134,14 @@ private fun MainActivity.App() { } ) } - composable(route = Screens.VideoPlayer()) { + composable( + route = Screens.VideoPlayer(), + arguments = listOf( + navArgument(MovieDetailsScreen.MovieIdBundleKey) { + type = NavType.StringType + } + ) + ) { VideoPlayerScreen( onBackPressed = { if (navController.navigateUp()) { diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt index b4c2a20b..8a48495c 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.ui.graphics.vector.ImageVector import com.google.jetstream.presentation.screens.categories.CategoryMovieListScreen import com.google.jetstream.presentation.screens.movies.MovieDetailsScreen +import com.google.jetstream.presentation.screens.videoPlayer.VideoPlayerScreen enum class Screens( private val args: List? = null, @@ -37,7 +38,7 @@ enum class Screens( CategoryMovieList(listOf(CategoryMovieListScreen.CategoryIdBundleKey)), MovieDetails(listOf(MovieDetailsScreen.MovieIdBundleKey)), Dashboard, - VideoPlayer; + VideoPlayer(listOf(VideoPlayerScreen.MovieIdBundleKey)); operator fun invoke(): String { val argList = StringBuilder() diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/dashboard/DashboardScreen.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/dashboard/DashboardScreen.kt index c3360bb4..4a54f157 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/dashboard/DashboardScreen.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/dashboard/DashboardScreen.kt @@ -81,7 +81,7 @@ fun rememberChildPadding(direction: LayoutDirection = LocalLayoutDirection.curre fun DashboardScreen( openCategoryMovieList: (categoryId: String) -> Unit, openMovieDetailsScreen: (movieId: String) -> Unit, - openVideoPlayer: () -> Unit, + openVideoPlayer: (movieId: String) -> Unit, isComingBackFromDifferentScreen: Boolean, resetIsComingBackFromDifferentScreen: () -> Unit, onBackPressed: () -> Unit @@ -203,7 +203,9 @@ fun DashboardScreen( onMovieClick = { selectedMovie -> openMovieDetailsScreen(selectedMovie.id) }, - goToVideoPlayer = openVideoPlayer, + goToVideoPlayer = { selectedMovie -> + openVideoPlayer(selectedMovie.id) + }, onScroll = { isTopBarVisible = it }, isTopBarVisible = isTopBarVisible ) diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/FeaturedMoviesCarousel.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/FeaturedMoviesCarousel.kt index 683e6e44..5fbee957 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/FeaturedMoviesCarousel.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/FeaturedMoviesCarousel.kt @@ -89,7 +89,7 @@ val CarouselSaver = Saver( fun FeaturedMoviesCarousel( movies: List, padding: Padding, - goToVideoPlayer: () -> Unit + goToVideoPlayer: (movie: Movie) -> Unit ) { val carouselHeight = LocalConfiguration.current.screenHeightDp.dp.times(0.60f) val carouselState = rememberSaveable(saver = CarouselSaver) { CarouselState(0) } @@ -114,7 +114,9 @@ fun FeaturedMoviesCarousel( contentDescription = StringConstants.Composable.ContentDescription.MoviesCarousel } - .handleDPadKeyEvents(onEnter = goToVideoPlayer), + .handleDPadKeyEvents(onEnter = { + goToVideoPlayer(movies[carouselState.activeItemIndex]) + }), itemCount = movies.size, carouselState = carouselState, carouselIndicator = { diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt index 9e3769e1..bb1fb217 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt @@ -46,7 +46,7 @@ import com.google.jetstream.presentation.screens.dashboard.rememberChildPadding @Composable fun HomeScreen( onMovieClick: (movie: Movie) -> Unit, - goToVideoPlayer: () -> Unit, + goToVideoPlayer: (movie: Movie) -> Unit, onScroll: (isTopBarVisible: Boolean) -> Unit, isTopBarVisible: Boolean, homeScreeViewModel: HomeScreeViewModel = hiltViewModel(), @@ -81,7 +81,7 @@ private fun Catalog( nowPlayingMovies: MovieList, onMovieClick: (movie: Movie) -> Unit, onScroll: (isTopBarVisible: Boolean) -> Unit, - goToVideoPlayer: () -> Unit, + goToVideoPlayer: (movie: Movie) -> Unit, modifier: Modifier = Modifier, isTopBarVisible: Boolean = true, ) { 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 ad7a148b..1608d876 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 @@ -22,9 +22,18 @@ import android.widget.FrameLayout 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.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.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,8 +41,13 @@ import androidx.compose.runtime.rememberCoroutineScope 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.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 @@ -44,101 +58,251 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControls +import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControllerIndicator +import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControllerText +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.VideoPlayerOverlay import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerState import com.google.jetstream.presentation.utils.handleDPadKeyEvents import kotlinx.coroutines.delay import kotlinx.coroutines.launch +object VideoPlayerScreen { + const val MovieIdBundleKey = "movieId" +} + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @Composable fun VideoPlayerScreen( mediaUri: Uri = Uri.parse(StringConstants.Composable.SampleVideoUrl), onBackPressed: () -> Unit, + videoPlayerScreenViewModel: VideoPlayerScreenViewModel = hiltViewModel() ) { - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var contentCurrentPosition: Long by remember { mutableStateOf(0L) } - val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) - - val exoPlayer = remember { - ExoPlayer.Builder(context) - .build() - .apply { - val defaultDataSourceFactory = DefaultDataSource.Factory(context) - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory( - context, - defaultDataSourceFactory - ) - val source = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(mediaUri)) - - setMediaSource(source) - prepare() + val uiState by videoPlayerScreenViewModel.uiState.collectAsStateWithLifecycle() + + // TODO: Handle Loading & Error states + when (val s = uiState) { + + is VideoPlayerScreenUiState.Loading -> {} + is VideoPlayerScreenUiState.Error -> {} + is VideoPlayerScreenUiState.Done -> { + val movieDetails = s.movieDetails + // TODO: Move more logic into the view model + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + var contentCurrentPosition: Long by remember { mutableStateOf(0L) } + val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) + + val exoPlayer = remember { + ExoPlayer.Builder(context) + .build() + .apply { + val defaultDataSourceFactory = DefaultDataSource.Factory(context) + val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory( + context, + defaultDataSourceFactory + ) + val source = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(mediaUri)) + + setMediaSource(source) + prepare() + } } - } - BackHandler(onBack = onBackPressed) + BackHandler(onBack = onBackPressed) - LaunchedEffect(Unit) { - while (true) { - delay(300) - contentCurrentPosition = exoPlayer.currentPosition - } - } + LaunchedEffect(Unit) { + while (true) { + delay(300) + contentCurrentPosition = exoPlayer.currentPosition + } + } - LaunchedEffect(Unit) { - with(exoPlayer) { - playWhenReady = true - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - repeatMode = Player.REPEAT_MODE_ONE - } - } + LaunchedEffect(Unit) { + with(exoPlayer) { + playWhenReady = true + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + repeatMode = Player.REPEAT_MODE_ONE + } + } - Box { - DisposableEffect( - AndroidView( - modifier = Modifier - .handleDPadKeyEvents( - onEnter = { - if (!videoPlayerState.isDisplayed) { - coroutineScope.launch { - videoPlayerState.showControls() + Box { + DisposableEffect( + AndroidView( + modifier = Modifier + .handleDPadKeyEvents( + onEnter = { + if (!videoPlayerState.isDisplayed) { + coroutineScope.launch { + videoPlayerState.showControls() + } + } } + ) + .focusable(), + factory = { + PlayerView(context).apply { + hideController() + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + + player = exoPlayer + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) } } ) - .focusable(), - factory = { - PlayerView(context).apply { - hideController() - useController = false - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - - player = exoPlayer - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - } + ) { + onDispose { exoPlayer.release() } } - ) - ) { - onDispose { exoPlayer.release() } - } - VideoPlayerControls( - modifier = Modifier.align(Alignment.BottomCenter), - isPlaying = exoPlayer.isPlaying, - onPlayPauseToggle = { shouldPlay -> - if (shouldPlay) { - exoPlayer.play() - } else { - exoPlayer.pause() + + val focusRequester = remember { FocusRequester() } + VideoPlayerOverlay( + modifier = Modifier.align(Alignment.BottomCenter), + focusRequester = focusRequester, + state = videoPlayerState, + isPlaying = exoPlayer.isPlaying + ) { + val onPlayPauseToggle = { shouldPlay: Boolean -> + if (shouldPlay) { + exoPlayer.play() + } else { + exoPlayer.pause() + } + } + val contentProgressInMillis = contentCurrentPosition + val contentDurationInMillis = exoPlayer.duration + val onSeek = { seekProgress: Float -> + exoPlayer.seekTo(exoPlayer.duration.times(seekProgress).toLong()) + } + val isPlaying = exoPlayer.isPlaying + val state = videoPlayerState + + val contentProgress by remember( + contentProgressInMillis, + contentDurationInMillis + ) { + derivedStateOf { + contentProgressInMillis.toFloat() / contentDurationInMillis + } + } + + val contentProgressString by remember(contentProgressInMillis) { + derivedStateOf { + val contentProgressMinutes = (contentProgressInMillis / 1000) / 60 + val contentProgressSeconds = (contentProgressInMillis / 1000) % 60 + val contentProgressMinutesStr = + if (contentProgressMinutes < 10) { + contentProgressMinutes.padStartWith0() + } else { + contentProgressMinutes.toString() + } + val contentProgressSecondsStr = + if (contentProgressSeconds < 10) { + contentProgressSeconds.padStartWith0() + } else { + contentProgressSeconds.toString() + } + "$contentProgressMinutesStr:$contentProgressSecondsStr" + } + } + + val contentDurationString by remember(contentDurationInMillis) { + derivedStateOf { + val contentDurationMinutes = + (contentDurationInMillis / 1000 / 60).coerceAtLeast(minimumValue = 0) + val contentDurationSeconds = + (contentDurationInMillis / 1000 % 60).coerceAtLeast(minimumValue = 0) + val contentDurationMinutesStr = + if (contentDurationMinutes < 10) { + contentDurationMinutes.padStartWith0() + } else { + contentDurationMinutes.toString() + } + val contentDurationSecondsStr = + if (contentDurationSeconds < 10) { + contentDurationSeconds.padStartWith0() + } else { + contentDurationSeconds.toString() + } + "$contentDurationMinutesStr:$contentDurationSecondsStr" + } + } + + VideoPlayerMainFrame( + mediaTitle = { + VideoPlayerMediaTitle( + title = movieDetails.name, + secondaryText = movieDetails.releaseDate, + tertiaryText = movieDetails.director, + isLive = false, + isAd = false + ) + }, + 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 = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + VideoPlayerControlsIcon( + modifier = Modifier.focusRequester(focusRequester), + icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, + onClick = { onPlayPauseToggle(!isPlaying) }, + state = state, + isPlaying = isPlaying, + contentDescription = StringConstants + .Composable + .VideoPlayerControlPlayPauseButton + ) + VideoPlayerControllerText(text = contentProgressString) + VideoPlayerControllerIndicator( + progress = contentProgress, + onSeek = onSeek, + state = state + ) + VideoPlayerControllerText(text = contentDurationString) + } + }, + more = null + ) } - }, - contentProgressInMillis = contentCurrentPosition, - contentDurationInMillis = exoPlayer.duration, - state = videoPlayerState, - onSeek = { seekProgress -> - exoPlayer.seekTo(exoPlayer.duration.times(seekProgress).toLong()) } - ) + } } } + +private fun Long.padStartWith0() = this.toString().padStart(2, '0') 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 new file mode 100644 index 00000000..c575e3ca --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 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 + * + * http://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 + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.jetstream.data.entities.MovieDetails +import com.google.jetstream.data.repositories.MovieRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class VideoPlayerScreenViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + repository: MovieRepository, +) : ViewModel() { + val uiState = savedStateHandle + .getStateFlow(VideoPlayerScreen.MovieIdBundleKey, null) + .map { id -> + if (id == null) { + VideoPlayerScreenUiState.Error + } else { + val details = repository.getMovieDetails(movieId = id) + VideoPlayerScreenUiState.Done(movieDetails = details) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = VideoPlayerScreenUiState.Loading + ) +} + +sealed class VideoPlayerScreenUiState { + object Loading : VideoPlayerScreenUiState() + object Error : VideoPlayerScreenUiState() + data class Done(val movieDetails: MovieDetails) : VideoPlayerScreenUiState() +} \ No newline at end of file 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 deleted file mode 100644 index 90071ef9..00000000 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerControls.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2023 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.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -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.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants - -@Composable -fun VideoPlayerControls( - modifier: Modifier = Modifier, - state: VideoPlayerState = rememberVideoPlayerState(), - isPlaying: Boolean, - onPlayPauseToggle: (Boolean) -> Unit, - onSeek: (seekProgress: Float) -> Unit, - contentProgressInMillis: Long, - contentDurationInMillis: Long -) { - val focusRequester = remember { FocusRequester() } - - val contentProgress by remember(contentProgressInMillis, contentDurationInMillis) { - derivedStateOf { - contentProgressInMillis.toFloat() / contentDurationInMillis - } - } - - val contentProgressString by remember(contentProgressInMillis) { - derivedStateOf { - val contentProgressMinutes = (contentProgressInMillis / 1000) / 60 - val contentProgressSeconds = (contentProgressInMillis / 1000) % 60 - val contentProgressMinutesStr = - if (contentProgressMinutes < 10) { - contentProgressMinutes.padStartWith0() - } else { - contentProgressMinutes.toString() - } - val contentProgressSecondsStr = - if (contentProgressSeconds < 10) { - contentProgressSeconds.padStartWith0() - } else { - contentProgressSeconds.toString() - } - "$contentProgressMinutesStr:$contentProgressSecondsStr" - } - } - - val contentDurationString by remember(contentDurationInMillis) { - derivedStateOf { - val contentDurationMinutes = - (contentDurationInMillis / 1000 / 60).coerceAtLeast(minimumValue = 0) - val contentDurationSeconds = - (contentDurationInMillis / 1000 % 60).coerceAtLeast(minimumValue = 0) - val contentDurationMinutesStr = - if (contentDurationMinutes < 10) { - contentDurationMinutes.padStartWith0() - } else { - contentDurationMinutes.toString() - } - val contentDurationSecondsStr = - if (contentDurationSeconds < 10) { - contentDurationSeconds.padStartWith0() - } else { - contentDurationSeconds.toString() - } - "$contentDurationMinutesStr:$contentDurationSecondsStr" - } - } - - LaunchedEffect(state.isDisplayed) { - if (state.isDisplayed) { - focusRequester.requestFocus() - } - } - - LaunchedEffect(isPlaying) { - if (!isPlaying) { - state.showControls(seconds = Int.MAX_VALUE) - } else { - state.showControls() - } - } - - AnimatedVisibility( - modifier = modifier, - visible = state.isDisplayed, - enter = slideInVertically { it }, - exit = slideOutVertically { it } - ) { - Column( - modifier = Modifier - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black - ) - ) - ) - .padding( - horizontal = 56.dp, - vertical = 32.dp - ) - ) { - 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 - ) - } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - VideoPlayerControlsIcon( - modifier = Modifier.focusRequester(focusRequester), - icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, - onClick = { onPlayPauseToggle(!isPlaying) }, - state = state, - isPlaying = isPlaying, - contentDescription = StringConstants - .Composable - .VideoPlayerControlPlayPauseButton - ) - VideoPlayerControllerText(text = contentProgressString) - VideoPlayerControllerIndicator( - progress = contentProgress, - onSeek = onSeek, - state = state - ) - VideoPlayerControllerText(text = contentDurationString) - } - } - } -} - -private fun Long.padStartWith0() = this.toString().padStart(2, '0') diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt new file mode 100644 index 00000000..c5bf66f9 --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt @@ -0,0 +1,121 @@ +package com.google.jetstream.presentation.screens.videoPlayer.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun VideoPlayerMainFrame( + mediaTitle: @Composable () -> Unit = {}, + mediaActions: @Composable () -> Unit = {}, + seeker: @Composable () -> Unit = {}, + more: (@Composable () -> Unit)? = null +) { + Column(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Box(Modifier.weight(1f)) { mediaTitle() } + mediaActions() + } + Spacer(Modifier.height(16.dp)) + seeker() + if (more != null) { + Spacer(Modifier.height(12.dp)) + Box(Modifier.align(Alignment.CenterHorizontally)) { + more() + } + } + } +} + + +@Preview(device = "id:tv_4k") +@Composable +private fun MediaPlayerMainFramePreviewLayout() { + VideoPlayerMainFrame( + mediaTitle = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(64.dp) + ) + }, + mediaActions = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .size(196.dp, 40.dp) + ) + }, + seeker = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(16.dp) + ) + }, + more = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .size(145.dp, 16.dp) + ) + }, + ) +} + +@Preview(device = "id:tv_4k") +@Composable +private fun MediaPlayerMainFramePreviewLayoutWithoutMore() { + VideoPlayerMainFrame( + mediaTitle = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(64.dp) + ) + }, + mediaActions = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .size(196.dp, 40.dp) + ) + }, + seeker = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(16.dp) + ) + }, + more = null, + ) +} \ No newline at end of file diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt new file mode 100644 index 00000000..5a8caac6 --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt @@ -0,0 +1,130 @@ +package com.google.jetstream.presentation.screens.videoPlayer.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.google.jetstream.R +import com.google.jetstream.presentation.theme.JetStreamTheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerMediaTitle( + title: String, + secondaryText: String, + tertiaryText: String, + isLive: Boolean, + isAd: Boolean, + modifier: Modifier = Modifier +) { + val subTitle = remember { + buildString { + append(secondaryText) + if (secondaryText.isNotEmpty() && tertiaryText.isNotEmpty()) append(" • ") + append(tertiaryText) + } + } + Column(modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(4.dp)) + Row { + // TODO: Replaced with Badge component once developed + if(isAd) { + Text( + text = stringResource(R.string.ad), + style = MaterialTheme.typography.labelLarge, + color = Color.Black, + modifier = Modifier + .background(Color(0xFFFBC02D), shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + .alignByBaseline() + ) + Spacer(Modifier.width(8.dp)) + } else if(isLive) { + Text( + text = stringResource(R.string.live), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.inverseSurface, + modifier = Modifier + .background(Color(0xFFCC0000), shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + .alignByBaseline() + ) + Spacer(Modifier.width(8.dp)) + } + + Text( + text = subTitle, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.alignByBaseline()) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(name = "TV Series", device = "id:tv_4k") +@Composable +private fun VideoPlayerMediaTitlePreviewSeries() { + JetStreamTheme { + Surface(shape = RectangleShape) { + VideoPlayerMediaTitle( + title = "True Detective", + secondaryText = "S1E5", + tertiaryText = "The Secret Fate Of All Life", + isLive = false, + isAd = false + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(name = "Live", device = "id:tv_4k") +@Composable +private fun VideoPlayerMediaTitlePreviewLive() { + JetStreamTheme { + Surface(shape = RectangleShape) { + VideoPlayerMediaTitle( + title = "MacLaren Reveal Their 2022 Car: The MCL36", + secondaryText = "Formula 1", + tertiaryText = "54K watching now", + isLive = true, + isAd = false + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(name = "Ads", device = "id:tv_4k") +@Composable +private fun VideoPlayerMediaTitlePreviewAd() { + JetStreamTheme { + Surface(shape = RectangleShape) { + VideoPlayerMediaTitle( + title = "Samsung Galaxy Note20 | Ultra 5G", + secondaryText = "Get the most powerful Note yet", + tertiaryText = "", + isLive = false, + isAd = true + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..3a71a26e --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerOverlay.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2023 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.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.jetstream.presentation.theme.JetStreamTheme + +/** + * Handles the visibility and animation of the controls. + */ +@Composable +fun VideoPlayerOverlay( + modifier: Modifier = Modifier, + state: VideoPlayerState = rememberVideoPlayerState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + isPlaying: Boolean, + content: @Composable () -> Unit +) { + LaunchedEffect(state.isDisplayed) { + if (state.isDisplayed) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(isPlaying) { + if (!isPlaying) { + state.showControls(seconds = Int.MAX_VALUE) + } else { + state.showControls() + } + } + + AnimatedVisibility( + modifier = modifier, + visible = state.isDisplayed, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + Column( + modifier = Modifier + .focusRequester(focusRequester) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0F), + Color.Black + ) + ) + ) + .padding( + horizontal = 56.dp, + vertical = 32.dp + ) + ) { + content() + } + } +} + +@Preview(device = "id:tv_4k") +@Composable +private fun VideoPlayerOverlayPreview() { + JetStreamTheme { + Box(Modifier.fillMaxSize()) { + VideoPlayerOverlay( + modifier = Modifier.align(Alignment.BottomCenter), + isPlaying = true + ) { + Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Blue)) + } + } + } +} \ No newline at end of file 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 2686b813..7f6fb33f 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 @@ -18,11 +18,12 @@ package com.google.jetstream.presentation.screens.videoPlayer.components import androidx.annotation.IntRange import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import kotlinx.coroutines.MainScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest @@ -35,8 +36,8 @@ class VideoPlayerState internal constructor( var isDisplayed by mutableStateOf(false) private val countDownTimer = MutableStateFlow(value = hideSeconds) - init { - MainScope().launch { + suspend fun observe() = coroutineScope { + launch { countDownTimer.collectLatest { time -> if (time > 0) { isDisplayed = true @@ -56,10 +57,11 @@ class VideoPlayerState internal constructor( /** * Create and remember a [VideoPlayerState] instance. Useful when trying to control the state of - * the [VideoPlayerControls]-related composable. + * the [VideoPlayerOverlay]-related composable. * @return A remembered instance of [VideoPlayerState]. * @param hideSeconds How many seconds should the controls be visible before being hidden. * */ @Composable fun rememberVideoPlayerState(@IntRange(from = 0) hideSeconds: Int = 2) = remember { VideoPlayerState(hideSeconds = hideSeconds) } + .also { LaunchedEffect(it) { it.observe() } } diff --git a/JetStreamCompose/jetstream/src/main/res/values/strings.xml b/JetStreamCompose/jetstream/src/main/res/values/strings.xml index 58b2577a..56849726 100644 --- a/JetStreamCompose/jetstream/src/main/res/values/strings.xml +++ b/JetStreamCompose/jetstream/src/main/res/values/strings.xml @@ -44,4 +44,6 @@ https://www.apache.org/licenses/LICENSE-2.0 Added Last Week Available in 4K Unknown filter + Ad + Live \ No newline at end of file From 6547c441d3b40f2fcb51e4f1efb89686a14f5b1c Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Fri, 24 Nov 2023 08:05:38 +0000 Subject: [PATCH 2/8] [WIP] get subtitles to show up. Respond to up/down presses when controls are not showing. --- .../screens/videoPlayer/VideoPlayerScreen.kt | 42 +++++--- .../components/VideoPlayerOverlay.kt | 97 +++++++++++++------ .../components/VideoPlayerState.kt | 6 +- .../presentation/utils/ModifierUtils.kt | 56 +++++++++++ 4 files changed, 155 insertions(+), 46 deletions(-) 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 1608d876..31f983c9 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 @@ -50,6 +50,7 @@ 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.MediaItem.SubtitleConfiguration import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource @@ -104,8 +105,19 @@ fun VideoPlayerScreen( context, defaultDataSourceFactory ) + + val mediaItem = MediaItem.Builder() + .setUri(mediaUri) + .setSubtitleConfigurations(listOf( + SubtitleConfiguration.Builder(Uri.parse("https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml")) + .setMimeType("application/ttml+xml") + .setLanguage("en") + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build() + )).build() + val source = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(MediaItem.fromUri(mediaUri)) + .createMediaSource(mediaItem) setMediaSource(source) prepare() @@ -129,20 +141,21 @@ fun VideoPlayerScreen( } } - Box { + Box(Modifier + .then(if (!videoPlayerState.controlsVisible) Modifier.handleDPadKeyEvents( + onLeft = { exoPlayer.seekBack() }, + onRight = { exoPlayer.seekForward() }, + onUp = { coroutineScope.launch { videoPlayerState.showControls() } }, + onDown = { coroutineScope.launch { videoPlayerState.showControls() } }, + onEnter = { + exoPlayer.pause() + coroutineScope.launch { videoPlayerState.showControls() } + } + ) else Modifier) + .focusable() + ) { DisposableEffect( AndroidView( - modifier = Modifier - .handleDPadKeyEvents( - onEnter = { - if (!videoPlayerState.isDisplayed) { - coroutineScope.launch { - videoPlayerState.showControls() - } - } - } - ) - .focusable(), factory = { PlayerView(context).apply { hideController() @@ -163,7 +176,8 @@ fun VideoPlayerScreen( modifier = Modifier.align(Alignment.BottomCenter), focusRequester = focusRequester, state = videoPlayerState, - isPlaying = exoPlayer.isPlaying + isPlaying = exoPlayer.isPlaying, + subtitles = { /* TODO Implement subtitles */ } ) { val onPlayPauseToggle = { shouldPlay: Boolean -> if (shouldPlay) { 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 3a71a26e..a384d5a6 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 @@ -17,11 +17,14 @@ package com.google.jetstream.presentation.screens.videoPlayer.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -48,10 +51,11 @@ fun VideoPlayerOverlay( state: VideoPlayerState = rememberVideoPlayerState(), focusRequester: FocusRequester = remember { FocusRequester() }, isPlaying: Boolean, - content: @Composable () -> Unit + subtitles: @Composable () -> Unit, + controls: @Composable () -> Unit ) { - LaunchedEffect(state.isDisplayed) { - if (state.isDisplayed) { + LaunchedEffect(state.controlsVisible) { + if (state.controlsVisible) { focusRequester.requestFocus() } } @@ -64,33 +68,54 @@ fun VideoPlayerOverlay( } } - AnimatedVisibility( - modifier = modifier, - visible = state.isDisplayed, - enter = slideInVertically { it }, - exit = slideOutVertically { it } + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Column( - modifier = Modifier - .focusRequester(focusRequester) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0F), - Color.Black - ) - ) - ) - .padding( - horizontal = 56.dp, - vertical = 32.dp - ) - ) { - content() + AnimatedVisibility(state.controlsVisible, modifier, fadeIn(), fadeOut()) { + CinematicBackground(Modifier.fillMaxSize()) + } + + Column { + Box(Modifier.weight(1f), + contentAlignment = Alignment.BottomCenter) { + subtitles() + } + + AnimatedVisibility( + state.controlsVisible, + modifier, + slideInVertically { it }, + slideOutVertically { it } + ) { + Column( + modifier = Modifier + .focusRequester(focusRequester) + .padding(horizontal = 56.dp) + .padding(bottom = 32.dp, top = 8.dp) + ) { + controls() + } + } } +// CenterButton() } } +@Composable +fun CinematicBackground(modifier: Modifier = Modifier) { + Spacer( + modifier.background( + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.1f), + Color.Black.copy(alpha = 0.8f) + ) + ) + ) + ) +} + @Preview(device = "id:tv_4k") @Composable private fun VideoPlayerOverlayPreview() { @@ -98,10 +123,24 @@ private fun VideoPlayerOverlayPreview() { Box(Modifier.fillMaxSize()) { VideoPlayerOverlay( modifier = Modifier.align(Alignment.BottomCenter), - isPlaying = true - ) { - Box(Modifier.fillMaxWidth().height(100.dp).background(Color.Blue)) - } + isPlaying = true, + subtitles = { + Box( + Modifier + .fillMaxWidth() + .height(100.dp) + .background(Color.Red) + ) + }, + controls = { + Box( + Modifier + .fillMaxWidth() + .height(100.dp) + .background(Color.Blue) + ) + } + ) } } } \ No newline at end of file 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 7f6fb33f..74629999 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 @@ -33,18 +33,18 @@ class VideoPlayerState internal constructor( @IntRange(from = 0) val hideSeconds: Int ) { - var isDisplayed by mutableStateOf(false) + var controlsVisible by mutableStateOf(true) private val countDownTimer = MutableStateFlow(value = hideSeconds) suspend fun observe() = coroutineScope { launch { countDownTimer.collectLatest { time -> if (time > 0) { - isDisplayed = true + controlsVisible = true delay(1000) countDownTimer.emit(countDownTimer.value - 1) } else { - isDisplayed = false + controlsVisible = false } } } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt index 213ec571..7f9e14b2 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt @@ -35,6 +35,10 @@ private val DPadEventsKeyCodes = listOf( KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER @@ -78,6 +82,58 @@ fun Modifier.handleDPadKeyEvents( false } +/** + * Handles all D-Pad Keys and consumes the event(s) so that the focus doesn't + * accidentally move to another element. + * */ +fun Modifier.handleDPadKeyEvents( + onLeft: (() -> Unit)? = null, + onRight: (() -> Unit)? = null, + onUp: (() -> Unit)? = null, + onDown: (() -> Unit)? = null, + onEnter: (() -> Unit)? = null +) = onPreviewKeyEvent { + fun onActionUp(block: () -> Unit) { + if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) block() + } + + if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode)) { + when (it.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { + onLeft?.apply { + onActionUp(::invoke) + return@onPreviewKeyEvent true + } + } + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { + onRight?.apply { + onActionUp(::invoke) + return@onPreviewKeyEvent true + } + } + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> { + onUp?.apply { + onActionUp(::invoke) + return@onPreviewKeyEvent true + } + } + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> { + onDown?.apply { + onActionUp(::invoke) + return@onPreviewKeyEvent true + } + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { + onEnter?.apply { + onActionUp(::invoke) + return@onPreviewKeyEvent true + } + } + } + } + false +} + /** * Fills max available size and only utilizes the content size for the composable. Useful for * cases when you need to quickly center the item on the available area. From f58130960fdd8181118845a703afda50cdd2da51 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Fri, 24 Nov 2023 08:16:36 +0000 Subject: [PATCH 3/8] Fix focus of play/pause button --- .../screens/videoPlayer/components/VideoPlayerOverlay.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 a384d5a6..0436331e 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 @@ -35,7 +35,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview @@ -90,7 +89,7 @@ fun VideoPlayerOverlay( ) { Column( modifier = Modifier - .focusRequester(focusRequester) +// .focusRequester(focusRequester) .padding(horizontal = 56.dp) .padding(bottom = 32.dp, top = 8.dp) ) { From a5abe4cd2438e81b8063a8354ef728cede021341 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Fri, 24 Nov 2023 14:00:22 +0000 Subject: [PATCH 4/8] Clean up the video player code --- JetStreamCompose/gradle/libs.versions.toml | 8 +- .../jetstream/src/main/assets/movies.json | 104 +++++ .../google/jetstream/data/entities/Movie.kt | 4 + .../jetstream/data/entities/MovieDetails.kt | 2 + .../jetstream/data/models/MoviesResponse.kt | 2 + .../data/repositories/MovieRepositoryImpl.kt | 2 + .../jetstream/data/util/StringConstants.kt | 3 +- .../screens/videoPlayer/VideoPlayerScreen.kt | 439 +++++++++--------- .../components/VideoPlayerIndicator.kt | 4 +- .../components/VideoPlayerOverlay.kt | 8 +- .../components/VideoPlayerPulse.kt | 66 +++ .../components/VideoPlayerSeeker.kt | 101 ++++ .../presentation/utils/ModifierUtils.kt | 36 +- 13 files changed, 512 insertions(+), 267 deletions(-) create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt create mode 100644 JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt diff --git a/JetStreamCompose/gradle/libs.versions.toml b/JetStreamCompose/gradle/libs.versions.toml index 90ec0e11..f2a407d6 100644 --- a/JetStreamCompose/gradle/libs.versions.toml +++ b/JetStreamCompose/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] -activity-compose = "1.8.0" +activity-compose = "1.8.1" android-gradle-plugin = "8.1.2" android-test-plugin = "8.1.2" -benchmark-macro-junit4 = "1.2.0-rc02" +benchmark-macro-junit4 = "1.2.1" coil-compose = "2.4.0" -compose-bom = "2023.10.00" +compose-bom = "2023.10.01" compose-for-tv = "1.0.0-alpha10" -compose-ui = "1.6.0-alpha07" +compose-ui = "1.6.0-beta01" core-ktx = "1.12.0" core-splashscreen = "1.0.1" hilt-navigation-compose = "1.0.0" diff --git a/JetStreamCompose/jetstream/src/main/assets/movies.json b/JetStreamCompose/jetstream/src/main/assets/movies.json index ea9ba10c..8c44c3ab 100644 --- a/JetStreamCompose/jetstream/src/main/assets/movies.json +++ b/JetStreamCompose/jetstream/src/main/assets/movies.json @@ -1,6 +1,8 @@ [ { "id": "8daa7d22d13a9", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 1, "rankUpDown": "+40", "title": "On the Bridge", @@ -22,6 +24,8 @@ }, { "id": "6f251d94cc5c2", + "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", "title": "Inventor", @@ -43,6 +47,8 @@ }, { "id": "b168b710fcbea", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 4, "rankUpDown": "+22", "title": "Cybernet", @@ -64,6 +70,8 @@ }, { "id": "51df9ee9a5c29", + "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", "title": "The Good Lawyer", @@ -85,6 +93,8 @@ }, { "id": "ecde1713c9d3b", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 6, "rankUpDown": "+25", "title": "Acts of Love", @@ -106,6 +116,8 @@ }, { "id": "040fab3d5e08e", + "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", "title": "Night in Tokyo", @@ -127,6 +139,8 @@ }, { "id": "814b01214546b", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 8, "rankUpDown": "+58", "title": "Space opera", @@ -148,6 +162,8 @@ }, { "id": "c4278acc58c31", + "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", "title": "Infinite", @@ -169,6 +185,8 @@ }, { "id": "4371b4ae71a42", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 10, "rankUpDown": "+44", "title": "Venture", @@ -190,6 +208,8 @@ }, { "id": "feee7e2119c28", + "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", "title": "The Invisible Man", @@ -211,6 +231,8 @@ }, { "id": "09810df603d1f", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 12, "rankUpDown": "+46", "title": "Meaning of Life", @@ -232,6 +254,8 @@ }, { "id": "66f35ba9d671d", + "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", "title": "Immortal Rider", @@ -253,6 +277,8 @@ }, { "id": "2cf93763fab89", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 14, "rankUpDown": "+26", "title": "Mr Potato", @@ -274,6 +300,8 @@ }, { "id": "523d5cdae88f7", + "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", "title": "Tomato", @@ -295,6 +323,8 @@ }, { "id": "e267a161bb286", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 16, "rankUpDown": "+43", "title": "Idiot", @@ -316,6 +346,8 @@ }, { "id": "c10133062b2aa", + "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", "title": "Monkey Man", @@ -337,6 +369,8 @@ }, { "id": "68ca154f8ec3f", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 19, "rankUpDown": "+28", "title": "Amelia Jones", @@ -358,6 +392,8 @@ }, { "id": "af69b8b439cb9", + "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", "title": "Pink City", @@ -379,6 +415,8 @@ }, { "id": "073a571e5f4a2", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 21, "rankUpDown": "+56", "title": "Him", @@ -400,6 +438,8 @@ }, { "id": "5d428a566a71c", + "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", "title": "Paws", @@ -421,6 +461,8 @@ }, { "id": "84e74ee74bfc", + "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", "title": "Tom and the Tomato", @@ -442,6 +484,8 @@ }, { "id": "4d86c42a30293", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 24, "rankUpDown": "+21", "title": "Luke Strong", @@ -463,6 +507,8 @@ }, { "id": "aa723f7cb6d5d", + "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", "title": "Boxer Kid", @@ -484,6 +530,8 @@ }, { "id": "f06206ea6ae99", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 26, "rankUpDown": "+36", "title": "Yellow", @@ -505,6 +553,8 @@ }, { "id": "f1b81e90f812", + "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", "title": "Don't let Go", @@ -526,6 +576,8 @@ }, { "id": "b1135e52c720d", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 28, "rankUpDown": "+47", "title": "Crossing", @@ -547,6 +599,8 @@ }, { "id": "504431d1aca8", + "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", "title": "Lucky", @@ -568,6 +622,8 @@ }, { "id": "f5aa589b50458", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 30, "rankUpDown": "+20", "title": "Spikey", @@ -589,6 +645,8 @@ }, { "id": "c08e5ae6ecc9f", + "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", "title": "Amanda", @@ -610,6 +668,8 @@ }, { "id": "d94abed9c1e34", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 32, "rankUpDown": "+49", "title": "Wanted", @@ -631,6 +691,8 @@ }, { "id": "40353aa9623af", + "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", "title": "Zaloria", @@ -652,6 +714,8 @@ }, { "id": "64e067f836ca2", + "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", "title": "Lion", @@ -673,6 +737,8 @@ }, { "id": "f801ef0d2032", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 35, "rankUpDown": "+15", "title": "Ape Country", @@ -694,6 +760,8 @@ }, { "id": "61946ea9ede15", + "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", "title": "Jungle", @@ -715,6 +783,8 @@ }, { "id": "78862e025d2fc", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 37, "rankUpDown": "+53", "title": "2502", @@ -736,6 +806,8 @@ }, { "id": "4b93d1a0e0ae3", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 38, "rankUpDown": "+17", "title": "Fish", @@ -757,6 +829,8 @@ }, { "id": "08ef353fd4def", + "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", "title": "The Monkey Family", @@ -778,6 +852,8 @@ }, { "id": "4d3aedaa48b06", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 40, "rankUpDown": "+12", "title": "Babies", @@ -799,6 +875,8 @@ }, { "id": "43e5a062e2bfc", + "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", "title": "Birds", @@ -820,6 +898,8 @@ }, { "id": "bd72e5f8d32a6", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 42, "rankUpDown": "+14", "title": "Desert", @@ -841,6 +921,8 @@ }, { "id": "73ce574852058", + "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", "title": "Africa", @@ -862,6 +944,8 @@ }, { "id": "9cf37611cc5c2", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 44, "rankUpDown": "+50", "title": "Earth", @@ -883,6 +967,8 @@ }, { "id": "defa276de73e5", + "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", "title": "Speed", @@ -904,6 +990,8 @@ }, { "id": "d84978bdf9622", + "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", "title": "Insect World", @@ -925,6 +1013,8 @@ }, { "id": "e00d47b121c31", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 47, "rankUpDown": "+39", "title": "Bear", @@ -946,6 +1036,8 @@ }, { "id": "834ce43565946", + "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", "title": "Bengal Tiger", @@ -967,6 +1059,8 @@ }, { "id": "291ffb81b9e06", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 49, "rankUpDown": "+24", "title": "Zebra", @@ -988,6 +1082,8 @@ }, { "id": "07c92f3a31737", + "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", "title": "Atlantis", @@ -1009,6 +1105,8 @@ }, { "id": "56964e7e8aa4a", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 51, "rankUpDown": "+50", "title": "Antaeaiea", @@ -1030,6 +1128,8 @@ }, { "id": "b995170bc926", + "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", "title": "The Legacy", @@ -1051,6 +1151,8 @@ }, { "id": "0d3929fb4428f", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 53, "rankUpDown": "+44", "title": "The Attack of Ants", @@ -1072,6 +1174,8 @@ }, { "id": "5be58f705ee35", + "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", "title": "Rainbow", diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/Movie.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/Movie.kt index 4c40903d..2c15c937 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/Movie.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/entities/Movie.kt @@ -20,6 +20,8 @@ import com.google.jetstream.data.models.MoviesResponseItem data class Movie( val id: String, + val videoUri: String, + val subtitleUri: String?, val posterUri: String, val name: String, val description: String @@ -32,6 +34,8 @@ fun MoviesResponseItem.toMovie(thumbnailType: ThumbnailType = ThumbnailType.Stan } return Movie( id, + videoUri, + subtitleUri, thumbnail, title, fullTitle 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 f5b9fb92..e6b72f32 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 @@ -18,6 +18,8 @@ package com.google.jetstream.data.entities data class MovieDetails( val id: String, + val videoUri: String, + val subtitleUri: String?, val posterUri: String, val name: String, val description: String, diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/models/MoviesResponse.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/models/MoviesResponse.kt index 415b6392..36a6cbb4 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/models/MoviesResponse.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/data/models/MoviesResponse.kt @@ -21,6 +21,8 @@ import kotlinx.serialization.Serializable @Serializable data class MoviesResponseItem( val id: String, + val videoUri: String, + val subtitleUri: String?, val rank: Int, val rankUpDown: String, val title: String, 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 df809ed6..040a791c 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 @@ -86,6 +86,8 @@ class MovieRepositoryImpl @Inject constructor( return MovieDetails( id = movie.id, + videoUri = movie.videoUri, + subtitleUri = movie.subtitleUri, posterUri = movie.posterUri, name = movie.name, description = movie.description, 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 5592d9e3..19e7c620 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 @@ -124,7 +124,6 @@ object StringConstants { const val VideoPlayerControlClosedCaptionsButton = "Playlist Button" const val VideoPlayerControlSettingsButton = "Playlist Button" const val VideoPlayerControlPlayPauseButton = "Playlist Button" - const val SampleVideoUrl = - "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + const val VideoPlayerControlForward = "Fast forward 10 seconds" } } 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 31f983c9..4cdd602d 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,9 +16,8 @@ package com.google.jetstream.presentation.screens.videoPlayer +import android.content.Context import android.net.Uri -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -27,13 +26,9 @@ 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.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,7 +37,6 @@ 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.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -50,23 +44,27 @@ 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.MediaItem.SubtitleConfiguration import androidx.media3.common.Player -import androidx.media3.datasource.DataSource +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.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControllerIndicator -import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControllerText 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.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.rememberVideoPlayerState import com.google.jetstream.presentation.utils.handleDPadKeyEvents +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -74,10 +72,14 @@ object VideoPlayerScreen { const val MovieIdBundleKey = "movieId" } -@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +/** + * [Work in progress] A composable screen for playing a video. + * + * @param onBackPressed The callback to invoke when the user presses the back button. + * @param videoPlayerScreenViewModel The view model for the video player screen. + */ @Composable fun VideoPlayerScreen( - mediaUri: Uri = Uri.parse(StringConstants.Composable.SampleVideoUrl), onBackPressed: () -> Unit, videoPlayerScreenViewModel: VideoPlayerScreenViewModel = hiltViewModel() ) { @@ -85,238 +87,219 @@ fun VideoPlayerScreen( // TODO: Handle Loading & Error states when (val s = uiState) { - is VideoPlayerScreenUiState.Loading -> {} is VideoPlayerScreenUiState.Error -> {} is VideoPlayerScreenUiState.Done -> { - val movieDetails = s.movieDetails - // TODO: Move more logic into the view model - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var contentCurrentPosition: Long by remember { mutableStateOf(0L) } - val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) + VideoPlayerScreenContent( + movieDetails = s.movieDetails, + onBackPressed = onBackPressed + ) + } + } +} - val exoPlayer = remember { - ExoPlayer.Builder(context) - .build() - .apply { - val defaultDataSourceFactory = DefaultDataSource.Factory(context) - val dataSourceFactory: DataSource.Factory = DefaultDataSource.Factory( - context, - defaultDataSourceFactory - ) +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Unit) { + val coroutineScope = rememberCoroutineScope() - val mediaItem = MediaItem.Builder() - .setUri(mediaUri) - .setSubtitleConfigurations(listOf( - SubtitleConfiguration.Builder(Uri.parse("https://storage.googleapis.com/exoplayer-test-media-1/ttml/netflix_ttml_sample.xml")) - .setMimeType("application/ttml+xml") - .setLanguage("en") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - )).build() + val context = LocalContext.current + val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) - val source = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItem) + val exoPlayer = rememberExoPlayer(context, movieDetails) - setMediaSource(source) - prepare() - } - } + var contentCurrentPosition: Long by remember { mutableStateOf(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 + } + } - BackHandler(onBack = onBackPressed) + BackHandler(onBack = onBackPressed) - LaunchedEffect(Unit) { - while (true) { - delay(300) - contentCurrentPosition = exoPlayer.currentPosition - } - } + val pulseState = remember { VideoPlayerPulseState() } - LaunchedEffect(Unit) { - with(exoPlayer) { - playWhenReady = true - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING - repeatMode = Player.REPEAT_MODE_ONE - } - } + Box( + Modifier + .DPadEvents( + exoPlayer, + coroutineScope, + videoPlayerState, + pulseState + ) + .focusable() + ) { + AndroidView( + factory = { + PlayerView(context).apply { useController = false } + }, + update = { it.player = exoPlayer }, + onRelease = { exoPlayer.release() } + ) - Box(Modifier - .then(if (!videoPlayerState.controlsVisible) Modifier.handleDPadKeyEvents( - onLeft = { exoPlayer.seekBack() }, - onRight = { exoPlayer.seekForward() }, - onUp = { coroutineScope.launch { videoPlayerState.showControls() } }, - onDown = { coroutineScope.launch { videoPlayerState.showControls() } }, - onEnter = { - exoPlayer.pause() - coroutineScope.launch { videoPlayerState.showControls() } - } - ) else Modifier) - .focusable() - ) { - DisposableEffect( - AndroidView( - factory = { - PlayerView(context).apply { - hideController() - useController = false - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + val focusRequester = remember { FocusRequester() } + VideoPlayerOverlay( + modifier = Modifier.align(Alignment.BottomCenter), + focusRequester = focusRequester, + state = videoPlayerState, + isPlaying = isPlaying, + centerButton = { VideoPlayerPulse(pulseState) }, + subtitles = { /* TODO Implement subtitles */ }, + controls = { + VideoPlayerControls( + movieDetails, + isPlaying, + contentCurrentPosition, + exoPlayer, + videoPlayerState, + focusRequester + ) + } + ) + } +} - player = exoPlayer - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - } - } - ) - ) { - onDispose { exoPlayer.release() } - } +@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() + } + } - val focusRequester = remember { FocusRequester() } - VideoPlayerOverlay( - modifier = Modifier.align(Alignment.BottomCenter), - focusRequester = focusRequester, - state = videoPlayerState, - isPlaying = exoPlayer.isPlaying, - subtitles = { /* TODO Implement subtitles */ } - ) { - val onPlayPauseToggle = { shouldPlay: Boolean -> - if (shouldPlay) { - exoPlayer.play() - } else { - exoPlayer.pause() - } - } - val contentProgressInMillis = contentCurrentPosition - val contentDurationInMillis = exoPlayer.duration - val onSeek = { seekProgress: Float -> - exoPlayer.seekTo(exoPlayer.duration.times(seekProgress).toLong()) - } - val isPlaying = exoPlayer.isPlaying - val state = videoPlayerState - val contentProgress by remember( - contentProgressInMillis, - contentDurationInMillis - ) { - derivedStateOf { - contentProgressInMillis.toFloat() / contentDurationInMillis - } - } + VideoPlayerMainFrame( + mediaTitle = { + VideoPlayerMediaTitle( + title = movieDetails.name, + secondaryText = movieDetails.releaseDate, + tertiaryText = movieDetails.director, + isLive = false, + isAd = false + ) + }, + 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()) }, + contentProgressInMillis = contentCurrentPosition, + contentDurationInMillis = exoPlayer.duration, + ) + }, + more = null + ) +} - val contentProgressString by remember(contentProgressInMillis) { - derivedStateOf { - val contentProgressMinutes = (contentProgressInMillis / 1000) / 60 - val contentProgressSeconds = (contentProgressInMillis / 1000) % 60 - val contentProgressMinutesStr = - if (contentProgressMinutes < 10) { - contentProgressMinutes.padStartWith0() - } else { - contentProgressMinutes.toString() - } - val contentProgressSecondsStr = - if (contentProgressSeconds < 10) { - contentProgressSeconds.padStartWith0() - } else { - contentProgressSeconds.toString() - } - "$contentProgressMinutesStr:$contentProgressSecondsStr" - } - } - val contentDurationString by remember(contentDurationInMillis) { - derivedStateOf { - val contentDurationMinutes = - (contentDurationInMillis / 1000 / 60).coerceAtLeast(minimumValue = 0) - val contentDurationSeconds = - (contentDurationInMillis / 1000 % 60).coerceAtLeast(minimumValue = 0) - val contentDurationMinutesStr = - if (contentDurationMinutes < 10) { - contentDurationMinutes.padStartWith0() - } else { - contentDurationMinutes.toString() - } - val contentDurationSecondsStr = - if (contentDurationSeconds < 10) { - contentDurationSeconds.padStartWith0() - } else { - contentDurationSeconds.toString() - } - "$contentDurationMinutesStr:$contentDurationSecondsStr" - } - } +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +private fun rememberExoPlayer( + context: Context, + movieDetails: MovieDetails +) = 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 - VideoPlayerMainFrame( - mediaTitle = { - VideoPlayerMediaTitle( - title = movieDetails.name, - secondaryText = movieDetails.releaseDate, - tertiaryText = movieDetails.director, - isLive = false, - isAd = false + 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() ) - }, - 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 = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - VideoPlayerControlsIcon( - modifier = Modifier.focusRequester(focusRequester), - icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, - onClick = { onPlayPauseToggle(!isPlaying) }, - state = state, - isPlaying = isPlaying, - contentDescription = StringConstants - .Composable - .VideoPlayerControlPlayPauseButton - ) - VideoPlayerControllerText(text = contentProgressString) - VideoPlayerControllerIndicator( - progress = contentProgress, - onSeek = onSeek, - state = state - ) - VideoPlayerControllerText(text = contentDurationString) - } - }, - more = null - ) - } - } + } + ).build() + ) + prepare() } - } } -private fun Long.padStartWith0() = this.toString().padStart(2, '0') +private fun Modifier.DPadEvents( + exoPlayer: ExoPlayer, + coroutineScope: CoroutineScope, + videoPlayerState: VideoPlayerState, + pulseState: VideoPlayerPulseState +): Modifier = this.then( + + Modifier.handleDPadKeyEvents( + onLeft = { + exoPlayer.seekBack() + coroutineScope.launch { pulseState.setType(BACK) } + }, + onRight = { + exoPlayer.seekForward() + coroutineScope.launch { pulseState.setType(FORWARD) } + }, + onUp = { coroutineScope.launch { videoPlayerState.showControls() } }, + onDown = { coroutineScope.launch { videoPlayerState.showControls() } }, + onEnter = { + exoPlayer.pause() + coroutineScope.launch { videoPlayerState.showControls() } + } + ) +) \ No newline at end of file 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 7dd7a818..2fea342e 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 @@ -87,14 +87,14 @@ fun RowScope.VideoPlayerControllerIndicator( }, onLeft = { if (isSelected) { - seekProgress -= 0.1f + seekProgress -= 0.1f.coerceAtLeast(0f) } else { focusManager.moveFocus(FocusDirection.Left) } }, onRight = { if (isSelected) { - seekProgress += 0.1f + seekProgress += 0.1f.coerceAtMost(1f) } else { focusManager.moveFocus(FocusDirection.Right) } 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 0436331e..f2fdc51c 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 @@ -50,8 +50,9 @@ fun VideoPlayerOverlay( state: VideoPlayerState = rememberVideoPlayerState(), focusRequester: FocusRequester = remember { FocusRequester() }, isPlaying: Boolean, - subtitles: @Composable () -> Unit, - controls: @Composable () -> Unit + centerButton: @Composable () -> Unit = {}, + subtitles: @Composable () -> Unit = {}, + controls: @Composable () -> Unit = {} ) { LaunchedEffect(state.controlsVisible) { if (state.controlsVisible) { @@ -89,7 +90,6 @@ fun VideoPlayerOverlay( ) { Column( modifier = Modifier -// .focusRequester(focusRequester) .padding(horizontal = 56.dp) .padding(bottom = 32.dp, top = 8.dp) ) { @@ -97,7 +97,7 @@ fun VideoPlayerOverlay( } } } -// CenterButton() + centerButton() } } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt new file mode 100644 index 00000000..ab4ac746 --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt @@ -0,0 +1,66 @@ +package com.google.jetstream.presentation.screens.videoPlayer.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +object VideoPlayerPulse { + enum class Type { FORWARD, BACK, NONE } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerPulse( + state: VideoPlayerPulseState = remember { VideoPlayerPulseState() } +) { + val icon = when(state.type) { + VideoPlayerPulse.Type.FORWARD -> Icons.Default.Forward10 + VideoPlayerPulse.Type.BACK -> Icons.Default.Replay10 + VideoPlayerPulse.Type.NONE -> null + } + if(icon != null) { + Icon( + icon, + contentDescription = null, + modifier = Modifier + .background(Color.Black.copy(alpha = 0.6f), CircleShape) + .size(88.dp) + .wrapContentSize() + .size(48.dp) + ) + } +} + +class VideoPlayerPulseState { + private var _type = mutableStateOf(VideoPlayerPulse.Type.NONE) + val type: VideoPlayerPulse.Type get() = _type.value + + private var currentTimer: Job? = null + + suspend fun setType(type: VideoPlayerPulse.Type) = coroutineScope { + // Cancel previous delay + currentTimer?.cancel() + currentTimer = launch { + _type.value = type + delay(2.seconds) + _type.value = VideoPlayerPulse.Type.NONE + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..e2600df6 --- /dev/null +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerSeeker.kt @@ -0,0 +1,101 @@ +package com.google.jetstream.presentation.screens.videoPlayer.components + +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.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import com.google.jetstream.data.util.StringConstants + +@Composable +fun VideoPlayerSeeker( + focusRequester: FocusRequester, + state: VideoPlayerState, + isPlaying: Boolean, + onPlayPauseToggle: (Boolean) -> Unit, + onSeek: (Float) -> Unit, + contentProgressInMillis: Long, + contentDurationInMillis: Long +) { + val contentProgress by remember( + contentProgressInMillis, + contentDurationInMillis + ) { + derivedStateOf { + contentProgressInMillis.toFloat() / contentDurationInMillis + } + } + + val contentProgressString by remember(contentProgressInMillis) { + derivedStateOf { + val contentProgressMinutes = (contentProgressInMillis / 1000) / 60 + val contentProgressSeconds = (contentProgressInMillis / 1000) % 60 + val contentProgressMinutesStr = + if (contentProgressMinutes < 10) { + contentProgressMinutes.padStartWith0() + } else { + contentProgressMinutes.toString() + } + val contentProgressSecondsStr = + if (contentProgressSeconds < 10) { + contentProgressSeconds.padStartWith0() + } else { + contentProgressSeconds.toString() + } + "$contentProgressMinutesStr:$contentProgressSecondsStr" + } + } + + val contentDurationString by remember(contentDurationInMillis) { + derivedStateOf { + val contentDurationMinutes = + (contentDurationInMillis / 1000 / 60).coerceAtLeast(minimumValue = 0) + val contentDurationSeconds = + (contentDurationInMillis / 1000 % 60).coerceAtLeast(minimumValue = 0) + val contentDurationMinutesStr = + if (contentDurationMinutes < 10) { + contentDurationMinutes.padStartWith0() + } else { + contentDurationMinutes.toString() + } + val contentDurationSecondsStr = + if (contentDurationSeconds < 10) { + contentDurationSeconds.padStartWith0() + } else { + contentDurationSeconds.toString() + } + "$contentDurationMinutesStr:$contentDurationSecondsStr" + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + VideoPlayerControlsIcon( + modifier = Modifier.focusRequester(focusRequester), + icon = if (!isPlaying) Icons.Default.PlayArrow else Icons.Default.Pause, + onClick = { onPlayPauseToggle(!isPlaying) }, + state = state, + isPlaying = isPlaying, + contentDescription = StringConstants + .Composable + .VideoPlayerControlPlayPauseButton + ) + VideoPlayerControllerText(text = contentProgressString) + VideoPlayerControllerIndicator( + progress = contentProgress, + onSeek = onSeek, + state = state + ) + VideoPlayerControllerText(text = contentDurationString) + } +} + +private fun Long.padStartWith0() = this.toString().padStart(2, '0') diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt index 7f9e14b2..38bb53bc 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.onPlaced @@ -83,8 +84,7 @@ fun Modifier.handleDPadKeyEvents( } /** - * Handles all D-Pad Keys and consumes the event(s) so that the focus doesn't - * accidentally move to another element. + * Handles all D-Pad Keys * */ fun Modifier.handleDPadKeyEvents( onLeft: (() -> Unit)? = null, @@ -92,42 +92,24 @@ fun Modifier.handleDPadKeyEvents( onUp: (() -> Unit)? = null, onDown: (() -> Unit)? = null, onEnter: (() -> Unit)? = null -) = onPreviewKeyEvent { - fun onActionUp(block: () -> Unit) { - if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) block() - } +) = onKeyEvent { - if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode)) { + if (DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode) && it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { when (it.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - onLeft?.apply { - onActionUp(::invoke) - return@onPreviewKeyEvent true - } + onLeft?.invoke().also { return@onKeyEvent true } } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - onRight?.apply { - onActionUp(::invoke) - return@onPreviewKeyEvent true - } + onRight?.invoke().also { return@onKeyEvent true } } KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> { - onUp?.apply { - onActionUp(::invoke) - return@onPreviewKeyEvent true - } + onUp?.invoke().also { return@onKeyEvent true } } KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> { - onDown?.apply { - onActionUp(::invoke) - return@onPreviewKeyEvent true - } + onDown?.invoke().also { return@onKeyEvent true } } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { - onEnter?.apply { - onActionUp(::invoke) - return@onPreviewKeyEvent true - } + onEnter?.invoke().also { return@onKeyEvent true } } } } From fe43db22a0ca8ec0164ac4dc54975a167bc0a11c Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Fri, 24 Nov 2023 14:19:25 +0000 Subject: [PATCH 5/8] Add center button to preview --- .../screens/videoPlayer/components/VideoPlayerOverlay.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 f2fdc51c..d34b04d5 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -138,6 +139,13 @@ private fun VideoPlayerOverlayPreview() { .height(100.dp) .background(Color.Blue) ) + }, + centerButton = { + Box( + Modifier + .size(88.dp) + .background(Color.Green) + ) } ) } From 4c316cbaf75a897cffac5e8cbd0f98c679b921c6 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Mon, 27 Nov 2023 09:52:14 +0000 Subject: [PATCH 6/8] Replace sample video --- .../jetstream/src/main/assets/movies.json | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/JetStreamCompose/jetstream/src/main/assets/movies.json b/JetStreamCompose/jetstream/src/main/assets/movies.json index 8c44c3ab..8f2d6d4e 100644 --- a/JetStreamCompose/jetstream/src/main/assets/movies.json +++ b/JetStreamCompose/jetstream/src/main/assets/movies.json @@ -1,7 +1,7 @@ [ { "id": "8daa7d22d13a9", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 1, "rankUpDown": "+40", @@ -24,7 +24,7 @@ }, { "id": "6f251d94cc5c2", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 3, "rankUpDown": "+23", @@ -47,7 +47,7 @@ }, { "id": "b168b710fcbea", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 4, "rankUpDown": "+22", @@ -70,7 +70,7 @@ }, { "id": "51df9ee9a5c29", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 5, "rankUpDown": "+27", @@ -93,7 +93,7 @@ }, { "id": "ecde1713c9d3b", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 6, "rankUpDown": "+25", @@ -116,7 +116,7 @@ }, { "id": "040fab3d5e08e", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 7, "rankUpDown": "+57", @@ -139,7 +139,7 @@ }, { "id": "814b01214546b", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 8, "rankUpDown": "+58", @@ -162,7 +162,7 @@ }, { "id": "c4278acc58c31", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 9, "rankUpDown": "+29", @@ -185,7 +185,7 @@ }, { "id": "4371b4ae71a42", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 10, "rankUpDown": "+44", @@ -208,7 +208,7 @@ }, { "id": "feee7e2119c28", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 11, "rankUpDown": "+36", @@ -231,7 +231,7 @@ }, { "id": "09810df603d1f", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 12, "rankUpDown": "+46", @@ -254,7 +254,7 @@ }, { "id": "66f35ba9d671d", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 13, "rankUpDown": "+16", @@ -277,7 +277,7 @@ }, { "id": "2cf93763fab89", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 14, "rankUpDown": "+26", @@ -300,7 +300,7 @@ }, { "id": "523d5cdae88f7", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 15, "rankUpDown": "+17", @@ -323,7 +323,7 @@ }, { "id": "e267a161bb286", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 16, "rankUpDown": "+43", @@ -346,7 +346,7 @@ }, { "id": "c10133062b2aa", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 18, "rankUpDown": "+33", @@ -369,7 +369,7 @@ }, { "id": "68ca154f8ec3f", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 19, "rankUpDown": "+28", @@ -392,7 +392,7 @@ }, { "id": "af69b8b439cb9", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 20, "rankUpDown": "+41", @@ -415,7 +415,7 @@ }, { "id": "073a571e5f4a2", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 21, "rankUpDown": "+56", @@ -438,7 +438,7 @@ }, { "id": "5d428a566a71c", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.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/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 23, "rankUpDown": "+48", @@ -484,7 +484,7 @@ }, { "id": "4d86c42a30293", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 24, "rankUpDown": "+21", @@ -507,7 +507,7 @@ }, { "id": "aa723f7cb6d5d", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 25, "rankUpDown": "+35", @@ -530,7 +530,7 @@ }, { "id": "f06206ea6ae99", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 26, "rankUpDown": "+36", @@ -553,7 +553,7 @@ }, { "id": "f1b81e90f812", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 27, "rankUpDown": "+22", @@ -576,7 +576,7 @@ }, { "id": "b1135e52c720d", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 28, "rankUpDown": "+47", @@ -599,7 +599,7 @@ }, { "id": "504431d1aca8", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 29, "rankUpDown": "+39", @@ -622,7 +622,7 @@ }, { "id": "f5aa589b50458", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 30, "rankUpDown": "+20", @@ -645,7 +645,7 @@ }, { "id": "c08e5ae6ecc9f", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 31, "rankUpDown": "+42", @@ -668,7 +668,7 @@ }, { "id": "d94abed9c1e34", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 32, "rankUpDown": "+49", @@ -691,7 +691,7 @@ }, { "id": "40353aa9623af", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.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/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 34, "rankUpDown": "+31", @@ -737,7 +737,7 @@ }, { "id": "f801ef0d2032", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 35, "rankUpDown": "+15", @@ -760,7 +760,7 @@ }, { "id": "61946ea9ede15", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 36, "rankUpDown": "+21", @@ -783,7 +783,7 @@ }, { "id": "78862e025d2fc", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 37, "rankUpDown": "+53", @@ -806,7 +806,7 @@ }, { "id": "4b93d1a0e0ae3", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 38, "rankUpDown": "+17", @@ -829,7 +829,7 @@ }, { "id": "08ef353fd4def", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 39, "rankUpDown": "+55", @@ -852,7 +852,7 @@ }, { "id": "4d3aedaa48b06", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 40, "rankUpDown": "+12", @@ -875,7 +875,7 @@ }, { "id": "43e5a062e2bfc", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 41, "rankUpDown": "+48", @@ -898,7 +898,7 @@ }, { "id": "bd72e5f8d32a6", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 42, "rankUpDown": "+14", @@ -921,7 +921,7 @@ }, { "id": "73ce574852058", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 43, "rankUpDown": "+19", @@ -944,7 +944,7 @@ }, { "id": "9cf37611cc5c2", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 44, "rankUpDown": "+50", @@ -967,7 +967,7 @@ }, { "id": "defa276de73e5", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.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/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 46, "rankUpDown": "+33", @@ -1013,7 +1013,7 @@ }, { "id": "e00d47b121c31", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 47, "rankUpDown": "+39", @@ -1036,7 +1036,7 @@ }, { "id": "834ce43565946", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 48, "rankUpDown": "+57", @@ -1059,7 +1059,7 @@ }, { "id": "291ffb81b9e06", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 49, "rankUpDown": "+24", @@ -1082,7 +1082,7 @@ }, { "id": "07c92f3a31737", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 50, "rankUpDown": "+30", @@ -1105,7 +1105,7 @@ }, { "id": "56964e7e8aa4a", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 51, "rankUpDown": "+50", @@ -1128,7 +1128,7 @@ }, { "id": "b995170bc926", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 52, "rankUpDown": "+46", @@ -1151,7 +1151,7 @@ }, { "id": "0d3929fb4428f", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 53, "rankUpDown": "+44", @@ -1174,7 +1174,7 @@ }, { "id": "5be58f705ee35", - "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "videoUri": "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "subtitleUri": "https://thepaciellogroup.github.io/AT-browser-tests/video/subtitles-en.vtt", "rank": 54, "rankUpDown": "+54", From e571062f69cde6a5061c0f4dff1e92d1c4b87d87 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 5 Dec 2023 11:45:55 +0000 Subject: [PATCH 7/8] Address review comments --- .../screens/videoPlayer/VideoPlayerScreen.kt | 88 +++++++++---------- .../videoPlayer/VideoPlayerScreenViewModel.kt | 2 + .../components/VideoPlayerIndicator.kt | 4 +- .../components/VideoPlayerMainFrame.kt | 4 +- .../components/VideoPlayerMediaTitle.kt | 81 +++++++++-------- .../components/VideoPlayerOverlay.kt | 6 +- .../components/VideoPlayerPulse.kt | 50 ++++++----- .../components/VideoPlayerSeeker.kt | 72 ++++----------- .../components/VideoPlayerState.kt | 38 ++++---- 9 files changed, 155 insertions(+), 190 deletions(-) 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 4cdd602d..0bffcc15 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 @@ -30,9 +30,9 @@ 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,6 +55,7 @@ import com.google.jetstream.data.util.StringConstants 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.VideoPlayerOverlay import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.BACK @@ -62,11 +63,11 @@ import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPla 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.rememberVideoPlayerPulseState import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerState import com.google.jetstream.presentation.utils.handleDPadKeyEvents -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds object VideoPlayerScreen { const val MovieIdBundleKey = "movieId" @@ -101,14 +102,33 @@ fun VideoPlayerScreen( @androidx.annotation.OptIn(UnstableApi::class) @Composable fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Unit) { - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4) - val exoPlayer = rememberExoPlayer(context, movieDetails) + // TODO: Move to ViewModel for better reuse + val exoPlayer = rememberExoPlayer(context) + 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: Long by remember { mutableStateOf(0L) } + var contentCurrentPosition by remember { mutableLongStateOf(0L) } var isPlaying: Boolean by remember { mutableStateOf(exoPlayer.isPlaying) } // TODO: Update in a more thoughtful manner LaunchedEffect(Unit) { @@ -121,13 +141,12 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un BackHandler(onBack = onBackPressed) - val pulseState = remember { VideoPlayerPulseState() } + val pulseState = rememberVideoPlayerPulseState() Box( Modifier - .DPadEvents( + .dPadEvents( exoPlayer, - coroutineScope, videoPlayerState, pulseState ) @@ -187,8 +206,7 @@ fun VideoPlayerControls( title = movieDetails.name, secondaryText = movieDetails.releaseDate, tertiaryText = movieDetails.director, - isLive = false, - isAd = false + type = VideoPlayerMediaTitleType.DEFAULT ) }, mediaActions = { @@ -231,8 +249,8 @@ fun VideoPlayerControls( isPlaying, onPlayPauseToggle, onSeek = { exoPlayer.seekTo(exoPlayer.duration.times(it).toLong()) }, - contentProgressInMillis = contentCurrentPosition, - contentDurationInMillis = exoPlayer.duration, + contentProgress = contentCurrentPosition.milliseconds, + contentDuration = exoPlayer.duration.milliseconds ) }, more = null @@ -242,10 +260,7 @@ fun VideoPlayerControls( @androidx.annotation.OptIn(UnstableApi::class) @Composable -private fun rememberExoPlayer( - context: Context, - movieDetails: MovieDetails -) = remember { +private fun rememberExoPlayer(context: Context) = remember { ExoPlayer.Builder(context) .setSeekForwardIncrementMs(10) .setSeekBackIncrementMs(10) @@ -257,49 +272,26 @@ private fun rememberExoPlayer( .apply { playWhenReady = true repeatMode = Player.REPEAT_MODE_ONE - - 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() - ) - prepare() } } -private fun Modifier.DPadEvents( +private fun Modifier.dPadEvents( exoPlayer: ExoPlayer, - coroutineScope: CoroutineScope, videoPlayerState: VideoPlayerState, pulseState: VideoPlayerPulseState -): Modifier = this.then( - - Modifier.handleDPadKeyEvents( +): Modifier = this.handleDPadKeyEvents( onLeft = { exoPlayer.seekBack() - coroutineScope.launch { pulseState.setType(BACK) } + pulseState.setType(BACK) }, onRight = { exoPlayer.seekForward() - coroutineScope.launch { pulseState.setType(FORWARD) } + pulseState.setType(FORWARD) }, - onUp = { coroutineScope.launch { videoPlayerState.showControls() } }, - onDown = { coroutineScope.launch { videoPlayerState.showControls() } }, + onUp = { videoPlayerState.showControls() }, + onDown = { videoPlayerState.showControls() }, onEnter = { exoPlayer.pause() - coroutineScope.launch { videoPlayerState.showControls() } + videoPlayerState.showControls() } - ) ) \ No newline at end of file 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 c575e3ca..86bd0c49 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 @@ -16,6 +16,7 @@ package com.google.jetstream.presentation.screens.videoPlayer +import androidx.compose.runtime.Immutable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -48,6 +49,7 @@ class VideoPlayerScreenViewModel @Inject constructor( ) } +@Immutable sealed class VideoPlayerScreenUiState { object Loading : VideoPlayerScreenUiState() object Error : VideoPlayerScreenUiState() 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 2fea342e..8fc8393b 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 @@ -87,14 +87,14 @@ fun RowScope.VideoPlayerControllerIndicator( }, onLeft = { if (isSelected) { - seekProgress -= 0.1f.coerceAtLeast(0f) + seekProgress = (seekProgress - 0.1f).coerceAtLeast(0f) } else { focusManager.moveFocus(FocusDirection.Left) } }, onRight = { if (isSelected) { - seekProgress += 0.1f.coerceAtMost(1f) + seekProgress = (seekProgress + 0.1f).coerceAtMost(1f) } else { focusManager.moveFocus(FocusDirection.Right) } diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt index c5bf66f9..f2d60611 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMainFrame.kt @@ -19,9 +19,9 @@ import androidx.compose.ui.unit.dp @Composable fun VideoPlayerMainFrame( - mediaTitle: @Composable () -> Unit = {}, + mediaTitle: @Composable () -> Unit, + seeker: @Composable () -> Unit, mediaActions: @Composable () -> Unit = {}, - seeker: @Composable () -> Unit = {}, more: (@Composable () -> Unit)? = null ) { Column(Modifier.fillMaxWidth()) { diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt index 5a8caac6..9de5c8a8 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerMediaTitle.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -24,56 +23,63 @@ import androidx.tv.material3.Text import com.google.jetstream.R import com.google.jetstream.presentation.theme.JetStreamTheme +enum class VideoPlayerMediaTitleType { AD, LIVE, DEFAULT } + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun VideoPlayerMediaTitle( title: String, secondaryText: String, tertiaryText: String, - isLive: Boolean, - isAd: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + type: VideoPlayerMediaTitleType = VideoPlayerMediaTitleType.DEFAULT ) { - val subTitle = remember { - buildString { - append(secondaryText) - if (secondaryText.isNotEmpty() && tertiaryText.isNotEmpty()) append(" • ") - append(tertiaryText) - } + val subTitle = buildString { + append(secondaryText) + if (secondaryText.isNotEmpty() && tertiaryText.isNotEmpty()) append(" • ") + append(tertiaryText) } Column(modifier.fillMaxWidth()) { Text(title, style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(4.dp)) Row { // TODO: Replaced with Badge component once developed - if(isAd) { - Text( - text = stringResource(R.string.ad), - style = MaterialTheme.typography.labelLarge, - color = Color.Black, - modifier = Modifier - .background(Color(0xFFFBC02D), shape = RoundedCornerShape(12.dp)) - .padding(horizontal = 8.dp, vertical = 2.dp) - .alignByBaseline() - ) - Spacer(Modifier.width(8.dp)) - } else if(isLive) { - Text( - text = stringResource(R.string.live), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.inverseSurface, - modifier = Modifier - .background(Color(0xFFCC0000), shape = RoundedCornerShape(12.dp)) - .padding(horizontal = 8.dp, vertical = 2.dp) - .alignByBaseline() - ) - Spacer(Modifier.width(8.dp)) + when (type) { + VideoPlayerMediaTitleType.AD -> { + Text( + text = stringResource(R.string.ad), + style = MaterialTheme.typography.labelLarge, + color = Color.Black, + modifier = Modifier + .background(Color(0xFFFBC02D), shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + .alignByBaseline() + ) + Spacer(Modifier.width(8.dp)) + } + + VideoPlayerMediaTitleType.LIVE -> { + Text( + text = stringResource(R.string.live), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.inverseSurface, + modifier = Modifier + .background(Color(0xFFCC0000), shape = RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + .alignByBaseline() + ) + + Spacer(Modifier.width(8.dp)) + } + + VideoPlayerMediaTitleType.DEFAULT -> {} } Text( text = subTitle, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.alignByBaseline()) + modifier = Modifier.alignByBaseline() + ) } } } @@ -88,8 +94,7 @@ private fun VideoPlayerMediaTitlePreviewSeries() { title = "True Detective", secondaryText = "S1E5", tertiaryText = "The Secret Fate Of All Life", - isLive = false, - isAd = false + type = VideoPlayerMediaTitleType.DEFAULT ) } } @@ -105,8 +110,7 @@ private fun VideoPlayerMediaTitlePreviewLive() { title = "MacLaren Reveal Their 2022 Car: The MCL36", secondaryText = "Formula 1", tertiaryText = "54K watching now", - isLive = true, - isAd = false + type = VideoPlayerMediaTitleType.LIVE ) } } @@ -122,8 +126,7 @@ private fun VideoPlayerMediaTitlePreviewAd() { title = "Samsung Galaxy Note20 | Ultra 5G", secondaryText = "Get the most powerful Note yet", tertiaryText = "", - isLive = false, - isAd = true + type = VideoPlayerMediaTitleType.AD ) } } 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 d34b04d5..2c2e4c2d 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 @@ -47,10 +47,10 @@ import com.google.jetstream.presentation.theme.JetStreamTheme */ @Composable fun VideoPlayerOverlay( + isPlaying: Boolean, modifier: Modifier = Modifier, state: VideoPlayerState = rememberVideoPlayerState(), focusRequester: FocusRequester = remember { FocusRequester() }, - isPlaying: Boolean, centerButton: @Composable () -> Unit = {}, subtitles: @Composable () -> Unit = {}, controls: @Composable () -> Unit = {} @@ -73,7 +73,7 @@ fun VideoPlayerOverlay( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - AnimatedVisibility(state.controlsVisible, modifier, fadeIn(), fadeOut()) { + AnimatedVisibility(state.controlsVisible, Modifier, fadeIn(), fadeOut()) { CinematicBackground(Modifier.fillMaxSize()) } @@ -85,7 +85,7 @@ fun VideoPlayerOverlay( AnimatedVisibility( state.controlsVisible, - modifier, + Modifier, slideInVertically { it }, slideOutVertically { it } ) { diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt index ab4ac746..55f56304 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/components/VideoPlayerPulse.kt @@ -8,17 +8,20 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.Replay10 import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon -import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.NONE +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.debounce import kotlin.time.Duration.Companion.seconds object VideoPlayerPulse { @@ -28,14 +31,14 @@ object VideoPlayerPulse { @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun VideoPlayerPulse( - state: VideoPlayerPulseState = remember { VideoPlayerPulseState() } + state: VideoPlayerPulseState = rememberVideoPlayerPulseState() ) { - val icon = when(state.type) { - VideoPlayerPulse.Type.FORWARD -> Icons.Default.Forward10 + val icon = when (state.type) { + VideoPlayerPulse.Type.FORWARD -> Icons.Default.Forward10 VideoPlayerPulse.Type.BACK -> Icons.Default.Replay10 - VideoPlayerPulse.Type.NONE -> null + NONE -> null } - if(icon != null) { + if (icon != null) { Icon( icon, contentDescription = null, @@ -49,18 +52,23 @@ fun VideoPlayerPulse( } class VideoPlayerPulseState { - private var _type = mutableStateOf(VideoPlayerPulse.Type.NONE) - val type: VideoPlayerPulse.Type get() = _type.value + private var _type by mutableStateOf(NONE) + val type: VideoPlayerPulse.Type get() = _type - private var currentTimer: Job? = null + private val channel = Channel(Channel.CONFLATED) - suspend fun setType(type: VideoPlayerPulse.Type) = coroutineScope { - // Cancel previous delay - currentTimer?.cancel() - currentTimer = launch { - _type.value = type - delay(2.seconds) - _type.value = VideoPlayerPulse.Type.NONE - } + suspend fun observe() { + channel.consumeAsFlow() + .debounce(2.seconds) + .collect { _type = NONE } } -} \ No newline at end of file + + fun setType(type: VideoPlayerPulse.Type) { + _type = type + channel.trySend(Unit) + } +} + +@Composable +fun rememberVideoPlayerPulseState() = + remember { VideoPlayerPulseState() }.also { LaunchedEffect(it) { it.observe() } } 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 e2600df6..234a4d6b 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 @@ -5,14 +5,12 @@ 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.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import com.google.jetstream.data.util.StringConstants +import kotlin.time.Duration @Composable fun VideoPlayerSeeker( @@ -21,59 +19,27 @@ fun VideoPlayerSeeker( isPlaying: Boolean, onPlayPauseToggle: (Boolean) -> Unit, onSeek: (Float) -> Unit, - contentProgressInMillis: Long, - contentDurationInMillis: Long + contentProgress: Duration, + contentDuration: Duration ) { - val contentProgress by remember( - contentProgressInMillis, - contentDurationInMillis - ) { - derivedStateOf { - contentProgressInMillis.toFloat() / contentDurationInMillis - } - } + val contentProgressString = + contentProgress.toComponents { h, m, s, _ -> + if(h > 0) { + "$h:${m.padStartWith0()}:${s.padStartWith0()}" + } else { + ":${m.padStartWith0()}:${s.padStartWith0()}" + } - val contentProgressString by remember(contentProgressInMillis) { - derivedStateOf { - val contentProgressMinutes = (contentProgressInMillis / 1000) / 60 - val contentProgressSeconds = (contentProgressInMillis / 1000) % 60 - val contentProgressMinutesStr = - if (contentProgressMinutes < 10) { - contentProgressMinutes.padStartWith0() - } else { - contentProgressMinutes.toString() - } - val contentProgressSecondsStr = - if (contentProgressSeconds < 10) { - contentProgressSeconds.padStartWith0() - } else { - contentProgressSeconds.toString() - } - "$contentProgressMinutesStr:$contentProgressSecondsStr" } - } + val contentDurationString = + contentDuration.toComponents { h, m, s, _ -> + if(h > 0) { + "$h:${m.padStartWith0()}:${s.padStartWith0()}" + } else { + ":${m.padStartWith0()}:${s.padStartWith0()}" + } - val contentDurationString by remember(contentDurationInMillis) { - derivedStateOf { - val contentDurationMinutes = - (contentDurationInMillis / 1000 / 60).coerceAtLeast(minimumValue = 0) - val contentDurationSeconds = - (contentDurationInMillis / 1000 % 60).coerceAtLeast(minimumValue = 0) - val contentDurationMinutesStr = - if (contentDurationMinutes < 10) { - contentDurationMinutes.padStartWith0() - } else { - contentDurationMinutes.toString() - } - val contentDurationSecondsStr = - if (contentDurationSeconds < 10) { - contentDurationSeconds.padStartWith0() - } else { - contentDurationSeconds.toString() - } - "$contentDurationMinutesStr:$contentDurationSecondsStr" } - } Row( verticalAlignment = Alignment.CenterVertically @@ -90,7 +56,7 @@ fun VideoPlayerSeeker( ) VideoPlayerControllerText(text = contentProgressString) VideoPlayerControllerIndicator( - progress = contentProgress, + progress = (contentProgress / contentDuration).toFloat(), onSeek = onSeek, state = state ) @@ -98,4 +64,4 @@ fun VideoPlayerSeeker( } } -private fun Long.padStartWith0() = this.toString().padStart(2, '0') +private fun Number.padStartWith0() = this.toString().padStart(2, '0') 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 74629999..51b67aa6 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,35 +23,29 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +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( @IntRange(from = 0) - val hideSeconds: Int + private val hideSeconds: Int ) { - var controlsVisible by mutableStateOf(true) - private val countDownTimer = MutableStateFlow(value = hideSeconds) + private var _controlsVisible by mutableStateOf(true) + val controlsVisible get() = _controlsVisible - suspend fun observe() = coroutineScope { - launch { - countDownTimer.collectLatest { time -> - if (time > 0) { - controlsVisible = true - delay(1000) - countDownTimer.emit(countDownTimer.value - 1) - } else { - controlsVisible = false - } - } - } + fun showControls(seconds: Int = hideSeconds) { + _controlsVisible = true + channel.trySend(seconds) } - suspend fun showControls(seconds: Int = hideSeconds) { - countDownTimer.emit(seconds) + private val channel = Channel(CONFLATED) + + suspend fun observe() { + channel.consumeAsFlow() + .debounce { it.toLong() * 1000 } + .collect { _controlsVisible = false } } } From 3e80a49305095c4aadb2b64ff850cd05386d13a9 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 5 Dec 2023 14:06:58 +0000 Subject: [PATCH 8/8] Address review comments --- JetStreamCompose/gradle/libs.versions.toml | 3 +-- JetStreamCompose/jetstream/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/JetStreamCompose/gradle/libs.versions.toml b/JetStreamCompose/gradle/libs.versions.toml index f2a407d6..e20566e0 100644 --- a/JetStreamCompose/gradle/libs.versions.toml +++ b/JetStreamCompose/gradle/libs.versions.toml @@ -21,7 +21,6 @@ media3-exoplayer = "1.1.1" navigation-compose = "2.7.4" profileinstaller = "1.3.1" uiautomator = "2.2.0" -ui-tooling = "1.5.4" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } @@ -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-base = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose-ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 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" } @@ -48,7 +48,6 @@ coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" } kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } diff --git a/JetStreamCompose/jetstream/build.gradle.kts b/JetStreamCompose/jetstream/build.gradle.kts index 7ca7034b..d5771b75 100644 --- a/JetStreamCompose/jetstream/build.gradle.kts +++ b/JetStreamCompose/jetstream/build.gradle.kts @@ -125,5 +125,5 @@ dependencies { implementation(libs.androidx.profileinstaller) // Compose Previews - debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.compose.ui.tooling) }