Change Navigation Drawler to Top Tabs

This commit is contained in:
2026-03-28 10:50:53 +01:00
parent 4c5dc8a452
commit d1ef218b16
6 changed files with 268 additions and 85 deletions

View File

@@ -2,102 +2,124 @@ package hu.bbara.purefin.tv.home
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.Collections
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.Search
import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.feature.shared.home.AppViewModel import hu.bbara.purefin.feature.shared.home.AppViewModel
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import hu.bbara.purefin.tv.home.ui.TvHomeContent import hu.bbara.purefin.tv.home.ui.TvHomeContent
import hu.bbara.purefin.tv.home.ui.TvHomeDrawerContent import hu.bbara.purefin.tv.home.ui.TvHomeTabDestination
import hu.bbara.purefin.tv.home.ui.TvHomeMockData import hu.bbara.purefin.tv.home.ui.TvHomeTabItem
import hu.bbara.purefin.tv.home.ui.TvHomeNavItem
import hu.bbara.purefin.tv.home.ui.TvHomeTopBar import hu.bbara.purefin.tv.home.ui.TvHomeTopBar
import kotlinx.coroutines.launch import hu.bbara.purefin.tv.library.ui.TvLibraryContent
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@Composable @Composable
fun TvHomePage( fun TvHomePage(
viewModel: AppViewModel = hiltViewModel(), viewModel: AppViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) var selectedTabIndex by remember { mutableIntStateOf(1) }
val coroutineScope = rememberCoroutineScope() val libraries by viewModel.libraries.collectAsState()
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
val libraries = viewModel.libraries.collectAsState().value val tabs = remember(libraries) {
val isOfflineMode = viewModel.isOfflineMode.collectAsState().value buildList {
val libraryNavItems = libraries.map { add(
TvHomeNavItem( TvHomeTabItem(
id = it.id, destination = TvHomeTabDestination.SEARCH,
label = "Search",
icon = Icons.Outlined.Search,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.HOME,
label = "Home",
icon = Icons.Outlined.Home,
)
)
addAll(libraries.map {
TvHomeTabItem(
destination = TvHomeTabDestination.LIBRARY,
label = it.name, label = it.name,
icon = when (it.type) { icon = when (it.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections else -> Icons.Outlined.Collections
}, },
libraryId = it.id
) )
})
}
} }
val continueWatching = viewModel.continueWatching.collectAsState()
val nextUp = viewModel.nextUp.collectAsState()
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.onResumed() viewModel.onResumed()
onPauseOrDispose { } onPauseOrDispose { }
} }
ModalNavigationDrawer( val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
drawerState = drawerState, val selectedTab = tabs.getOrNull(safeSelectedTabIndex)
drawerContent = {
ModalDrawerSheet( LaunchedEffect(selectedTab?.destination, selectedTab?.libraryId) {
modifier = Modifier if (selectedTab?.destination == TvHomeTabDestination.LIBRARY) {
.width(280.dp) val libraryId = selectedTab.libraryId ?: return@LaunchedEffect
.fillMaxSize(), libraryViewModel.selectLibrary(libraryId)
drawerContainerColor = MaterialTheme.colorScheme.surface,
drawerContentColor = MaterialTheme.colorScheme.onBackground
) {
TvHomeDrawerContent(
title = "Jellyfin",
subtitle = "Library Dashboard",
primaryNavItems = libraryNavItems,
secondaryNavItems = TvHomeMockData.secondaryNavItems,
user = TvHomeMockData.user,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
onLogout = viewModel::logout
)
} }
} }
) {
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
TvHomeTopBar( TvHomeTopBar(
onMenuClick = { coroutineScope.launch { drawerState.open() } }, tabs = tabs,
selectedTabIndex = safeSelectedTabIndex,
onTabSelected = { index, _ ->
selectedTabIndex = index
},
isOfflineMode = isOfflineMode, isOfflineMode = isOfflineMode,
onToggleOfflineMode = viewModel::toggleOfflineMode onToggleOfflineMode = viewModel::toggleOfflineMode
) )
} }
) { innerPadding -> ) { innerPadding ->
when (selectedTab?.destination) {
TvHomeTabDestination.LIBRARY -> {
TvLibraryContent(
libraryItems = selectedLibraryItems,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding)
)
}
TvHomeTabDestination.SEARCH,
TvHomeTabDestination.HOME,
null -> {
TvHomeContent( TvHomeContent(
libraries = libraries, libraries = libraries,
libraryContent = latestLibraryContent.value, libraryContent = latestLibraryContent,
continueWatching = continueWatching.value, continueWatching = continueWatching,
nextUp = nextUp.value, nextUp = nextUp,
onMovieSelected = viewModel::onMovieSelected, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected, onEpisodeSelected = viewModel::onEpisodeSelected,
@@ -106,3 +128,4 @@ fun TvHomePage(
} }
} }
} }
}

