From 6dc3442089d56de8336bcda5884c9e785a915ea7 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 6 Apr 2026 13:08:56 +0200 Subject: [PATCH] Replace TV tabs with navigation drawer --- .../episode/EpisodeScreenContentTest.kt | 8 +- .../purefin/tv/home/ui/TvHomeTopBarTest.kt | 61 ------ .../tv/home/ui/TvNavigationDrawerTest.kt | 99 +++++++++ .../ui/TvLibrariesOverviewScreenTest.kt | 56 +++++ .../java/hu/bbara/purefin/tv/TvAppScreen.kt | 129 ++++-------- .../purefin/tv/home/ui/TvHomeMockData.kt | 24 --- .../bbara/purefin/tv/home/ui/TvHomeModels.kt | 29 +-- .../bbara/purefin/tv/home/ui/TvHomeTopBar.kt | 139 ------------- .../purefin/tv/home/ui/TvNavigationDrawer.kt | 189 +++++++++++++++++ .../library/ui/TvLibrariesOverviewScreen.kt | 194 ++++++++++++++++++ 10 files changed, 590 insertions(+), 338 deletions(-) delete mode 100644 app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt create mode 100644 app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawerTest.kt create mode 100644 app-tv/src/androidTest/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreenTest.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt create mode 100644 app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawer.kt create mode 100644 app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreen.kt diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt index f1767240..5df50b94 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/app/content/episode/EpisodeScreenContentTest.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.input.key.Key -import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.ui.theme.AppTheme @@ -34,7 +33,6 @@ class EpisodeScreenContentTest { EpisodeScreenContent( episode = sampleEpisode(progress = 63.0), seriesTitle = "Severance", - topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}), onBack = {}, onPlay = {} ) @@ -60,16 +58,12 @@ class EpisodeScreenContentTest { } @Test - fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() { + fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() { composeRule.setContent { AppTheme { EpisodeScreenContent( episode = sampleEpisode(progress = null), seriesTitle = "Severance", - topBarShortcut = episodeTopBarShortcut( - previousRoute = Route.PlayerRoute(mediaId = "episode-4"), - onSeriesClick = {} - ), onBack = {}, onPlay = {} ) diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt deleted file mode 100644 index 12d11375..00000000 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBarTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package hu.bbara.purefin.tv.home.ui - -import androidx.activity.ComponentActivity -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Search -import androidx.compose.ui.test.assertIsNotSelected -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.setValue -import hu.bbara.purefin.ui.theme.AppTheme -import org.junit.Rule -import org.junit.Test - -class TvHomeTopBarTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - @Test - fun tvHomeTopBar_updatesSelectedTab() { - var selectedTabIndex by mutableIntStateOf(1) - - composeRule.setContent { - AppTheme { - TvHomeTopBar( - tabs = listOf( - TvHomeTabItem( - destination = TvHomeTabDestination.SEARCH, - label = "Search", - icon = Icons.Outlined.Search - ), - TvHomeTabItem( - destination = TvHomeTabDestination.HOME, - label = "Home", - icon = Icons.Outlined.Home - ) - ), - selectedTabIndex = selectedTabIndex, - onTabSelected = { index, _ -> - selectedTabIndex = index - } - ) - } - } - - composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsSelected() - composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0") - .assertIsNotSelected() - .performClick() - - composeRule.waitForIdle() - - composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0").assertIsSelected() - composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsNotSelected() - } -} diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawerTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawerTest.kt new file mode 100644 index 00000000..c2b8f28f --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawerTest.kt @@ -0,0 +1,99 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.Home +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +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.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +class TvNavigationDrawerTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tvDrawerHeader_onlyShowsTitleWhenExpanded() { + var expanded by mutableStateOf(false) + + composeRule.setContent { + AppTheme { + TvDrawerHeader(expanded = expanded) + } + } + + composeRule.onAllNodesWithTag(TvDrawerTitleTag).assertCountEquals(0) + + composeRule.runOnUiThread { + expanded = true + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TvDrawerTitleTag).assertIsDisplayed() + } + + @Test + fun tvNavigationDrawer_updatesSelectedDestination() { + var selectedDestination by mutableStateOf(TvDrawerDestination.HOME) + + composeRule.setContent { + AppTheme { + TvNavigationDrawer( + destinations = listOf( + TvDrawerDestinationItem( + destination = TvDrawerDestination.HOME, + label = "Home", + icon = Icons.Outlined.Home + ), + TvDrawerDestinationItem( + destination = TvDrawerDestination.LIBRARIES, + label = "Libraries", + icon = Icons.Outlined.Collections + ) + ), + selectedDestination = selectedDestination, + onDestinationSelected = { destination -> + selectedDestination = destination + } + ) { + Box(modifier = Modifier.size(320.dp)) + } + } + } + + composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsSelected() + composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1") + .assertIsNotSelected() + .performSemanticsAction(SemanticsActions.RequestFocus) + .performKeyInput { + pressKey(Key.DirectionCenter) + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1").assertIsSelected() + composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsNotSelected() + } +} diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreenTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreenTest.kt new file mode 100644 index 00000000..327bdf23 --- /dev/null +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreenTest.kt @@ -0,0 +1,56 @@ +package hu.bbara.purefin.tv.library.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import hu.bbara.purefin.feature.shared.home.LibraryItem +import hu.bbara.purefin.ui.theme.AppTheme +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class TvLibrariesOverviewScreenTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tvLibrariesOverviewScreen_opensSelectedLibrary() { + val libraries = listOf( + LibraryItem( + id = UUID.fromString("11111111-1111-1111-1111-111111111111"), + name = "Movies", + type = CollectionType.MOVIES, + posterUrl = "", + isEmpty = false + ), + LibraryItem( + id = UUID.fromString("22222222-2222-2222-2222-222222222222"), + name = "Shows", + type = CollectionType.TVSHOWS, + posterUrl = "", + isEmpty = false + ) + ) + var openedLibrary: LibraryItem? = null + + composeRule.setContent { + AppTheme { + TvLibrariesOverviewScreen( + libraries = libraries, + onLibrarySelected = { library -> + openedLibrary = library + } + ) + } + } + + composeRule.onNodeWithTag("${TvLibrariesOverviewItemTagPrefix}1").performClick() + composeRule.waitForIdle() + + assertEquals(libraries[1], openedLibrary) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvAppScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvAppScreen.kt index 81565a84..af78ebfe 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/TvAppScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvAppScreen.kt @@ -1,38 +1,31 @@ package hu.bbara.purefin.tv import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding 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.Settings import androidx.compose.material.icons.outlined.Tv -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf 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.feature.shared.home.AppViewModel -import hu.bbara.purefin.feature.shared.library.LibraryViewModel import hu.bbara.purefin.tv.home.TvHomeScreen -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 hu.bbara.purefin.tv.library.ui.TvLibraryContent +import hu.bbara.purefin.tv.home.ui.TvDrawerDestination +import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem +import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer +import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen import org.jellyfin.sdk.model.api.CollectionType @Composable fun TvAppScreen( viewModel: AppViewModel = hiltViewModel(), - libraryViewModel: LibraryViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { val serverUrl by viewModel.serverUrl.collectAsState() @@ -40,46 +33,28 @@ fun TvAppScreen( val continueWatching by viewModel.continueWatching.collectAsState() val nextUp by viewModel.nextUp.collectAsState() val latestLibraryContent by viewModel.latestLibraryContent.collectAsState() - val selectedLibraryItems by libraryViewModel.contents.collectAsState() - var selectedTabIndex by remember { mutableIntStateOf(1) } + var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) } - val tabs = remember(libraries) { - buildList { - add( - TvHomeTabItem( - destination = TvHomeTabDestination.SETTINGS, - label = "Settings", - icon = Icons.Outlined.Settings, - ) + val destinations = remember(libraries, selectedDestination) { + listOf( + TvDrawerDestinationItem( + destination = TvDrawerDestination.HOME, + label = "Home", + icon = Icons.Outlined.Home, + selected = selectedDestination == TvDrawerDestination.HOME + ), + TvDrawerDestinationItem( + destination = TvDrawerDestination.LIBRARIES, + label = "Libraries", + icon = when { + libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie + libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv + else -> Icons.Outlined.Collections + }, + selected = selectedDestination == TvDrawerDestination.LIBRARIES ) - 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 - ) - }) - } + ) } LifecycleResumeEffect(Unit) { @@ -87,41 +62,16 @@ fun TvAppScreen( onPauseOrDispose { } } - 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(), - topBar = { - TvHomeTopBar( - tabs = tabs, - selectedTabIndex = safeSelectedTabIndex, - onTabSelected = { index, _ -> - selectedTabIndex = index - } - ) - } - ) { innerPadding -> - when (selectedTab?.destination) { - TvHomeTabDestination.SETTINGS, - TvHomeTabDestination.LIBRARY -> { - TvLibraryContent( - libraryItems = selectedLibraryItems, - onMovieSelected = viewModel::onMovieSelected, - onSeriesSelected = viewModel::onSeriesSelected, - modifier = Modifier.padding(innerPadding) - ) - } - TvHomeTabDestination.SEARCH, - TvHomeTabDestination.HOME, - null -> { + TvNavigationDrawer( + destinations = destinations, + selectedDestination = selectedDestination, + onDestinationSelected = { destination -> + selectedDestination = destination + }, + modifier = modifier.fillMaxSize() + ) { + when (selectedDestination) { + TvDrawerDestination.HOME -> { TvHomeScreen( libraries = libraries, libraryContent = latestLibraryContent, @@ -131,7 +81,16 @@ fun TvAppScreen( onMovieSelected = viewModel::onMovieSelected, onSeriesSelected = viewModel::onSeriesSelected, onEpisodeSelected = viewModel::onEpisodeSelected, - modifier = Modifier.padding(innerPadding) + modifier = Modifier.fillMaxSize() + ) + } + TvDrawerDestination.LIBRARIES -> { + TvLibrariesOverviewScreen( + libraries = libraries, + onLibrarySelected = { library -> + viewModel.onLibrarySelected(library.id, library.name) + }, + modifier = Modifier.fillMaxSize() ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt deleted file mode 100644 index 4ab94328..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeMockData.kt +++ /dev/null @@ -1,24 +0,0 @@ -package hu.bbara.purefin.tv.home.ui - -import androidx.compose.material.icons.Icons -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.Settings -import androidx.compose.material.icons.outlined.Tv -import org.jellyfin.sdk.model.UUID - -object TvHomeMockData { - val user = TvHomeUser(name = "Alex User", plan = "Premium Account") - - val primaryNavItems = listOf( - TvHomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true), - TvHomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie), - TvHomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv), - TvHomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search) - ) - - val secondaryNavItems = listOf( - TvHomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings) - ) -} 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 ce1f8cb9..f35c8495 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 @@ -1,30 +1,15 @@ package hu.bbara.purefin.tv.home.ui import androidx.compose.ui.graphics.vector.ImageVector -import org.jellyfin.sdk.model.UUID -data class TvHomeNavItem( - val id: UUID, +enum class TvDrawerDestination { + HOME, + LIBRARIES +} + +data class TvDrawerDestinationItem( + val destination: TvDrawerDestination, val label: String, val icon: ImageVector, val selected: Boolean = false ) - -enum class TvHomeTabDestination { - SEARCH, - HOME, - LIBRARY, - SETTINGS -} - -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 deleted file mode 100644 index 9bcb7628..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvHomeTopBar.kt +++ /dev/null @@ -1,139 +0,0 @@ -package hu.bbara.purefin.tv.home.ui - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.BorderStroke -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch - -internal const val TvHomeTabTagPrefix = "tv-home-tab-" -private val TvHomeTopBarPillShape = RoundedCornerShape(18.dp) - -@Composable -fun TvHomeTopBar( - tabs: List, - selectedTabIndex: Int, - onTabSelected: (Int, TvHomeTabItem) -> Unit, - modifier: Modifier = Modifier, -) { - val scheme = MaterialTheme.colorScheme - val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0)) - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - - LaunchedEffect(safeSelectedTabIndex) { - if (tabs.isNotEmpty()) { - listState.animateScrollToItem(safeSelectedTabIndex) - } - } - - LazyRow( - state = listState, - modifier = modifier - .fillMaxWidth() - .background(scheme.background), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - itemsIndexed(items = tabs) { index, tab -> - var isFocused by remember { mutableStateOf(false) } - val isSelected = index == safeSelectedTabIndex - val containerColor by animateColorAsState( - targetValue = when { - isSelected -> scheme.surfaceContainerHigh - isFocused -> scheme.surfaceContainerHigh.copy(alpha = 0.92f) - else -> scheme.surfaceContainer.copy(alpha = 0.72f) - }, - label = "tv-home-tab-container" - ) - val contentColor by animateColorAsState( - targetValue = if (isSelected || isFocused) { - scheme.onBackground - } else { - scheme.onSurfaceVariant - }, - label = "tv-home-tab-content" - ) - val borderColor by animateColorAsState( - targetValue = when { - isFocused -> scheme.primary - isSelected -> scheme.outline - else -> scheme.outlineVariant.copy(alpha = 0.6f) - }, - label = "tv-home-tab-border" - ) - val scale by animateFloatAsState( - targetValue = if (isFocused) 1.03f else 1f, - label = "tv-home-tab-scale" - ) - - OutlinedButton( - onClick = { onTabSelected(index, tab) }, - modifier = Modifier - .testTag("$TvHomeTabTagPrefix$index") - .semantics { selected = isSelected } - .heightIn(min = 46.dp) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .onFocusChanged { - isFocused = it.isFocused - if (it.isFocused) { - scope.launch { - listState.animateScrollToItem(index) - } - } - }, - shape = TvHomeTopBarPillShape, - border = BorderStroke(1.dp, borderColor), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = containerColor, - contentColor = contentColor - ), - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp) - ) { - Icon( - imageVector = tab.icon, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) - ) - Text( - text = tab.label, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium - ) - } - } - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawer.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawer.kt new file mode 100644 index 00000000..fff51ff0 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/ui/TvNavigationDrawer.kt @@ -0,0 +1,189 @@ +package hu.bbara.purefin.tv.home.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.DrawerValue +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.tv.R + +internal const val TvDrawerItemTagPrefix = "tv-drawer-item-" +internal const val TvDrawerTitleTag = "tv-drawer-title" + +private val TvDrawerCollapsedWidth = 92.dp +private val TvDrawerExpandedWidth = 280.dp + +@Composable +fun TvNavigationDrawer( + destinations: List, + selectedDestination: TvDrawerDestination, + onDestinationSelected: (TvDrawerDestination) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + ProvideTvDrawerTheme { + NavigationDrawer( + modifier = modifier.fillMaxSize(), + drawerContent = { + TvNavigationDrawerRail( + drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed, + destinations = destinations, + selectedDestination = selectedDestination, + onDestinationSelected = onDestinationSelected + ) + }, + content = content + ) + } +} + +@Composable +private fun ProvideTvDrawerTheme(content: @Composable () -> Unit) { + val scheme = MaterialTheme.colorScheme + + TvMaterialTheme( + colorScheme = darkColorScheme( + primary = scheme.primary, + onPrimary = scheme.onPrimary, + primaryContainer = scheme.primaryContainer, + onPrimaryContainer = scheme.onPrimaryContainer, + inversePrimary = scheme.inversePrimary, + secondary = scheme.secondary, + onSecondary = scheme.onSecondary, + secondaryContainer = scheme.secondaryContainer, + onSecondaryContainer = scheme.onSecondaryContainer, + tertiary = scheme.tertiary, + onTertiary = scheme.onTertiary, + tertiaryContainer = scheme.tertiaryContainer, + onTertiaryContainer = scheme.onTertiaryContainer, + background = scheme.background, + onBackground = scheme.onBackground, + surface = scheme.surface, + onSurface = scheme.onSurface, + surfaceVariant = scheme.surfaceVariant, + onSurfaceVariant = scheme.onSurfaceVariant, + surfaceTint = scheme.surfaceTint, + inverseSurface = scheme.inverseSurface, + inverseOnSurface = scheme.inverseOnSurface, + error = scheme.error, + onError = scheme.onError, + errorContainer = scheme.errorContainer, + onErrorContainer = scheme.onErrorContainer, + border = scheme.outline, + borderVariant = scheme.outlineVariant, + scrim = scheme.scrim + ), + content = content + ) +} + +@Composable +fun TvDrawerHeader( + expanded: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Image( + painter = painterResource(id = R.mipmap.purefin_logo_foreground), + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + AnimatedVisibility(visible = expanded) { + Text( + text = "Purefin", + modifier = Modifier.testTag(TvDrawerTitleTag) + ) + } + } +} + +@Composable +private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail( + drawerValue: DrawerValue, + destinations: List, + selectedDestination: TvDrawerDestination, + onDestinationSelected: (TvDrawerDestination) -> 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 + + Box( + modifier = Modifier + .width(drawerWidth.value) + .fillMaxHeight() + .background(scheme.surface.copy(alpha = 0.96f)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 24.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + TvDrawerHeader( + expanded = expanded, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + destinations.forEachIndexed { index, destination -> + val isSelected = destination.destination == selectedDestination + NavigationDrawerItem( + selected = isSelected, + onClick = { onDestinationSelected(destination.destination) }, + modifier = Modifier + .testTag("$TvDrawerItemTagPrefix$index") + .semantics { selected = isSelected }, + leadingContent = { + Icon( + imageVector = destination.icon, + contentDescription = destination.label + ) + } + ) { + if (expanded) { + Text( + text = destination.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + Spacer(modifier = Modifier.weight(1f)) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreen.kt new file mode 100644 index 00000000..bb25aa99 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/library/ui/TvLibrariesOverviewScreen.kt @@ -0,0 +1,194 @@ +package hu.bbara.purefin.tv.library.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.feature.shared.home.LibraryItem +import org.jellyfin.sdk.model.api.CollectionType + +internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-" + +private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp) + +@Composable +fun TvLibrariesOverviewScreen( + libraries: List, + onLibrarySelected: (LibraryItem) -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val firstItemFocusRequester = remember { FocusRequester() } + var initialFocusApplied by remember { mutableStateOf(false) } + + LaunchedEffect(libraries.size, initialFocusApplied) { + if (!initialFocusApplied && libraries.isNotEmpty()) { + firstItemFocusRequester.requestFocus() + initialFocusApplied = true + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(scheme.background) + .padding(top = 24.dp) + ) { + Text( + text = "Libraries", + style = MaterialTheme.typography.headlineMedium, + color = scheme.onBackground, + modifier = Modifier.padding(horizontal = 28.dp) + ) + if (libraries.isEmpty()) { + Text( + text = "No libraries available", + style = MaterialTheme.typography.bodyLarge, + color = scheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp) + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 220.dp), + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(libraries, key = { _, item -> item.id }) { index, item -> + TvLibraryOverviewCard( + item = item, + onClick = { onLibrarySelected(item) }, + modifier = Modifier + .then( + if (index == 0) { + Modifier.focusRequester(firstItemFocusRequester) + } else { + Modifier + } + ) + .testTag("$TvLibrariesOverviewItemTagPrefix$index") + ) + } + } + } +} + +@Composable +private fun TvLibraryOverviewCard( + item: LibraryItem, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.05f else 1f, + label = "tv-library-overview-scale" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 0f) + } + ) { + Box( + modifier = Modifier + .aspectRatio(16f / 9f) + .clip(TvLibrariesOverviewCardShape) + .border( + width = if (isFocused) 2.dp else 1.dp, + color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.45f), + shape = TvLibrariesOverviewCardShape + ) + .background(scheme.surfaceVariant) + .onFocusChanged { isFocused = it.isFocused } + .clickable(onClick = onClick) + ) { + if (item.posterUrl.isNotBlank()) { + PurefinAsyncImage( + model = item.posterUrl, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + imageVector = when (item.type) { + CollectionType.MOVIES -> Icons.Outlined.Movie + CollectionType.TVSHOWS -> Icons.Outlined.Tv + else -> Icons.Outlined.Collections + }, + contentDescription = null, + tint = scheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + } + } + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = scheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 10.dp) + ) + Text( + text = if (item.isEmpty) "Empty library" else "Open library", + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } +}