feat: integrate dynamic library navigation in TV drawer

Refactors the TV navigation drawer to use specific library routes instead
of a static libraries overview. Added a `NavDisplay` with a backstack to
manage navigation between the Home screen and individual Library screens
directly from the drawer.

- Replace `TvDrawerDestination` enum with `Route` in the navigation drawer
- Dynamically populate drawer items based on available libraries
- Add `NavDisplay` to `TvAppScreen` for nested TV navigation
- Connect `TvLibraryScreen` to `AppViewModel` for media selection handling
- Remove back button from `TvLibraryScreen` in favor of drawer navigation
- Update `TvNavigationDrawerTest` to reflect routing changes
This commit is contained in:
2026-04-26 18:08:40 +02:00
parent 51ffd14804
commit afdd9b860f
6 changed files with 120 additions and 102 deletions

View File

@@ -24,9 +24,12 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class TvNavigationDrawerTest { class TvNavigationDrawerTest {
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
@Test @Test
fun tvNavigationDrawer_updatesSelectedDestination() { fun tvNavigationDrawer_updatesSelectedDestination() {
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME) val libraryRoute = Route.LibraryRoute(
library = LibraryDto(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Movies"
)
)
var selectedDestination by mutableStateOf<Route>(Route.Home)
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
TvNavigationDrawer( TvNavigationDrawer(
destinations = listOf( destinations = listOf(
TvDrawerDestinationItem( TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME, destination = Route.Home,
label = "Home", label = "Home",
icon = Icons.Outlined.Home icon = Icons.Outlined.Home
), ),
TvDrawerDestinationItem( TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES, destination = libraryRoute,
label = "Libraries", label = "Movies",
icon = Icons.Outlined.Collections icon = Icons.Outlined.Collections
) )
), ),

View File

@@ -1,6 +1,8 @@
package hu.bbara.purefin.navigation package hu.bbara.purefin.navigation
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
@@ -51,6 +53,10 @@ fun EntryProviderScope<Route>.tvPlayerSection() {
fun EntryProviderScope<Route>.tvLibrarySection() { fun EntryProviderScope<Route>.tvLibrarySection() {
entry<Route.LibraryRoute> { route -> entry<Route.LibraryRoute> { route ->
TvLibraryScreen(library = route.library) val viewModel: AppViewModel = hiltViewModel()
TvLibraryScreen(
library = route.library,
onMediaSelected = viewModel::onMediaSelected
)
} }
} }

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.Tv
@@ -10,18 +9,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.model.LibraryKind import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.ui.screen.home.TvHomeScreen import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestination
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
import hu.bbara.purefin.ui.screen.libraries.TvLibrariesOverviewScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
@Composable @Composable
fun TvAppScreen( fun TvAppScreen(
@@ -32,45 +37,41 @@ fun TvAppScreen(
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState() val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
val navigationManager = LocalNavigationManager.current
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) } @Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val selectedDestination = backStack.lastOrNull() ?: Route.Home
val destinations = remember(libraries, selectedDestination) { val destinations = remember(libraries) {
listOf( listOf(
TvDrawerDestinationItem( TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME, destination = Route.Home,
label = "Home", label = "Home",
icon = Icons.Outlined.Home, icon = Icons.Outlined.Home
selected = selectedDestination == TvDrawerDestination.HOME )
), ) + libraries.map { library ->
val destination = Route.LibraryRoute(
library = LibraryDto(id = library.id, name = library.name)
)
TvDrawerDestinationItem( TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES, destination = destination,
label = "Libraries", label = library.name,
icon = when { icon = when (library.type) {
libraries.any { it.type == LibraryKind.MOVIES } -> Icons.Outlined.Movie LibraryKind.MOVIES -> Icons.Outlined.Movie
libraries.any { it.type == LibraryKind.SERIES } -> Icons.Outlined.Tv LibraryKind.SERIES -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections }
},
selected = selectedDestination == TvDrawerDestination.LIBRARIES
)
) )
} }
}
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.onResumed() viewModel.onResumed()
onPauseOrDispose { } onPauseOrDispose { }
} }
TvNavigationDrawer( val tvEntryProvider = entryProvider {
destinations = destinations, entry<Route.Home> {
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
selectedDestination = destination
},
modifier = modifier.fillMaxSize()
) {
when (selectedDestination) {
TvDrawerDestination.HOME -> {
TvHomeScreen( TvHomeScreen(
libraries = libraries, libraries = libraries,
libraryContent = latestLibraryContent, libraryContent = latestLibraryContent,
@@ -80,15 +81,44 @@ fun TvAppScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
TvDrawerDestination.LIBRARIES -> { entry<Route.LibraryRoute> { route ->
TvLibrariesOverviewScreen( TvLibraryScreen(
libraries = libraries, library = route.library,
onLibrarySelected = { library -> onMediaSelected = viewModel::onMediaSelected,
viewModel.onLibrarySelected(library.id, library.name)
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
} }
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
if (selectedDestination != destination) {
backStack.clear()
backStack.add(Route.Home)
if (destination is Route.LibraryRoute) {
backStack.add(destination)
}
}
},
modifier = modifier.fillMaxSize()
) {
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
navigationManager.pop()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = tvEntryProvider,
modifier = Modifier.fillMaxSize()
)
} }
} }

View File

@@ -1,15 +1,10 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import hu.bbara.purefin.navigation.Route
enum class TvDrawerDestination {
HOME,
LIBRARIES
}
data class TvDrawerDestinationItem( data class TvDrawerDestinationItem(
val destination: TvDrawerDestination, val destination: Route,
val label: String, val label: String,
val icon: ImageVector, val icon: ImageVector
val selected: Boolean = false
) )

View File

@@ -30,9 +30,10 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
import androidx.tv.material3.darkColorScheme import androidx.tv.material3.darkColorScheme
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.tv.R import hu.bbara.purefin.tv.R
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-" internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
internal const val TvDrawerTitleTag = "tv-drawer-title" internal const val TvDrawerTitleTag = "tv-drawer-title"
@@ -43,8 +44,8 @@ private val TvDrawerExpandedWidth = 280.dp
@Composable @Composable
fun TvNavigationDrawer( fun TvNavigationDrawer(
destinations: List<TvDrawerDestinationItem>, destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination, selectedDestination: Route,
onDestinationSelected: (TvDrawerDestination) -> Unit, onDestinationSelected: (Route) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
@@ -132,15 +133,15 @@ fun TvDrawerHeader(
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail( private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
drawerValue: DrawerValue, drawerValue: DrawerValue,
destinations: List<TvDrawerDestinationItem>, destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination, selectedDestination: Route,
onDestinationSelected: (TvDrawerDestination) -> Unit, onDestinationSelected: (Route) -> Unit,
) { ) {
val expanded = drawerValue == DrawerValue.Open val expanded = drawerValue == DrawerValue.Open
val drawerWidth = animateDpAsState( val drawerWidth = animateDpAsState(
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth, targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
label = "tv-drawer-width" label = "tv-drawer-width"
) )
val scheme = androidx.tv.material3.MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box( Box(
modifier = Modifier modifier = Modifier

View File

@@ -4,15 +4,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -22,16 +19,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.library.LibraryViewModel import hu.bbara.purefin.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.ui.common.button.PurefinIconButton import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import java.util.UUID import hu.bbara.purefin.ui.model.MediaUiModel
@Composable @Composable
fun TvLibraryScreen( fun TvLibraryScreen(
library: LibraryDto, library: LibraryDto,
onMediaSelected: (MediaUiModel) -> Unit,
viewModel: LibraryViewModel = hiltViewModel(), viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -45,15 +41,13 @@ fun TvLibraryScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TvLibraryTopBar( TvLibraryTopBar(
title = library.name, title = library.name
onBack = viewModel::onBack
) )
} }
) { innerPadding -> ) { innerPadding ->
TvLibraryContent( TvLibraryContent(
libraryItems = libraryItems.value, libraryItems = libraryItems.value,
onMovieSelected = viewModel::onMovieSelected, onMediaSelected = onMediaSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
} }
@@ -61,40 +55,23 @@ fun TvLibraryScreen(
@Composable @Composable
private fun TvLibraryTopBar( private fun TvLibraryTopBar(
title: String, title: String
onBack: () -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PurefinIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.onPrimary,
focusedBackgroundColor = scheme.primary
)
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 12.dp) modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
) )
}
} }
@Composable @Composable
fun TvLibraryContent( fun TvLibraryContent(
libraryItems: List<MediaUiModel>, libraryItems: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onSeriesSelected: (UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@@ -108,9 +85,9 @@ fun TvLibraryContent(
items(libraryItems, key = { item -> item.id }) { item -> items(libraryItems, key = { item -> item.id }) { item ->
PosterCard( PosterCard(
item = item, item = item,
onMovieSelected = onMovieSelected, onMovieSelected = { onMediaSelected(item) },
onSeriesSelected = onSeriesSelected, onSeriesSelected = { onMediaSelected(item) },
onEpisodeSelected = { _, _, _ -> } onEpisodeSelected = { _, _, _ -> onMediaSelected(item) }
) )
} }
} }