implement library navigation and screen

- Implement `LibraryScreen` with a responsive grid layout for displaying library contents.
- Create `LibraryViewModel` to handle fetching items for a specific library via `JellyfinApiClient`.
- Integrate dynamic library navigation in `HomePage` by mapping Jellyfin user views to drawer navigation items.
- Add `Route.Library` and `Route.Login` to the navigation graph.
- Update `SeriesCard` and `SeriesViewModel` to adjust hero height and use backdrop images for episodes.
- Refactor `HomePageViewModel` to support library selection and rename episode selection logic for consistency.
- Enhance `PosterCard` with default colors and `PlayerActivity` with a black background.
- Remove redundant play button from `SeriesComponents`.
This commit is contained in:
2026-01-20 16:00:04 +01:00
parent 94117b1df2
commit c4347e610c
17 changed files with 174 additions and 30 deletions

View File

@@ -32,7 +32,7 @@ fun SeriesCard(
.fillMaxSize()
.background(SeriesBackgroundDark)
) {
val heroHeight = maxHeight * 0.6f
val heroHeight = maxHeight * 0.4f
Column(
modifier = Modifier
.fillMaxSize()

View File

@@ -127,12 +127,6 @@ internal fun SeriesHero(
)
)
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
PlayButton(size = 80.dp)
}
}
}

View File

