Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions JetStreamCompose/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
.cxx
/.idea/
local.properties
.kotlin
3 changes: 3 additions & 0 deletions JetStreamCompose/benchmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ dependencies {
// Use 1.2.0-alpha03 or above versions for benchmarking TV apps
implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.rules)

implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.runtime)
}

androidComponents {
Expand Down
24 changes: 12 additions & 12 deletions JetStreamCompose/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
[versions]
activity-compose = "1.10.0"
android-gradle-plugin = "8.7.3"
android-test-plugin = "8.7.3"
activity-compose = "1.10.1"
android-gradle-plugin = "8.8.2"
android-test-plugin = "8.8.2"
androidx-baselineprofile = "1.3.3"
benchmark-macro-junit4 = "1.3.3"
coil-compose = "2.7.0"
compose-bom = "2025.01.00"
compose-bom = "2025.02.00"
tv-material = "1.0.0"
core-ktx = "1.15.0"
core-splashscreen = "1.0.1"
hilt-navigation-compose = "1.2.0"
hilt-android = "2.52"
hilt-android = "2.54"
junit = "1.2.1"
kotlin-android = "2.1.0"
kotlinx-serialization = "1.6.3"
ksp = "2.0.20-1.0.24"
kotlinx-serialization = "1.8.0"
ksp = "2.1.0-1.0.29"
lifecycle-runtime-ktx = "2.8.7"
media3-ui = "1.5.1"
media3-exoplayer = "1.5.1"
navigation-compose = "2.8.5"
media3 = "1.6.0-beta01"
navigation-compose = "2.8.8"
profileinstaller = "1.4.1"
uiautomator = "2.3.0"
rules = "1.6.1"
Expand All @@ -29,6 +28,7 @@ androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
Expand All @@ -37,8 +37,8 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3-ui" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3-exoplayer" }
androidx-media3-ui = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" }
androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
4 changes: 4 additions & 0 deletions JetStreamCompose/jetstream/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}

kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,60 +16,45 @@

package com.google.jetstream.presentation.screens.videoPlayer

import android.content.Context
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesomeMotion
import androidx.compose.material.icons.filled.ClosedCaption
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import com.google.jetstream.data.entities.MovieDetails
import com.google.jetstream.data.util.StringConstants
import com.google.jetstream.presentation.common.Error
import com.google.jetstream.presentation.common.Loading
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControlsIcon
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMainFrame
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMediaTitle
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerMediaTitleType
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerControls
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerOverlay
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.BACK
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulse.Type.FORWARD
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerPulseState
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerSeeker
import com.google.jetstream.presentation.screens.videoPlayer.components.VideoPlayerState
import com.google.jetstream.presentation.screens.videoPlayer.components.rememberPlayer
import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerPulseState
import com.google.jetstream.presentation.screens.videoPlayer.components.rememberVideoPlayerState
import com.google.jetstream.presentation.utils.handleDPadKeyEvents
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.delay

