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.pressKey
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 org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvNavigationDrawerTest {
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
@Test
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 {
AppTheme {
TvNavigationDrawer(
destinations = listOf(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
destination = Route.Home,
label = "Home",
icon = Icons.Outlined.Home
),
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
destination = libraryRoute,
label = "Movies",
icon = Icons.Outlined.Collections
)
),

View File

@@ -1,6 +1,8 @@
package hu.bbara.purefin.navigation
import androidx.hilt.navigation.compose.hiltViewModel
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.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
@@ -51,6 +53,10 @@ fun EntryProviderScope<Route>.tvPlayerSection() {
fun EntryProviderScope<Route>.tvLibrarySection() {
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie
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.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
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.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.components.TvDrawerDestination
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
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
fun TvAppScreen(
@@ -32,45 +37,41 @@ fun TvAppScreen(
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.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(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
destination = Route.Home,
label = "Home",
icon = Icons.Outlined.Home,
selected = selectedDestination == TvDrawerDestination.HOME
),
icon = Icons.Outlined.Home
)
) + libraries.map { library ->
val destination = Route.LibraryRoute(
library = LibraryDto(id = library.id, name = library.name)
)
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
icon = when {
libraries.any { it.type == LibraryKind.MOVIES } -> Icons.Outlined.Movie
libraries.any { it.type == LibraryKind.SERIES } -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
selected = selectedDestination == TvDrawerDestination.LIBRARIES
)
destination = destination,
label = library.name,
icon = when (library.type) {
LibraryKind.MOVIES -> Icons.Outlined.Movie
LibraryKind.SERIES -> Icons.Outlined.Tv
}
)
}
}
LifecycleResumeEffect(Unit) {
viewModel.onResumed()
onPauseOrDispose { }
}
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
selectedDestination = destination
},
modifier = modifier.fillMaxSize()
) {
when (selectedDestination) {
TvDrawerDestination.HOME -> {
val tvEntryProvider = entryProvider {
entry<Route.Home> {
TvHomeScreen(
libraries = libraries,
libraryContent = latestLibraryContent,
@@ -80,15 +81,44 @@ fun TvAppScreen(
modifier = Modifier.fillMaxSize()
)
}
TvDrawerDestination.LIBRARIES -> {
TvLibrariesOverviewScreen(
libraries = libraries,
onLibrarySelected = { library ->
viewModel.onLibrarySelected(library.id, library.name)
},
entry<Route.LibraryRoute> { route ->
TvLibraryScreen(
library = route.library,
onMediaSelected = viewModel::onMediaSelected,
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
import androidx.compose.ui.graphics.vector.ImageVector
enum class TvDrawerDestination {
HOME,
LIBRARIES
}
import hu.bbara.purefin.navigation.Route
data class TvDrawerDestinationItem(
val destination: TvDrawerDestination,
val destination: Route,
val label: String,
val icon: ImageVector,
val selected: Boolean = false
val icon: ImageVector
)

View File

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

View File

@@ -4,15 +4,12 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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.Scaffold
import androidx.compose.material3.Text
@@ -22,16 +19,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.ui.common.button.PurefinIconButton
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard
import java.util.UUID
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
fun TvLibraryScreen(
library: LibraryDto,
onMediaSelected: (MediaUiModel) -> Unit,
viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
@@ -45,15 +41,13 @@ fun TvLibraryScreen(
modifier = modifier,
topBar = {
TvLibraryTopBar(
title = library.name,
onBack = viewModel::onBack
title = library.name
)
}
) { innerPadding ->
TvLibraryContent(
libraryItems = libraryItems.value,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onMediaSelected = onMediaSelected,
modifier = Modifier.padding(innerPadding)
)
}
@@ -61,40 +55,23 @@ fun TvLibraryScreen(
@Composable
private fun TvLibraryTopBar(
title: String,
onBack: () -> Unit
title: String
) {
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 = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 12.dp)
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
@Composable
fun TvLibraryContent(
libraryItems: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
@@ -108,9 +85,9 @@ fun TvLibraryContent(
items(libraryItems, key = { item -> item.id }) { item ->
PosterCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = { _, _, _ -> }
onMovieSelected = { onMediaSelected(item) },
onSeriesSelected = { onMediaSelected(item) },
onEpisodeSelected = { _, _, _ -> onMediaSelected(item) }
)
}
}