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() .fillMaxSize()
.background(SeriesBackgroundDark) .background(SeriesBackgroundDark)
) { ) {
val heroHeight = maxHeight * 0.6f val heroHeight = maxHeight * 0.4f
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .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", title = episode.name ?: "Unknown",
description = episode.overview ?: "", description = episode.overview ?: "",
duration = "58m", 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( SeriesSeasonUiModel(

View File

@@ -3,6 +3,10 @@ package hu.bbara.purefin.app.home
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.DrawerValue
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer 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.HomeContent
import hu.bbara.purefin.app.home.ui.HomeDrawerContent import hu.bbara.purefin.app.home.ui.HomeDrawerContent
import hu.bbara.purefin.app.home.ui.HomeMockData 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.HomeTopBar
import hu.bbara.purefin.app.home.ui.rememberHomeColors import hu.bbara.purefin.app.home.ui.rememberHomeColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.CollectionType
@Composable @Composable
fun HomePage( fun HomePage(
@@ -30,6 +36,17 @@ fun HomePage(
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope() 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() val continueWatching = viewModel.continueWatching.collectAsState()
ModalNavigationDrawer( ModalNavigationDrawer(
@@ -46,9 +63,9 @@ fun HomePage(
title = "Jellyfin", title = "Jellyfin",
subtitle = "Library Dashboard", subtitle = "Library Dashboard",
colors = colors, colors = colors,
primaryNavItems = HomeMockData.primaryNavItems, primaryNavItems = libraries,
secondaryNavItems = HomeMockData.secondaryNavItems, secondaryNavItems = HomeMockData.secondaryNavItems,
user = HomeMockData.user user = HomeMockData.user,
) )
} }
} }

View File

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

View File

@@ -37,7 +37,6 @@ fun HomeDrawerContent(
secondaryNavItems: List<HomeNavItem>, secondaryNavItems: List<HomeNavItem>,
user: HomeUser, user: HomeUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) { ) {
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
HomeDrawerHeader( HomeDrawerHeader(
@@ -49,7 +48,6 @@ fun HomeDrawerContent(
primaryItems = primaryNavItems, primaryItems = primaryNavItems,
secondaryItems = secondaryNavItems, secondaryItems = secondaryNavItems,
colors = colors, colors = colors,
onNavItemClick = onNavItemClick
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
HomeDrawerFooter(user = user, colors = colors) HomeDrawerFooter(user = user, colors = colors)
@@ -105,7 +103,6 @@ fun HomeDrawerNav(
secondaryItems: List<HomeNavItem>, secondaryItems: List<HomeNavItem>,
colors: HomeColors, colors: HomeColors,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onNavItemClick: (HomeNavItem) -> Unit = {}
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@@ -113,7 +110,7 @@ fun HomeDrawerNav(
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
) { ) {
primaryItems.forEach { item -> primaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) } HomeDrawerNavItem(item = item, colors = colors)
} }
if (secondaryItems.isNotEmpty()) { if (secondaryItems.isNotEmpty()) {
HorizontalDivider( HorizontalDivider(
@@ -122,7 +119,7 @@ fun HomeDrawerNav(
color = colors.divider color = colors.divider
) )
secondaryItems.forEach { item -> secondaryItems.forEach { item ->
HomeDrawerNavItem(item = item, colors = colors) { onNavItemClick(item) } HomeDrawerNavItem(item = item, colors = colors)
} }
} }
} }
@@ -133,7 +130,7 @@ fun HomeDrawerNavItem(
item: HomeNavItem, item: HomeNavItem,
colors: HomeColors, colors: HomeColors,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {} viewModel: HomePageViewModel = hiltViewModel(),
) { ) {
val background = if (item.selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent val background = if (item.selected) colors.primary.copy(alpha = 0.12f) else Color.Transparent
val tint = if (item.selected) colors.primary else colors.textSecondary val tint = if (item.selected) colors.primary else colors.textSecondary
@@ -142,7 +139,7 @@ fun HomeDrawerNavItem(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.background(background, RoundedCornerShape(12.dp)) .background(background, RoundedCornerShape(12.dp))
.clickable { onClick() } .clickable { viewModel.onLibrarySelected(item) }
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically 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.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.Tv
import org.jellyfin.sdk.model.UUID
object HomeMockData { object HomeMockData {
val user = HomeUser(name = "Alex User", plan = "Premium Account") val user = HomeUser(name = "Alex User", plan = "Premium Account")
val primaryNavItems = listOf( val primaryNavItems = listOf(
HomeNavItem(label = "Home", icon = Icons.Outlined.Home, selected = true), HomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true),
HomeNavItem(label = "Movies", icon = Icons.Outlined.Movie), HomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie),
HomeNavItem(label = "TV Shows", icon = Icons.Outlined.Tv), HomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv),
HomeNavItem(label = "Search", icon = Icons.Outlined.Search) HomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search)
) )
val secondaryNavItems = listOf( 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 androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
data class ContinueWatchingItem( data class ContinueWatchingItem(
val id: UUID, val id: UUID,
@@ -15,8 +16,9 @@ data class ContinueWatchingItem(
) )
data class LibraryItem( data class LibraryItem(
val name: String,
val id: UUID, val id: UUID,
val name: String,
val type: CollectionType,
val isEmpty: Boolean val isEmpty: Boolean
) )
@@ -30,6 +32,7 @@ data class PosterItem(
} }
data class HomeNavItem( data class HomeNavItem(
val id: UUID,
val label: String, val label: String,
val icon: ImageVector, val icon: ImageVector,
val selected: Boolean = false val selected: Boolean = false

View File

@@ -79,7 +79,7 @@ fun ContinueWatchingCard(
fun openItem(item: ContinueWatchingItem) { fun openItem(item: ContinueWatchingItem) {
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString()) BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString())
BaseItemKind.EPISODE -> viewModel.onSelectEpisode(item.id.toString()) BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(item.id.toString())
else -> {} 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( val response = api.userViewsApi.getUserViews(
userId = getUserId(), userId = getUserId(),
includeHidden = false includeHidden = false,
) )
Log.d("getLibraries response: {}", response.content.toString()) Log.d("getLibraries response: {}", response.content.toString())
return response.content.items 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.HomePageViewModel
import hu.bbara.purefin.app.home.ui.HomeColors import hu.bbara.purefin.app.home.ui.HomeColors
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.app.home.ui.rememberHomeColors
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@@ -29,7 +30,7 @@ import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
colors: HomeColors, colors: HomeColors = rememberHomeColors(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel() viewModel: HomePageViewModel = hiltViewModel()
) { ) {
@@ -37,7 +38,7 @@ fun PosterCard(
when (posterItem.type) { when (posterItem.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString()) BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
BaseItemKind.SERIES -> viewModel.onSeriesSelected(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 -> {} 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 @Serializable
data class Episode(val item : ItemDto) : Route 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.movie.MovieScreen
import hu.bbara.purefin.app.content.series.SeriesScreen import hu.bbara.purefin.app.content.series.SeriesScreen
import hu.bbara.purefin.app.home.HomePage 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() { fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Home> { entry<Route.Home> {
@@ -19,4 +21,10 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.Episode> { entry<Route.Episode> {
EpisodeScreen(episode = it.item) 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 android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
@@ -22,7 +24,10 @@ class PlayerActivity : ComponentActivity() {
setContent { setContent {
val viewModel = hiltViewModel<PlayerViewModel>() val viewModel = hiltViewModel<PlayerViewModel>()
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier.fillMaxSize()
.background(Color.Black)
) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
PlayerView(context).also { PlayerView(context).also {