Replace TV tabs with navigation drawer

This commit is contained in:
2026-04-06 13:08:56 +02:00
parent df67348f24
commit 6dc3442089
10 changed files with 590 additions and 338 deletions

View File

@@ -13,7 +13,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key 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.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -34,7 +33,6 @@ class EpisodeScreenContentTest {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = 63.0), episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance", seriesTitle = "Severance",
topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}),
onBack = {}, onBack = {},
onPlay = {} onPlay = {}
) )
@@ -60,16 +58,12 @@ class EpisodeScreenContentTest {
} }
@Test @Test
fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() { fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
composeRule.setContent { composeRule.setContent {
AppTheme { AppTheme {
EpisodeScreenContent( EpisodeScreenContent(
episode = sampleEpisode(progress = null), episode = sampleEpisode(progress = null),
seriesTitle = "Severance", seriesTitle = "Severance",
topBarShortcut = episodeTopBarShortcut(
previousRoute = Route.PlayerRoute(mediaId = "episode-4"),
onSeriesClick = {}
),
onBack = {}, onBack = {},
onPlay = {} onPlay = {}
) )

View File

@@ -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<ComponentActivity>()
@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()
}
}

View File

@@ -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<ComponentActivity>()
@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()
}
}

View File

@@ -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<ComponentActivity>()
@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)
}
}

View File

@@ -1,38 +1,31 @@
package hu.bbara.purefin.tv package hu.bbara.purefin.tv
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.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.Settings
import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.Scaffold
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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.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.TvHomeScreen import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.tv.home.ui.TvHomeTabDestination import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvHomeTabItem import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
import hu.bbara.purefin.tv.home.ui.TvHomeTopBar import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
import hu.bbara.purefin.tv.library.ui.TvLibraryContent import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@Composable @Composable
fun TvAppScreen( fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(), viewModel: AppViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val serverUrl by viewModel.serverUrl.collectAsState() val serverUrl by viewModel.serverUrl.collectAsState()
@@ -40,46 +33,28 @@ 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 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) { val destinations = remember(libraries, selectedDestination) {
buildList { listOf(
add( TvDrawerDestinationItem(
TvHomeTabItem( destination = TvDrawerDestination.HOME,
destination = TvHomeTabDestination.SETTINGS,
label = "Settings",
icon = Icons.Outlined.Settings,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.SEARCH,
label = "Search",
icon = Icons.Outlined.Search,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.HOME,
label = "Home", label = "Home",
icon = Icons.Outlined.Home, icon = Icons.Outlined.Home,
) selected = selectedDestination == TvDrawerDestination.HOME
) ),
addAll(libraries.map { TvDrawerDestinationItem(
TvHomeTabItem( destination = TvDrawerDestination.LIBRARIES,
destination = TvHomeTabDestination.LIBRARY, label = "Libraries",
label = it.name, icon = when {
icon = when (it.type) { libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
CollectionType.MOVIES -> Icons.Outlined.Movie libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections else -> Icons.Outlined.Collections
}, },
libraryId = it.id selected = selectedDestination == TvDrawerDestination.LIBRARIES
)
) )
})
}
} }
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
@@ -87,41 +62,16 @@ fun TvAppScreen(
onPauseOrDispose { } onPauseOrDispose { }
} }
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0)) TvNavigationDrawer(
val selectedTab = tabs.getOrNull(safeSelectedTabIndex) destinations = destinations,
selectedDestination = selectedDestination,
LaunchedEffect(selectedTab?.destination, selectedTab?.libraryId) { onDestinationSelected = { destination ->
if (selectedTab?.destination == TvHomeTabDestination.LIBRARY) { selectedDestination = destination
val libraryId = selectedTab.libraryId ?: return@LaunchedEffect },
libraryViewModel.selectLibrary(libraryId) modifier = modifier.fillMaxSize()
} ) {
} when (selectedDestination) {
TvDrawerDestination.HOME -> {
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 -> {
TvHomeScreen( TvHomeScreen(
libraries = libraries, libraries = libraries,
libraryContent = latestLibraryContent, libraryContent = latestLibraryContent,
@@ -131,7 +81,16 @@ fun TvAppScreen(
onMovieSelected = viewModel::onMovieSelected, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected, 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()
) )
} }
} }

View File

@@ -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)
)
}

View File

@@ -1,30 +1,15 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.tv.home.ui
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID
data class TvHomeNavItem( enum class TvDrawerDestination {
val id: UUID, HOME,
LIBRARIES
}
data class TvDrawerDestinationItem(
val destination: TvDrawerDestination,
val label: String, val label: String,
val icon: ImageVector, val icon: ImageVector,
val selected: Boolean = false 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
)

View File

@@ -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<TvHomeTabItem>,
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
)
}
}
}
}

View File

@@ -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<TvDrawerDestinationItem>,
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<TvDrawerDestinationItem>,
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))
}
}
}

View File

@@ -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<LibraryItem>,
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)
)
}
}