mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Replace TV tabs with navigation drawer
This commit is contained in:
@@ -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 = {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = "Home",
|
||||||
label = "Settings",
|
icon = Icons.Outlined.Home,
|
||||||
icon = Icons.Outlined.Settings,
|
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) {
|
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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user