@@ -79,7 +79,7 @@ class SeriesViewModel @Inject constructor(
title = episode.name ?: "Unknown",
description = episode.overview ?: "",
duration = "58m",
imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY)
imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.BACKDROP)
)
}
SeriesSeasonUiModel(

View File

@@ -3,6 +3,10 @@ package hu.bbara.purefin.app.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
@@ -17,9 +21,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.ui.HomeDrawerContent
import hu.bbara.purefin.app.home.ui.HomeMockData
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.ui.rememberHomeColors
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun HomePage(
@@ -30,6 +36,17 @@ fun HomePage(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
val libraries = viewModel.libraries.collectAsState().value.map {
HomeNavItem(
id = it.id,
label = it.name,
icon = when (it.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
)
}
val continueWatching = viewModel.continueWatching.collectAsState()
ModalNavigationDrawer(
@@ -46,9 +63,9 @@ fun HomePage(
title = "Jellyfin",
subtitle = "Library Dashboard",
colors = colors,
primaryNavItems = HomeMockData.primaryNavItems,
primaryNavItems = libraries,
secondaryNavItems = HomeMockData.secondaryNavItems,
user = HomeMockData.user
user = HomeMockData.user,
)
}
}

View File

@@ -5,10 +5,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.ContinueWatchingItem
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.LibraryItem
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
@@ -46,6 +48,12 @@ class HomePageViewModel @Inject constructor(
loadHomePageData()
}
fun onLibrarySelected(library : HomeNavItem) {
viewModelScope.launch {
navigationManager.navigate(Route.Library(library = LibraryDto(id = library.id, name = library.label)))
}
}
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.Movie(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
}
@@ -56,7 +64,7 @@ class HomePageViewModel @Inject constructor(
}
}
fun onSelectEpisode(episodeId: String) {
fun onEpisodeSelected(episodeId: String) {
viewModelScope.launch {
navigationManager.navigate(Route.Episode(ItemDto(id = UUID.fromString(episodeId), type = BaseItemKind.EPISODE)))
}
@@ -111,7 +119,8 @@ class HomePageViewModel @Inject constructor(
LibraryItem(
name = it.name!!,
id = it.id,
isEmpty = it.childCount!! == 0
isEmpty = it.childCount!! == 0,
type = it.collectionType!!
)
}
_libraries.value = mappedLibraries

View File

@@ -37,7 +37,6 @@ fun HomeDrawerContent(
secondaryNavItems: List<HomeNavItem>,
user: HomeUser,
modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) {
Column(modifier = modifier.fillMaxSize()) {
HomeDrawerHeader(
@@ -49,7 +48,6 @@ fun HomeDrawerContent(
primaryItems = primaryNavItems,
secondaryItems = secondaryNavItems,
colors = colors,
onNavItemClick = onNavItemClick
)
Spacer(modifier = Modifier.weight(1f))
HomeDrawerFooter(user = user, colors = colors)
@@ -105,7 +103,6 @@ fun HomeDrawerNav(
secondaryItems: List<HomeNavItem>,
colors: HomeColors,
modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) {
Column(
modifier = modifier
@@ -113,7 +110,7 @@ fun HomeDrawerNav(
.padding(vertical = 16.dp)
) {
primaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) }
HomeDrawerNavItem(item = item, colors = colors)
}
if (secondaryItems.isNotEmpty()) {
HorizontalDivider(
@@ -122,7 +119,7 @@ fun HomeDrawerNav(
color = colors.divider
)
secondaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) }
HomeDrawerNavItem(item = item, colors = colors)
}
}
}
@@ -133,7 +130,7 @@ fun HomeDrawerNavItem(
item: HomeNavItem,
colors: HomeColors,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
viewModel: HomePageViewModel = hiltViewModel(),
) {
val background = if (item.selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent
val tint = if (item.selected) colors.primary else colors.textSecondary
@@ -142,7 +139,7 @@ fun HomeDrawerNavItem(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.background(background, RoundedCornerShape(12.dp))
.clickable { onClick() }
.clickable { viewModel.onLibrarySelected(item) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -6,19 +6,20 @@ 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 HomeMockData {
val user = HomeUser(name = "Alex User", plan = "Premium Account")
val primaryNavItems = listOf(
HomeNavItem(label = "Home", icon = Icons.Outlined.Home, selected = true),
HomeNavItem(label = "Movies", icon = Icons.Outlined.Movie),
HomeNavItem(label = "TV Shows", icon = Icons.Outlined.Tv),
HomeNavItem(label = "Search", icon = Icons.Outlined.Search)
HomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true),
HomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie),
HomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv),
HomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search)
)
val secondaryNavItems = listOf(
HomeNavItem(label = "Settings", icon = Icons.Outlined.Settings)
HomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings)
)
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
data class ContinueWatchingItem(
val id: UUID,
@@ -15,8 +16,9 @@ data class ContinueWatchingItem(
)
data class LibraryItem(
val name: String,
val id: UUID,
val name: String,
val type: CollectionType,
val isEmpty: Boolean
)
@@ -30,6 +32,7 @@ data class PosterItem(
}
data class HomeNavItem(
val id: UUID,
val label: String,
val icon: ImageVector,
val selected: Boolean = false

View File

@@ -79,7 +79,7 @@ fun ContinueWatchingCard(
fun openItem(item: ContinueWatchingItem) {
when (item.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString())
BaseItemKind.EPISODE -> viewModel.onSelectEpisode(item.id.toString())
BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(item.id.toString())
else -> {}
}
}

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.app.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.navigation.NavigationManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
val contents = _contents.asStateFlow()
fun selectLibrary(libraryId: UUID) {
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibrary(libraryId)
_contents.value = libraryItems.map {
PosterItem(
id = it.id,
title = it.name ?: "Unknown",
type = it.type
)
}
}
}
}

View File

@@ -0,0 +1,56 @@
package hu.bbara.purefin.app.library.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.app.library.LibraryViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.navigation.LibraryDto
@Composable
fun LibraryScreen(
library: LibraryDto,
viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(library) {
viewModel.selectLibrary(libraryId = library.id)
}
val libraryItems = viewModel.contents.collectAsState()
LibraryPosterGrid(libraryItems = libraryItems.value)
}
@Composable
fun LibraryPosterGrid(
libraryItems: List<PosterItem>,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(Color.Black)
) {
items(libraryItems) { item ->
PosterCard(
item = item,
)
}
}
}

View File

@@ -103,7 +103,7 @@ class JellyfinApiClient @Inject constructor(
}
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
includeHidden = false
includeHidden = false,
)
Log.d("getLibraries response: {}", response.content.toString())
return response.content.items

View File

@@ -22,6 +22,7 @@ import coil3.compose.AsyncImage
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.app.home.ui.HomeColors
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.app.home.ui.rememberHomeColors
import hu.bbara.purefin.image.JellyfinImageHelper
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
@@ -29,7 +30,7 @@ import org.jellyfin.sdk.model.api.ImageType
@Composable
fun PosterCard(
item: PosterItem,
colors: HomeColors,
colors: HomeColors = rememberHomeColors(),
modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel()
) {
@@ -37,7 +38,7 @@ fun PosterCard(
when (posterItem.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString())
BaseItemKind.EPISODE -> viewModel.onSelectEpisode(posterItem.id.toString())
BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(posterItem.id.toString())
else -> {}
}
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@Serializable
data class LibraryDto (
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val name: String
)

View File

@@ -15,4 +15,10 @@ sealed interface Route : NavKey {
@Serializable
data class Episode(val item : ItemDto) : Route
@Serializable
data class Library(val library : LibraryDto) : Route
@Serializable
data object Login : Route
}

View File

@@ -5,6 +5,8 @@ import hu.bbara.purefin.app.content.episode.EpisodeScreen
import hu.bbara.purefin.app.content.movie.MovieScreen
import hu.bbara.purefin.app.content.series.SeriesScreen
import hu.bbara.purefin.app.home.HomePage
import hu.bbara.purefin.app.library.ui.LibraryScreen
import hu.bbara.purefin.login.ui.LoginScreen
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Home> {
@@ -19,4 +21,10 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Episode> {
EpisodeScreen(episode = it.item)
}
entry<Route.Library> {
LibraryScreen(library = it.library)
}
entry<Route.Login> {
LoginScreen()
}
}

View File

@@ -3,12 +3,14 @@ package hu.bbara.purefin.player
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.ui.PlayerView
@@ -22,7 +24,10 @@ class PlayerActivity : ComponentActivity() {
setContent {
val viewModel = hiltViewModel<PlayerViewModel>()
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize()
.background(Color.Black)
) {
AndroidView(
factory = { context ->
PlayerView(context).also {