View File

@@ -10,6 +10,19 @@ data class TvHomeNavItem(
val selected: Boolean = false val selected: Boolean = false
) )
enum class TvHomeTabDestination {
SEARCH,
HOME,
LIBRARY
}
data class TvHomeTabItem(
val destination: TvHomeTabDestination,
val label: String,
val icon: ImageVector,
val libraryId: UUID? = null
)
data class TvHomeUser( data class TvHomeUser(
val name: String, val name: String,
val plan: String val plan: String

View File

@@ -10,24 +10,31 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.Menu import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import hu.bbara.purefin.common.ui.components.PurefinIconButton import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.common.ui.components.SearchField
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TvHomeTopBar( fun TvHomeTopBar(
onMenuClick: () -> Unit, tabs: List<TvHomeTabItem>,
selectedTabIndex: Int,
onTabSelected: (Int, TvHomeTabItem) -> Unit,
isOfflineMode: Boolean, isOfflineMode: Boolean,
onToggleOfflineMode: () -> Unit, onToggleOfflineMode: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
Box( Box(
modifier = modifier modifier = modifier
@@ -41,19 +48,33 @@ fun TvHomeTopBar(
.padding(horizontal = 16.dp, vertical = 16.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
PurefinIconButton( PrimaryScrollableTabRow(
icon = Icons.Outlined.Menu, selectedTabIndex = safeSelectedTabIndex,
contentDescription = "Menu", modifier = Modifier.weight(1f),
onClick = onMenuClick, containerColor = scheme.surface,
contentColor = scheme.onSurface
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = index == safeSelectedTabIndex,
onClick = { onTabSelected(index, tab) },
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = tab.icon,
contentDescription = null
) )
SearchField( Text(text = tab.label)
value = "", }
onValueChange = {}, }
placeholder = "Search",
modifier = Modifier.weight(1.0f, true),
) )
}
}
PurefinIconButton( PurefinIconButton(
icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud, icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud,
contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline", contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline",

View File

@@ -0,0 +1,113 @@
package hu.bbara.purefin.tv.library.ui
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import org.jellyfin.sdk.model.UUID
@Composable
fun TvLibraryScreen(
library: LibraryDto,
viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(library) {
viewModel.selectLibrary(libraryId = library.id)
}
val libraryItems = viewModel.contents.collectAsState()
Scaffold(
modifier = modifier,
topBar = {
TvLibraryTopBar(
title = library.name,
onBack = viewModel::onBack
)
}
) { innerPadding ->
TvLibraryContent(
libraryItems = libraryItems.value,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding)
)
}
}
@Composable
private fun TvLibraryTopBar(
title: String,
onBack: () -> Unit
) {
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
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
fun TvLibraryContent(
libraryItems: List<PosterItem>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
items(libraryItems) { item ->
PosterCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
}

View File

@@ -47,4 +47,10 @@ object TvNavigationModule {
fun provideTvPlayerEntryBuilder(): EntryProviderScope<Route>.() -> Unit = { fun provideTvPlayerEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvPlayerSection() tvPlayerSection()
} }
@IntoSet
@Provides
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvLibrarySection()
}
} }

View File

@@ -8,6 +8,7 @@ import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.home.TvHomePage import hu.bbara.purefin.tv.home.TvHomePage
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen
import hu.bbara.purefin.tv.player.TvPlayerScreen import hu.bbara.purefin.tv.player.TvPlayerScreen
fun EntryProviderScope<Route>.tvHomeSection() { fun EntryProviderScope<Route>.tvHomeSection() {
@@ -49,3 +50,9 @@ fun EntryProviderScope<Route>.tvPlayerSection() {
) )
} }
} }
fun EntryProviderScope<Route>.tvLibrarySection() {
entry<Route.LibraryRoute> { route ->
TvLibraryScreen(library = route.library)
}
}