object VideoPlayerScreen {
Expand All @@ -94,9 +79,11 @@ fun VideoPlayerScreen(
is VideoPlayerScreenUiState.Loading -> {
Loading(modifier = Modifier.fillMaxSize())
}

is VideoPlayerScreenUiState.Error -> {
Error(modifier = Modifier.fillMaxSize())
}

is VideoPlayerScreenUiState.Done -> {
VideoPlayerScreenContent(
movieDetails = s.movieDetails,
Expand All @@ -109,11 +96,13 @@ fun VideoPlayerScreen(
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Unit) {
val context = LocalContext.current
val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4)
val exoPlayer = rememberPlayer(LocalContext.current)

val videoPlayerState = rememberVideoPlayerState(
exoPlayer = exoPlayer,
hideSeconds = 4,
)

// TODO: Move to ViewModel for better reuse
val exoPlayer = rememberExoPlayer(context)
LaunchedEffect(exoPlayer, movieDetails) {
exoPlayer.setMediaItem(
MediaItem.Builder()
Expand All @@ -137,13 +126,12 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un
}

var contentCurrentPosition by remember { mutableLongStateOf(0L) }
var isPlaying: Boolean by remember { mutableStateOf(exoPlayer.isPlaying) }

// TODO: Update in a more thoughtful manner
LaunchedEffect(Unit) {
while (true) {
delay(300)
contentCurrentPosition = exoPlayer.currentPosition
isPlaying = exoPlayer.isPlaying
}
}

Expand All @@ -160,140 +148,53 @@ fun VideoPlayerScreenContent(movieDetails: MovieDetails, onBackPressed: () -> Un
)
.focusable()
) {
AndroidView(
factory = {
PlayerView(context).apply { useController = false }
},
update = { it.player = exoPlayer },
onRelease = { exoPlayer.release() }
PlayerSurface(
player = exoPlayer,
surfaceType = SURFACE_TYPE_TEXTURE_VIEW,
modifier = Modifier.resizeWithContentScale(
contentScale = ContentScale.Fit,
sourceSizeDp = null
)
)

val focusRequester = remember { FocusRequester() }
VideoPlayerOverlay(
modifier = Modifier.align(Alignment.BottomCenter),
focusRequester = focusRequester,
state = videoPlayerState,
isPlaying = isPlaying,
isPlaying = videoPlayerState.isPlaying,
isControlsVisible = videoPlayerState.isControlsVisible,
centerButton = { VideoPlayerPulse(pulseState) },
subtitles = { /* TODO Implement subtitles */ },
showControls = videoPlayerState::showControls,
controls = {
VideoPlayerControls(
movieDetails,
isPlaying,
contentCurrentPosition,
exoPlayer,
videoPlayerState,
focusRequester
movieDetails = movieDetails,
contentCurrentPosition = contentCurrentPosition,
contentDuration = exoPlayer.duration,
isPlaying = videoPlayerState.isPlaying,
focusRequester = focusRequester,
onShowControls = videoPlayerState::showControls,
onSeek = { exoPlayer.seekTo(exoPlayer.duration.times(it).toLong()) },
onPlayPauseToggle = videoPlayerState::togglePlayPause
)
}
)
}
}

@Composable
fun VideoPlayerControls(
movieDetails: MovieDetails,
isPlaying: Boolean,
contentCurrentPosition: Long,
exoPlayer: ExoPlayer,
state: VideoPlayerState,
focusRequester: FocusRequester
) {
val onPlayPauseToggle = { shouldPlay: Boolean ->
if (shouldPlay) {
exoPlayer.play()
} else {
exoPlayer.pause()
}
}

VideoPlayerMainFrame(
mediaTitle = {
VideoPlayerMediaTitle(
title = movieDetails.name,
secondaryText = movieDetails.releaseDate,
tertiaryText = movieDetails.director,
type = VideoPlayerMediaTitleType.DEFAULT
)
},
mediaActions = {
Row(
modifier = Modifier.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
VideoPlayerControlsIcon(
icon = Icons.Default.AutoAwesomeMotion,
state = state,
isPlaying = isPlaying,
contentDescription = StringConstants
.Composable
.VideoPlayerControlPlaylistButton
)
VideoPlayerControlsIcon(
modifier = Modifier.padding(start = 12.dp),
icon = Icons.Default.ClosedCaption,
state = state,
isPlaying = isPlaying,
contentDescription = StringConstants
.Composable
.VideoPlayerControlClosedCaptionsButton
)
VideoPlayerControlsIcon(
modifier = Modifier.padding(start = 12.dp),
icon = Icons.Default.Settings,
state = state,
isPlaying = isPlaying,
contentDescription = StringConstants
.Composable
.VideoPlayerControlSettingsButton
)
}
},
seeker = {
VideoPlayerSeeker(
focusRequester,
state,
isPlaying,
onPlayPauseToggle,
onSeek = { exoPlayer.seekTo(exoPlayer.duration.times(it).toLong()) },
contentProgress = contentCurrentPosition.milliseconds,
contentDuration = exoPlayer.duration.milliseconds
)
},
more = null
)
}

@androidx.annotation.OptIn(UnstableApi::class)
@Composable
private fun rememberExoPlayer(context: Context) = remember {
ExoPlayer.Builder(context)
.setSeekForwardIncrementMs(10)
.setSeekBackIncrementMs(10)
.setMediaSourceFactory(
ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
)
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)
.build()
.apply {
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ONE
}
}

private fun Modifier.dPadEvents(
exoPlayer: ExoPlayer,
videoPlayerState: VideoPlayerState,
pulseState: VideoPlayerPulseState
): Modifier = this.handleDPadKeyEvents(
onLeft = {
if (!videoPlayerState.controlsVisible) {
if (!videoPlayerState.isControlsVisible) {
exoPlayer.seekBack()
pulseState.setType(BACK)
}
},
onRight = {
if (!videoPlayerState.controlsVisible) {
if (!videoPlayerState.isControlsVisible) {
exoPlayer.seekForward()
pulseState.setType(FORWARD)
}
Expand Down
Loading
Loading