diff --git a/.idea/detekt.xml b/.idea/detekt.xml
new file mode 100644
index 000000000..36b17e5cc
--- /dev/null
+++ b/.idea/detekt.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a8e907001..fa1b83288 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -40,6 +40,7 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" }
+androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "navigation" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts
index 666a446b7..73d5f31f8 100644
--- a/pillarbox-demo-shared/build.gradle.kts
+++ b/pillarbox-demo-shared/build.gradle.kts
@@ -39,6 +39,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.common)
+ implementation(libs.androidx.navigation.runtime)
implementation(libs.androidx.paging.common)
implementation(libs.srg.dataprovider.paging)
implementation(libs.srg.dataprovider.retrofit)
diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt
index a43bbb876..7f3867299 100644
--- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt
+++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt
@@ -7,11 +7,12 @@ package ch.srgssr.pillarbox.demo.shared.ui
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
-import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.NavController
+import androidx.navigation.NavGraph.Companion.findStartDestination
import ch.srgssr.pillarbox.demo.shared.R
/**
@@ -41,13 +42,29 @@ sealed class HomeDestination(
*/
data object Lists : HomeDestination(NavigationRoutes.homeLists, R.string.lists, Icons.Default.ViewList)
- /**
- * Info home page
- */
- data object Info : HomeDestination(NavigationRoutes.homeInformation, R.string.info, Icons.Default.Info)
-
/**
* Info home page
*/
data object Search : HomeDestination(NavigationRoutes.searchHome, R.string.search, Icons.Default.Search)
}
+
+/**
+ * Navigate as a top level destination.
+ *
+ * @param destination The [HomeDestination] to navigate to.
+ */
+fun NavController.navigate(destination: HomeDestination) {
+ navigate(destination.route) {
+ // Pop up to the start destination of the graph to
+ // avoid building up a large stack of destinations
+ // on the back stack as users select items
+ popUpTo(graph.findStartDestination().id) {
+ saveState = true
+ }
+ // Avoid multiple copies of the same destination when
+ // reselecting the same item
+ launchSingleTop = true
+ // Restore state when reselecting a previously selected item
+ restoreState = true
+ }
+}
diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt
index 48cbe9ea7..b26d34fd2 100644
--- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt
+++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt
@@ -12,7 +12,6 @@ object NavigationRoutes {
const val homeSamples = "home_samples"
const val homeSample = "home_sample"
const val homeShowcases = "home_showcases"
- const val homeInformation = "home_information"
const val showcaseList = "showcase_list"
const val story = "story"
const val simplePlayer = "simple_player"
diff --git a/pillarbox-demo-shared/src/main/res/values/strings.xml b/pillarbox-demo-shared/src/main/res/values/strings.xml
index a474a24a3..2bc72db8f 100644
--- a/pillarbox-demo-shared/src/main/res/values/strings.xml
+++ b/pillarbox-demo-shared/src/main/res/values/strings.xml
@@ -4,7 +4,6 @@
-->
Examples
- Information
Lists
Search
Showcases
diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt
index 6550855f7..d2fac2a28 100644
--- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt
+++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt
@@ -7,23 +7,27 @@ package ch.srgssr.pillarbox.demo.tv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.CompositionLocalProvider
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination
+import ch.srgssr.pillarbox.demo.shared.ui.navigate
import ch.srgssr.pillarbox.demo.tv.ui.TVDemoNavigation
import ch.srgssr.pillarbox.demo.tv.ui.TVDemoTopBar
import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme
@@ -50,36 +54,35 @@ class MainActivity : ComponentActivity() {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface
) {
- val destinations = listOf(HomeDestination.Examples, HomeDestination.Lists)
+ val destinations = listOf(HomeDestination.Examples, HomeDestination.Lists, HomeDestination.Search)
val navController = rememberNavController()
- val startDestination by remember(destinations) { mutableStateOf(destinations[0]) }
+ val focusRequester = remember { FocusRequester() }
+ val startDestination by remember(destinations) { mutableStateOf(destinations[0]) }
var selectedDestination by remember { mutableStateOf(startDestination) }
+ val currentBackStackEntry by navController.currentBackStackEntryAsState()
- navController.addOnDestinationChangedListener { _, destination, _ ->
- destinations.find { it.route == destination.route }
- ?.takeIf { it != selectedDestination }
- ?.let { selectedDestination = it }
- }
-
- AnimatedVisibility(visible = selectedDestination != HomeDestination.Search) {
- TVDemoTopBar(
- destinations = destinations,
- selectedDestination = selectedDestination,
- modifier = Modifier.padding(vertical = MaterialTheme.paddings.baseline),
- onDestinationClick = { destination ->
- selectedDestination = destination
-
- navController.navigate(destination.route)
- }
- )
- }
+ TVDemoTopBar(
+ destinations = destinations,
+ currentNavDestination = currentBackStackEntry?.destination,
+ modifier = Modifier
+ .padding(vertical = MaterialTheme.paddings.baseline)
+ .focusRequester(focusRequester),
+ onDestinationClick = { destination ->
+ selectedDestination = destination
+ navController.navigate(destination)
+ }
+ )
TVDemoNavigation(
navController = navController,
startDestination = startDestination,
modifier = Modifier.fillMaxSize()
)
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
}
}
}
diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt
index 53b2f8677..b12d5e176 100644
--- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt
+++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/examples/Examples.kt
@@ -16,6 +16,8 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -38,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -46,6 +49,7 @@ import androidx.navigation.navArgument
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.itemsIndexed
+import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState
import androidx.tv.material3.Card
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
@@ -55,6 +59,7 @@ import ch.srgssr.pillarbox.demo.shared.data.Playlist
import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes
import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme
import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings
+import kotlinx.coroutines.launch
/**
* Examples home
@@ -79,8 +84,9 @@ fun ExamplesHome(
) {
composable(NavigationRoutes.homeSamples) {
ExamplesSection(
- modifier = modifier,
items = playlists,
+ focusFirstItem = false,
+ navController = navController,
onItemClick = { index, _ ->
navController.navigate("${NavigationRoutes.homeSample}/$index")
}
@@ -114,6 +120,8 @@ fun ExamplesHome(
ExamplesSection(
title = playlist.title,
items = playlist.items,
+ focusFirstItem = true,
+ navController = navController,
onItemClick = { _, item ->
onItemSelected(item)
}
@@ -154,10 +162,14 @@ private fun ExamplesSection(
modifier: Modifier = Modifier,
title: String? = null,
items: List,
+ focusFirstItem: Boolean,
+ navController: NavHostController,
onItemClick: (index: Int, item: T) -> Unit,
content: @Composable (item: T) -> Unit
) {
- var focusedIndex by remember(items) { mutableIntStateOf(0) }
+ var focusedIndex by rememberSaveable(items, focusFirstItem) {
+ mutableIntStateOf(if (focusFirstItem) 0 else -1)
+ }
val columnCount = 4
val focusManager = LocalFocusManager.current
@@ -166,15 +178,7 @@ private fun ExamplesSection(
}
Column(
- modifier = modifier
- .onPreviewKeyEvent {
- if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) {
- focusedIndex = -1
- focusManager.moveFocus(FocusDirection.Up)
- } else {
- false
- }
- },
+ modifier = modifier,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
) {
if (title != null) {
@@ -184,9 +188,38 @@ private fun ExamplesSection(
)
}
+ val coroutineScope = rememberCoroutineScope()
+ val scrollState = rememberTvLazyGridState()
+
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(columnCount),
- modifier = Modifier.focusRestorer(),
+ modifier = Modifier
+ .focusRestorer()
+ .onPreviewKeyEvent {
+ if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) {
+ focusedIndex = -1
+ focusManager.moveFocus(FocusDirection.Up)
+ } else if (it.key == Key.Back && it.type == KeyEventType.KeyDown) {
+ if (!isOnFirstRow) {
+ focusedIndex = 0
+
+ coroutineScope.launch {
+ scrollState.animateScrollToItem(focusedIndex)
+ }
+
+ true
+ } else if (navController.previousBackStackEntry == null) {
+ focusedIndex = -1
+ focusManager.moveFocus(FocusDirection.Up)
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ },
+ state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
@@ -195,10 +228,7 @@ private fun ExamplesSection(
val focusRequester = remember { FocusRequester() }
Card(
- onClick = {
- focusedIndex = index
- onItemClick(index, item)
- },
+ onClick = { onItemClick(index, item) },
modifier = Modifier
.height(104.dp)
.focusRequester(focusRequester)
diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt
index ba4b6e356..28368965f 100644
--- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt
+++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt
@@ -4,37 +4,40 @@
*/
package ch.srgssr.pillarbox.demo.tv.ui
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Search
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.tv.material3.ExperimentalTvMaterial3Api
-import androidx.tv.material3.Icon
-import androidx.tv.material3.ListItem
-import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Tab
+import androidx.tv.material3.TabRow
import androidx.tv.material3.Text
import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination
import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme
import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings
+import ch.srgssr.pillarbox.ui.extension.handleDPadKeyEvents
/**
* Top bar displayed in the demo app on TV.
*
* @param destinations The list of destinations to display.
- * @param selectedDestination The currently selected destination.
+ * @param currentNavDestination The currently destination selected.
* @param modifier The [Modifier] to apply to the top bar.
* @param onDestinationClick The action to perform the selected a destination.
*/
@@ -42,42 +45,64 @@ import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings
@OptIn(ExperimentalComposeUiApi::class, ExperimentalTvMaterial3Api::class)
fun TVDemoTopBar(
destinations: List,
- selectedDestination: HomeDestination,
+ currentNavDestination: NavDestination?,
modifier: Modifier = Modifier,
onDestinationClick: (destination: HomeDestination) -> Unit
) {
- Row(
+ val focusManager = LocalFocusManager.current
+ val focusedTabIndex by rememberSaveable(currentNavDestination) {
+ val destinationHierarchy = currentNavDestination?.hierarchy.orEmpty()
+
+ mutableIntStateOf(
+ destinations.indexOfFirst { dest ->
+ destinationHierarchy.any { it.route == dest.route }
+ }
+ )
+ }
+
+ TabRow(
+ selectedTabIndex = focusedTabIndex,
modifier = modifier
- .fillMaxWidth()
- .focusRestorer(),
- horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline),
- verticalAlignment = Alignment.CenterVertically
- ) {
- destinations.forEach { destination ->
- ListItem(
- selected = destination == selectedDestination,
- onClick = { onDestinationClick(destination) },
- modifier = Modifier.width(IntrinsicSize.Max),
- headlineContent = {
- Text(text = stringResource(destination.labelResId))
+ .focusRestorer()
+ .handleDPadKeyEvents(
+ onRight = {
+ if (focusedTabIndex < destinations.lastIndex) {
+ focusManager.moveFocus(FocusDirection.Right)
+ }
}
)
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- ListItem(
- selected = selectedDestination == HomeDestination.Search,
- onClick = { onDestinationClick(HomeDestination.Search) },
- modifier = Modifier.width(IntrinsicSize.Max),
- shape = ListItemDefaults.shape(CircleShape),
- headlineContent = {
- Icon(
- imageVector = Icons.Default.Search,
- contentDescription = stringResource(HomeDestination.Search.labelResId)
- )
+ ) {
+ destinations.forEachIndexed { index, destination ->
+ key(index) {
+ val focusRequester = remember {
+ FocusRequester()
+ }
+ LaunchedEffect(focusedTabIndex) {
+ if (index == focusedTabIndex) {
+ focusRequester.requestFocus()
+ }
+ }
+ Tab(
+ modifier = Modifier.focusRequester(focusRequester),
+ selected = focusedTabIndex == index,
+ onFocus = {
+ if (index != focusedTabIndex) {
+ onDestinationClick(destination)
+ }
+ },
+ onClick = { focusManager.moveFocus(FocusDirection.Down) }
+ ) {
+ Text(
+ text = stringResource(destination.labelResId),
+ modifier = Modifier.padding(
+ horizontal = MaterialTheme.paddings.baseline,
+ vertical = MaterialTheme.paddings.small,
+ ),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
}
- )
+ }
}
}
@@ -86,8 +111,8 @@ fun TVDemoTopBar(
private fun TVDemoTopBarPreview() {
PillarboxTheme {
TVDemoTopBar(
- destinations = listOf(HomeDestination.Examples, HomeDestination.Lists),
- selectedDestination = HomeDestination.Examples,
+ destinations = listOf(HomeDestination.Examples, HomeDestination.Lists, HomeDestination.Search),
+ currentNavDestination = null,
onDestinationClick = {}
)
}
diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt
index 2c4546600..d63f6e2a9 100644
--- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt
+++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt
@@ -21,6 +21,8 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -49,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -62,6 +65,7 @@ import androidx.paging.compose.itemKey
import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.itemsIndexed
+import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState
import androidx.tv.material3.Card
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Icon
@@ -92,6 +96,7 @@ import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme
import ch.srgssr.pillarbox.demo.tv.ui.theme.paddings
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.seconds
import ch.srgssr.pillarbox.demo.shared.R as sharedR
@@ -120,7 +125,9 @@ fun ListsHome(
composable(NavigationRoutes.contentLists) {
ListsSection(
items = sections,
+ focusFirstItem = false,
itemToString = { it.title },
+ navController = navController,
onItemClick = { index, _ ->
navController.navigate("${NavigationRoutes.contentList}/$index")
}
@@ -139,9 +146,11 @@ fun ListsHome(
ListsSection(
title = section.title,
items = section.contentList,
+ focusFirstItem = true,
itemToString = { item ->
item.destinationTitle
},
+ navController = navController,
onItemClick = { _, contentList ->
navController.navigate(contentList.destinationRoute)
}
@@ -234,10 +243,14 @@ private fun ListsSection(
modifier: Modifier = Modifier,
title: String? = null,
items: List,
+ focusFirstItem: Boolean,
+ navController: NavHostController,
itemToString: (item: T) -> String,
onItemClick: (index: Int, item: T) -> Unit
) {
- var focusedIndex by remember(items) { mutableIntStateOf(0) }
+ var focusedIndex by rememberSaveable(items, focusFirstItem) {
+ mutableIntStateOf(if (focusFirstItem) 0 else -1)
+ }
val columnCount = 4
val focusManager = LocalFocusManager.current
@@ -246,16 +259,7 @@ private fun ListsSection(
}
Column(
- modifier = modifier
- .padding(horizontal = MaterialTheme.paddings.baseline)
- .onPreviewKeyEvent {
- if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) {
- focusedIndex = -1
- focusManager.moveFocus(FocusDirection.Up)
- } else {
- false
- }
- },
+ modifier = modifier,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
) {
if (title != null) {
@@ -265,9 +269,41 @@ private fun ListsSection(
)
}
+ val coroutineScope = rememberCoroutineScope()
+ val scrollState = rememberTvLazyGridState()
+
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(columnCount),
- modifier = Modifier.focusRestorer(),
+ modifier = Modifier
+ .focusRestorer()
+ .onKeyEvent(
+ onUpPress = {
+ if (isOnFirstRow) {
+ focusedIndex = -1
+ focusManager.moveFocus(FocusDirection.Up)
+ } else {
+ false
+ }
+ },
+ onBackPress = {
+ if (!isOnFirstRow) {
+ focusedIndex = 0
+
+ coroutineScope.launch {
+ scrollState.animateScrollToItem(focusedIndex)
+ }
+
+ true
+ } else if (navController.previousBackStackEntry == null) {
+ focusedIndex = -1
+ focusManager.moveFocus(FocusDirection.Up)
+ true
+ } else {
+ false
+ }
+ }
+ ),
+ state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
@@ -276,10 +312,7 @@ private fun ListsSection(
val focusRequester = remember { FocusRequester() }
Card(
- onClick = {
- focusedIndex = index
- onItemClick(index, item)
- },
+ onClick = { onItemClick(index, item) },
modifier = Modifier
.height(104.dp)
.focusRequester(focusRequester)
@@ -391,7 +424,7 @@ private fun ListsSectionContent(
if (items.itemCount == 0) {
emptyScreen(modifier)
} else {
- var focusedIndex by remember(items, focusFirstItem) {
+ var focusedIndex by rememberSaveable(items, focusFirstItem) {
mutableIntStateOf(if (focusFirstItem) 0 else -1)
}
@@ -403,18 +436,37 @@ private fun ListsSectionContent(
}
val itemHeight = if (hasMedia) 160.dp else 104.dp
+ val coroutineScope = rememberCoroutineScope()
+ val scrollState = rememberTvLazyGridState()
+
TvLazyVerticalGrid(
columns = TvGridCells.Fixed(columnCount),
modifier = modifier
.focusRestorer()
- .onPreviewKeyEvent {
- if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && isOnFirstRow) {
- focusedIndex = -1
- focusManager.moveFocus(FocusDirection.Up)
- } else {
- false
+ .onKeyEvent(
+ onUpPress = {
+ if (isOnFirstRow) {
+ focusedIndex = -1
+ focusManager.moveFocus(FocusDirection.Up)
+ } else {
+ false
+ }
+ },
+ onBackPress = {
+ if (!isOnFirstRow) {
+ focusedIndex = 0
+
+ coroutineScope.launch {
+ scrollState.animateScrollToItem(focusedIndex)
+ }
+
+ true
+ } else {
+ false
+ }
}
- },
+ ),
+ state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.paddings.baseline),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
@@ -428,10 +480,7 @@ private fun ListsSectionContent(
var containerWidth by remember { mutableIntStateOf(0) }
Card(
- onClick = {
- focusedIndex = index
- onItemClick(item)
- },
+ onClick = { onItemClick(item) },
modifier = Modifier
.height(itemHeight)
.focusRequester(focusRequester)
@@ -633,6 +682,23 @@ private fun ListsSectionError(
}
}
+private fun Modifier.onKeyEvent(
+ onUpPress: () -> Boolean,
+ onBackPress: () -> Boolean
+): Modifier {
+ return this then Modifier.onPreviewKeyEvent { keyEvent ->
+ if (keyEvent.type == KeyEventType.KeyDown) {
+ when (keyEvent.key) {
+ Key.DirectionUp -> onUpPress()
+ Key.Back -> onBackPress()
+ else -> false
+ }
+ } else {
+ false
+ }
+ }
+}
+
@Preview
@Composable
private fun ContentListsViewPreview() {
diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt
index d6769a7c7..867125645 100644
--- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt
+++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt
@@ -19,18 +19,21 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.focus.FocusDirection
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
@@ -119,13 +122,24 @@ private fun SearchRow(
onQueryChange: (query: String) -> Unit,
onBuChange: (bu: Bu) -> Unit
) {
+ val focusManager = LocalFocusManager.current
+
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.baseline)
) {
SearchInput(
query = query,
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .onPreviewKeyEvent {
+ if (it.key == Key.Back && it.type == KeyEventType.KeyDown) {
+ focusManager.moveFocus(FocusDirection.Up)
+ true
+ } else {
+ false
+ }
+ },
onQueryChange = onQueryChange
)
@@ -151,17 +165,13 @@ private fun SearchInput(
modifier: Modifier = Modifier,
onQueryChange: (query: String) -> Unit
) {
- val focusRequest = remember { FocusRequester() }
-
BasicTextField(
value = query,
onValueChange = onQueryChange,
- modifier = modifier
- .focusRequester(focusRequest)
- .background(
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.small
- ),
+ modifier = modifier.background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small
+ ),
textStyle = MaterialTheme.typography.titleSmall
.copy(color = MaterialTheme.colorScheme.onSurface),
keyboardOptions = KeyboardOptions(
@@ -186,10 +196,6 @@ private fun SearchInput(
}
}
)
-
- LaunchedEffect(Unit) {
- focusRequest.requestFocus()
- }
}
@Composable
diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt
deleted file mode 100644
index f21a0608d..000000000
--- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/InfoView.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright (c) SRG SSR. All rights reserved.
- * License information is available from the LICENSE file.
- */
-package ch.srgssr.pillarbox.demo.ui
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
-import ch.srgssr.pillarbox.demo.BuildConfig
-import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme
-import ch.srgssr.pillarbox.demo.ui.theme.paddings
-
-/**
- * Display current version name
- */
-@Composable
-fun InfoView() {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(MaterialTheme.paddings.small)
- ) {
- Text(
- text = BuildConfig.VERSION_NAME,
- style = MaterialTheme.typography.bodyLarge,
- textAlign = TextAlign.Center,
- modifier = Modifier.align(Alignment.Center)
- )
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun PreviewInfoView() {
- PillarboxTheme {
- InfoView()
- }
-}
diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt
index 14726769b..760f3a39c 100644
--- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt
+++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt
@@ -44,7 +44,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavDeepLink
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
-import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -62,6 +61,7 @@ import ch.srgssr.pillarbox.demo.shared.di.PlayerModule
import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination
import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes
import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel
+import ch.srgssr.pillarbox.demo.shared.ui.navigate
import ch.srgssr.pillarbox.demo.trackPagView
import ch.srgssr.pillarbox.demo.ui.examples.ExamplesHome
import ch.srgssr.pillarbox.demo.ui.integrationLayer.SearchView
@@ -80,7 +80,6 @@ private val topLevelRoutes =
* Main view with all the navigation
*/
@OptIn(ExperimentalMaterial3Api::class)
-@Suppress("StringLiteralDuplication")
@Composable
fun MainNavigation() {
val navController = rememberNavController()
@@ -140,10 +139,6 @@ fun MainNavigation() {
listNavGraph(navController, ilRepository, ilHost)
}
- composable(HomeDestination.Info.route, DemoPageView("home", listOf("app", "pillarbox", "information"))) {
- InfoView()
- }
-
composable(route = NavigationRoutes.searchHome, DemoPageView("home", listOf("app", "pillarbox", "search"))) {
val ilRepository = PlayerModule.createIlRepository(context)
val viewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory(ilRepository))
@@ -232,19 +227,7 @@ private fun DemoBottomNavigation(navController: NavController, currentDestinatio
label = { Text(stringResource(screen.labelResId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
- navController.navigate(screen.route) {
- // Pop up to the start destination of the graph to
- // avoid building up a large stack of destinations
- // on the back stack as users select items
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- // Avoid multiple copies of the same destination when
- // reselecting the same item
- launchSingleTop = true
- // Restore state when reselecting a previously selected item
- restoreState = true
- }
+ navController.navigate(screen)
}
)
}