diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt index 0ff8863..edb4a83 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt @@ -2,107 +2,130 @@ package hu.bbara.purefin.tv.home import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width 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.Search 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.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect 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.TvHomeDrawerContent -import hu.bbara.purefin.tv.home.ui.TvHomeMockData -import hu.bbara.purefin.tv.home.ui.TvHomeNavItem +import hu.bbara.purefin.tv.home.ui.TvHomeTabDestination +import hu.bbara.purefin.tv.home.ui.TvHomeTabItem 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 @Composable fun TvHomePage( viewModel: AppViewModel = hiltViewModel(), + libraryViewModel: LibraryViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val coroutineScope = rememberCoroutineScope() + var selectedTabIndex by remember { mutableIntStateOf(1) } + 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 isOfflineMode = viewModel.isOfflineMode.collectAsState().value - val libraryNavItems = libraries.map { - TvHomeNavItem( - id = it.id, - label = it.name, - icon = when (it.type) { - CollectionType.MOVIES -> Icons.Outlined.Movie - CollectionType.TVSHOWS -> Icons.Outlined.Tv - else -> Icons.Outlined.Collections - }, - ) + val tabs = remember(libraries) { + buildList { + add( + TvHomeTabItem( + 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, + icon = when (it.type) { + CollectionType.MOVIES -> Icons.Outlined.Movie + CollectionType.TVSHOWS -> Icons.Outlined.Tv + else -> Icons.Outlined.Collections + }, + libraryId = it.id + ) + }) + } } - val continueWatching = viewModel.continueWatching.collectAsState() - val nextUp = viewModel.nextUp.collectAsState() - val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() LifecycleResumeEffect(Unit) { viewModel.onResumed() onPauseOrDispose { } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - ModalDrawerSheet( - modifier = Modifier - .width(280.dp) - .fillMaxSize(), - 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 - ) - } + val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0)) + val selectedTab = tabs.getOrNull(safeSelectedTabIndex) + + LaunchedEffect(selectedTab?.destination, selectedTab?.libraryId) { + if (selectedTab?.destination == TvHomeTabDestination.LIBRARY) { + val libraryId = selectedTab.libraryId ?: return@LaunchedEffect + libraryViewModel.selectLibrary(libraryId) } - ) { - Scaffold( - modifier = modifier.fillMaxSize(), - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - topBar = { - TvHomeTopBar( - onMenuClick = { coroutineScope.launch { drawerState.open() } }, - isOfflineMode = isOfflineMode, - onToggleOfflineMode = viewModel::toggleOfflineMode + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TvHomeTopBar( + tabs = tabs, + selectedTabIndex = safeSelectedTabIndex, + onTabSelected = { index, _ -> + selectedTabIndex = index + }, + isOfflineMode = isOfflineMode, + onToggleOfflineMode = viewModel::toggleOfflineMode + ) + } + ) { 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( + libraries = libraries, + libraryContent = latestLibraryContent, + continueWatching = continueWatching, + nextUp = nextUp, + onMovieSelected = viewModel::onMovieSelected, + onSeriesSelected = viewModel::onSeriesSelected, + onEpisodeSelected = viewModel::onEpisodeSelected, + modifier = Modifier.padding(innerPadding) ) } - ) { innerPadding -> - TvHomeContent( - libraries = libraries, - libraryContent = latestLibraryContent.value, - continueWatching = continueWatching.value, - nextUp = nextUp.value, - onMovieSelected = viewModel::onMovieSelected, - onSeriesSelected = viewModel::onSeriesSelected, - onEpisodeSelected = viewModel::onEpisodeSelected, - modifier = Modifier.padding(innerPadding) - ) } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt index 585817d..3eb9038 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeModels.kt @@ -10,6 +10,19 @@ data class TvHomeNavItem( 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( val name: String, val plan: String diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt index afbdbfb..99766d9 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt @@ -10,24 +10,31 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cloud 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.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import hu.bbara.purefin.common.ui.components.PurefinIconButton -import hu.bbara.purefin.common.ui.components.SearchField +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TvHomeTopBar( - onMenuClick: () -> Unit, + tabs: List, + selectedTabIndex: Int, + onTabSelected: (Int, TvHomeTabItem) -> Unit, isOfflineMode: Boolean, onToggleOfflineMode: () -> Unit, modifier: Modifier = Modifier, ) { val scheme = MaterialTheme.colorScheme + val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0)) Box( modifier = modifier @@ -41,19 +48,33 @@ fun TvHomeTopBar( .padding(horizontal = 16.dp, vertical = 16.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - PurefinIconButton( - icon = Icons.Outlined.Menu, - contentDescription = "Menu", - onClick = onMenuClick, - ) - SearchField( - value = "", - onValueChange = {}, - placeholder = "Search", - modifier = Modifier.weight(1.0f, true), - ) + PrimaryScrollableTabRow( + selectedTabIndex = safeSelectedTabIndex, + modifier = Modifier.weight(1f), + 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 + ) + Text(text = tab.label) + } + } + ) + } + } PurefinIconButton( icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud, contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline", diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibraryScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibraryScreen.kt new file mode 100644 index 0000000..b6deea2 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibraryScreen.kt @@ -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, + 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 = { _, _, _ -> } + ) + } + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt index 1439843..29babcd 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt @@ -47,4 +47,10 @@ object TvNavigationModule { fun provideTvPlayerEntryBuilder(): EntryProviderScope.() -> Unit = { tvPlayerSection() } + + @IntoSet + @Provides + fun provideTvLibraryEntryBuilder(): EntryProviderScope.() -> Unit = { + tvLibrarySection() + } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt index 6172981..e774ffe 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt @@ -8,6 +8,7 @@ import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.tv.home.TvHomePage +import hu.bbara.purefin.tv.library.ui.TvLibraryScreen import hu.bbara.purefin.tv.player.TvPlayerScreen fun EntryProviderScope.tvHomeSection() { @@ -49,3 +50,9 @@ fun EntryProviderScope.tvPlayerSection() { ) } } + +fun EntryProviderScope.tvLibrarySection() { + entry { route -> + TvLibraryScreen(library = route.library) + } +}