diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt index 058555b5e..a993dbba5 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/player/settings/PlayerSettingsViewModel.kt @@ -15,7 +15,9 @@ import androidx.compose.material.icons.filled.Tune import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters @@ -342,14 +344,15 @@ class PlayerSettingsViewModel( * Factory * * @param player - * @param application * @constructor Create an empty Factory */ class Factory( private val player: Player, - private val application: Application ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[APPLICATION_KEY]) + + @Suppress("UNCHECKED_CAST") return PlayerSettingsViewModel(player, application) as T } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt index 7ff404739..2e430ab81 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/PlayerView.kt @@ -18,13 +18,17 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -33,11 +37,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Timeline.Window @@ -45,10 +53,11 @@ import androidx.tv.material3.Button import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon import androidx.tv.material3.IconButton +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ModalNavigationDrawer import androidx.tv.material3.Text import androidx.tv.material3.rememberDrawerState -import ch.srgssr.pillarbox.demo.shared.R import ch.srgssr.pillarbox.demo.shared.extension.onDpadEvent import ch.srgssr.pillarbox.demo.shared.ui.components.PillarboxSlider import ch.srgssr.pillarbox.demo.shared.ui.getFormatter @@ -58,8 +67,10 @@ import ch.srgssr.pillarbox.demo.shared.ui.player.metrics.MetricsOverlay import ch.srgssr.pillarbox.demo.shared.ui.player.rememberDelayedControlsVisibility import ch.srgssr.pillarbox.demo.shared.ui.rememberIsTalkBackEnabled import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions +import ch.srgssr.pillarbox.demo.tv.R import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerError import ch.srgssr.pillarbox.demo.tv.ui.player.compose.controls.PlayerPlaybackRow +import ch.srgssr.pillarbox.demo.tv.ui.player.compose.playlist.PlaylistDrawer import ch.srgssr.pillarbox.demo.tv.ui.player.compose.settings.PlaybackSettingsDrawer import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings import ch.srgssr.pillarbox.player.PillarboxExoPlayer @@ -84,6 +95,12 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import ch.srgssr.pillarbox.demo.shared.R as shareR + +private enum class DrawerMode { + PLAYLIST, + SETTINGS, +} /** * TV player view @@ -103,12 +120,13 @@ fun PlayerView( metricsOverlayOptions: MetricsOverlayOptions, ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val talkBackEnabled = rememberIsTalkBackEnabled() val isPlaying by player.isPlayingAsState() val keepControlDelay = if (!talkBackEnabled && isPlaying) DefaultVisibilityDelay else ZERO val controlsVisibilityState = rememberDelayedControlsVisibility(initialVisible = true, keepControlDelay) + var drawerMode by remember { mutableStateOf(DrawerMode.SETTINGS) } + LaunchedEffect(drawerState.currentValue) { controlsVisibilityState.visible = when (drawerState.currentValue) { DrawerValue.Closed -> true @@ -120,10 +138,42 @@ fun PlayerView( controlsVisibilityState.visible = false } - PlaybackSettingsDrawer( - player = player, - drawerState = drawerState, + ModalNavigationDrawer( + drawerContent = { drawerValue -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + if (drawerValue == DrawerValue.Open) { + BackHandler { + drawerState.setValue(DrawerValue.Closed) + } + + var hasFocus by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + val modifier = Modifier + .width(320.dp) + .fillMaxHeight() + .padding(MaterialTheme.paddings.baseline) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = MaterialTheme.shapes.large + ) + .focusRequester(focusRequester) + .onFocusChanged { hasFocus = it.hasFocus } + .onGloballyPositioned { + if (!hasFocus) { + focusRequester.requestFocus() + } + } + + when (drawerMode) { + DrawerMode.PLAYLIST -> PlaylistDrawer(player, modifier) + DrawerMode.SETTINGS -> PlaybackSettingsDrawer(player, modifier) + } + } + } + }, modifier = modifier, + drawerState = drawerState, ) { val error by player.playerErrorAsState() if (error != null) { @@ -181,6 +231,7 @@ fun PlayerView( onClick = { player.seekTo(currentCredit?.end ?: 0L) }, ) } + AnimatedVisibility( visible = controlsVisibilityState.visible, modifier = Modifier @@ -211,13 +262,32 @@ fun PlayerView( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - IconButton( - onClick = { drawerState.setValue(DrawerValue.Open) }, + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline), ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = stringResource(R.string.settings), - ) + IconButton( + onClick = { + drawerMode = DrawerMode.SETTINGS + drawerState.setValue(DrawerValue.Open) + }, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(shareR.string.settings), + ) + } + + IconButton( + onClick = { + drawerMode = DrawerMode.PLAYLIST + drawerState.setValue(DrawerValue.Open) + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, + contentDescription = stringResource(R.string.playlist), + ) + } } if (currentCredit != null) { @@ -294,7 +364,7 @@ private fun SkipButton( onClick = onClick, modifier = modifier, ) { - Text(text = stringResource(R.string.skip)) + Text(text = stringResource(shareR.string.skip)) } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/playlist/PlaylistDrawer.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/playlist/PlaylistDrawer.kt new file mode 100644 index 000000000..e33955be5 --- /dev/null +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/playlist/PlaylistDrawer.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.tv.ui.player.compose.playlist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.media3.common.Player +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.NavigationDrawerScope +import androidx.tv.material3.Text +import ch.srgssr.pillarbox.demo.tv.R +import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings +import ch.srgssr.pillarbox.ui.extension.currentMediaItemIndexAsState +import ch.srgssr.pillarbox.ui.extension.getCurrentMediaItemsAsState +import coil3.compose.AsyncImage + +/** + * Drawer used to display a player's playlist. + * + * @param player The currently active player. + * @param modifier The [Modifier] to apply to this layout. + */ +@Composable +fun NavigationDrawerScope.PlaylistDrawer( + player: Player, + modifier: Modifier = Modifier, +) { + val mediaItems by player.getCurrentMediaItemsAsState() + val currentMediaItemIndex by player.currentMediaItemIndexAsState() + + Column( + modifier = modifier + .padding(horizontal = MaterialTheme.paddings.baseline) + .padding(top = MaterialTheme.paddings.baseline), + ) { + Text( + text = stringResource(R.string.playlist), + style = MaterialTheme.typography.titleMedium + ) + + LazyColumn( + contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline) + ) { + itemsIndexed(mediaItems) { index, mediaItem -> + NavigationDrawerItem( + selected = index == currentMediaItemIndex, + onClick = { + player.seekToDefaultPosition(index) + player.play() + }, + leadingContent = { + AsyncImage( + model = mediaItem.mediaMetadata.artworkUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + }, + supportingContent = if (mediaItem.mediaMetadata.description != null) { + { + Text( + text = mediaItem.mediaMetadata.description.toString(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } else { + null + }, + content = { + Text( + text = mediaItem.mediaMetadata.title.toString(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + ) + } + } + } +} diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt index 0a8ed1153..8248aef05 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/player/compose/settings/PlaybackSettingsDrawer.kt @@ -4,51 +4,32 @@ */ package ch.srgssr.pillarbox.demo.tv.ui.player.compose.settings -import android.app.Application -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.HearingDisabled import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.Format import androidx.media3.common.Player import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.tv.material3.DrawerState -import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ModalNavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.Text @@ -71,68 +52,20 @@ import ch.srgssr.pillarbox.ui.extension.getPeriodicallyCurrentMetricsAsState * Drawer used to display a player's settings. * * @param player The currently active player. - * @param drawerState The state of the drawer. - * @param modifier The [Modifier] to apply to the drawer. - * @param content The content to display behind the drawer. + * @param modifier The [Modifier] to apply to this layout. */ @Composable -fun PlaybackSettingsDrawer( +fun NavigationDrawerScope.PlaybackSettingsDrawer( player: Player, - drawerState: DrawerState, modifier: Modifier = Modifier, - content: @Composable () -> Unit ) { - ModalNavigationDrawer( - drawerContent = { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - if (it == DrawerValue.Open) { - BackHandler { - drawerState.setValue(DrawerValue.Closed) - } - - NavigationDrawerNavHost( - player = player, - modifier = Modifier - .width(320.dp) - .fillMaxHeight() - .padding(MaterialTheme.paddings.baseline) - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = MaterialTheme.shapes.large - ) - ) - } - } - }, - modifier = modifier, - drawerState = drawerState, - content = content, - ) -} - -@Composable -private fun NavigationDrawerScope.NavigationDrawerNavHost( - player: Player, - modifier: Modifier = Modifier -) { - val application = LocalContext.current.applicationContext as Application - val settingsViewModel = viewModel(factory = PlayerSettingsViewModel.Factory(player, application)) - val focusRequester = remember { FocusRequester() } + val settingsViewModel = viewModel(factory = PlayerSettingsViewModel.Factory(player)) val navController = rememberNavController() - var hasFocus by remember { mutableStateOf(false) } - NavHost( navController = navController, startDestination = SettingsRoutes.Main, - modifier = modifier - .focusRequester(focusRequester) - .onFocusChanged { hasFocus = it.hasFocus } - .onGloballyPositioned { - if (!hasFocus) { - focusRequester.requestFocus() - } - } + modifier = modifier, ) { composable { val settings by settingsViewModel.settings.collectAsState() @@ -364,7 +297,7 @@ private fun NavigationDrawerScope.TracksSetting( append(format.displayName) if (format.bitrate > Format.NO_VALUE) { - append(" @%1\$.2f Mbps".format(format.bitrate / 1_000_000f)) + append(" @%1$.2f Mbps".format(format.bitrate / 1_000_000f)) } } @@ -387,7 +320,7 @@ private fun NavigationDrawerScope.TracksSetting( append(format.height) if (format.bitrate > Format.NO_VALUE) { - append(" @%1\$.2f Mbps".format(format.bitrate / 1_000_000f)) + append(" @%1$.2f Mbps".format(format.bitrate / 1_000_000f)) } } diff --git a/pillarbox-demo-tv/src/main/res/values/strings.xml b/pillarbox-demo-tv/src/main/res/values/strings.xml index 40e63535f..7124904cf 100644 --- a/pillarbox-demo-tv/src/main/res/values/strings.xml +++ b/pillarbox-demo-tv/src/main/res/values/strings.xml @@ -1,5 +1,10 @@ + Pillarbox Demo Tv Loading… No content + Playlist diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt index 905fead78..b376fe2bb 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/settings/PlaybackSettingsContent.kt @@ -4,7 +4,6 @@ */ package ch.srgssr.pillarbox.demo.ui.player.settings -import android.app.Application import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding @@ -20,7 +19,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.CollectionInfo import androidx.compose.ui.semantics.CollectionItemInfo import androidx.compose.ui.semantics.Role @@ -51,9 +49,8 @@ fun PlaybackSettingsContent( player: Player, modifier: Modifier = Modifier, ) { - val application = LocalContext.current.applicationContext as Application val navController = rememberNavController() - val settingsViewModel: PlayerSettingsViewModel = viewModel(factory = PlayerSettingsViewModel.Factory(player, application)) + val settingsViewModel: PlayerSettingsViewModel = viewModel(factory = PlayerSettingsViewModel.Factory(player)) Surface(modifier = modifier) { NavHost(navController = navController, startDestination = SettingsRoutes.Main) { composable(