implement latest library content loading and refactor Home screen state

- Implement `loadLatestLibraryContent` in `InMemoryMediaRepository` to fetch and categorize latest media from Jellyfin libraries.
- Update `HomePageViewModel` to use `mapLatest` and `stateIn` for asynchronous, thread-safe loading of "Continue Watching" and "Latest" content.
- Refactor `HomePage` and its UI components (`HomeContent`, `HomeDrawer`, `HomeSections`) to pass data and callbacks as parameters instead of using `hiltViewModel()` internally.
- Enhance `PosterCard` and `ContinueWatchingCard` with explicit click listeners and image request optimization using `Coil`.
- Add `SeasonMedia` type to the `Media` sealed class to support more granular library item tracking.
- Standardize `UUID` usage for media selection callbacks across `LibraryViewModel` and common UI components.
- Improve UI styling by replacing shadows with subtle borders and consistent corner radii on media cards.
This commit is contained in:
2026-02-01 07:56:46 +01:00
parent b643988ed4
commit 88b34c4780
10 changed files with 252 additions and 175 deletions

View File

@@ -35,7 +35,8 @@ 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 { val libraries = viewModel.libraries.collectAsState().value
val libraryNavItems = libraries.map {
HomeNavItem( HomeNavItem(
id = it.id, id = it.id,
label = it.name, label = it.name,
@@ -47,6 +48,7 @@ fun HomePage(
) )
} }
val continueWatching = viewModel.continueWatching.collectAsState() val continueWatching = viewModel.continueWatching.collectAsState()
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -61,9 +63,11 @@ fun HomePage(
HomeDrawerContent( HomeDrawerContent(
title = "Jellyfin", title = "Jellyfin",
subtitle = "Library Dashboard", subtitle = "Library Dashboard",
primaryNavItems = libraries, primaryNavItems = libraryNavItems,
secondaryNavItems = HomeMockData.secondaryNavItems, secondaryNavItems = HomeMockData.secondaryNavItems,
user = HomeMockData.user, user = HomeMockData.user,
onLibrarySelected = viewModel::onLibrarySelected,
onLogout = viewModel::logout
) )
} }
} }
@@ -79,7 +83,12 @@ fun HomePage(
} }
) { innerPadding -> ) { innerPadding ->
HomeContent( HomeContent(
libraries = libraries,
libraryContent = latestLibraryContent.value,
continueWatching = continueWatching.value, continueWatching = continueWatching.value,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
} }

View File

@@ -18,14 +18,16 @@ import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@@ -49,42 +51,106 @@ class HomePageViewModel @Inject constructor(
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList()) private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
val libraries = _libraries.asStateFlow() val libraries = _libraries.asStateFlow()
val continueWatching = mediaRepository.continueWatching.map { list -> init {
list.map { viewModelScope.launch {
when ( it ) { loadLibraries()
is Media.MovieMedia -> { }
val movie = mediaRepository.getMovie(it.movieId) }
ContinueWatchingItem(
type = BaseItemKind.MOVIE, val continueWatching = mediaRepository.continueWatching
movie = movie .mapLatest { list ->
) withContext(Dispatchers.IO) {
} list.map { media ->
is Media.EpisodeMedia -> { when (media) {
val episode = mediaRepository.getEpisode( is Media.MovieMedia -> {
seriesId = it.seriesId, val movie = mediaRepository.getMovie(media.movieId)
episodeId = it.episodeId ContinueWatchingItem(
) type = BaseItemKind.MOVIE,
ContinueWatchingItem( movie = movie
type = BaseItemKind.EPISODE, )
episode = episode }
)
} is Media.EpisodeMedia -> {
else -> throw UnsupportedOperationException("Unsupported item type: $it") val episode = mediaRepository.getEpisode(
seriesId = media.seriesId,
episodeId = media.episodeId
)
ContinueWatchingItem(
type = BaseItemKind.EPISODE,
episode = episode
)
}
else -> throw UnsupportedOperationException("Unsupported item type: $media")
}
}.distinctBy { it.id }
} }
} }
}.distinctUntilChanged() .distinctUntilChanged()
.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList() initialValue = emptyList()
) )
private val _latestLibraryContent = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap()) val latestLibraryContent = mediaRepository.latestLibraryContent
val latestLibraryContent = _latestLibraryContent.asStateFlow() .mapLatest { libraryMap ->
withContext(Dispatchers.IO) {
libraryMap.mapValues { (_, items) ->
items.map { media ->
when (media) {
is Media.MovieMedia -> {
val movie = mediaRepository.getMovie(media.movieId)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
is Media.EpisodeMedia -> {
val episode = mediaRepository.getEpisode(
seriesId = media.seriesId,
episodeId = media.episodeId
)
PosterItem(
type = BaseItemKind.EPISODE,
episode = episode
)
}
is Media.SeriesMedia -> {
val series = mediaRepository.getSeries(media.id)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
is Media.SeasonMedia -> {
val series = mediaRepository.getSeries(media.seriesId)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
else -> throw UnsupportedOperationException("Unsupported item type: $media")
}
}.distinctBy { it.id }
}
}
}
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()
)
init { init {
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { mediaRepository.ensureReady() }
loadHomePageData()
} }
fun onLibrarySelected(library : HomeNavItem) { fun onLibrarySelected(library : HomeNavItem) {
@@ -132,19 +198,7 @@ class HomePageViewModel @Inject constructor(
navigationManager.replaceAll(Route.Home) navigationManager.replaceAll(Route.Home)
} }
fun loadContinueWatching() { private suspend fun loadLibraries() {
viewModelScope.launch {
// mediaRepository.loadContinueWatching()
}
}
fun loadLibraries() {
viewModelScope.launch {
// mediaRepository.loadLibraries()
}
}
private suspend fun loadLibrariesInternal() {
val libraries: List<BaseItemDto> = jellyfinApiClient.getLibraries() val libraries: List<BaseItemDto> = jellyfinApiClient.getLibraries()
val mappedLibraries = libraries.map { val mappedLibraries = libraries.map {
LibraryItem( LibraryItem(
@@ -157,72 +211,6 @@ class HomePageViewModel @Inject constructor(
_libraries.value = mappedLibraries _libraries.value = mappedLibraries
} }
fun loadAllShownLibraryItems() {
viewModelScope.launch {
if (_libraries.value.isEmpty()) {
loadLibrariesInternal()
}
_libraries.value.forEach { library ->
loadLatestLibraryItems(library.id)
}
}
}
fun loadLatestLibraryItems(libraryId: UUID) {
viewModelScope.launch {
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
val latestLibraryPosterItem = latestLibraryItems.map {
when (it.type) {
BaseItemKind.MOVIE -> {
val movie = mediaRepository.getMovie(it.id)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
BaseItemKind.EPISODE -> {
val episode = mediaRepository.getEpisode(
it.seriesId!!,
it.parentId!!,
it.id
)
PosterItem(
type = BaseItemKind.EPISODE,
episode = episode
)
}
BaseItemKind.SEASON -> {
val series = mediaRepository.getSeries(
seriesId = it.seriesId!!
)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
BaseItemKind.SERIES -> {
val series = mediaRepository.getSeries(
seriesId = it.id
)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}.distinctBy { it.id }
_latestLibraryContent.update { currentMap ->
currentMap + (libraryId to latestLibraryPosterItem)
}
}
}
fun loadHomePageData() {
loadContinueWatching()
loadAllShownLibraryItems()
}
fun getImageUrl(itemId: UUID, type: ImageType): String { fun getImageUrl(itemId: UUID, type: ImageType): String {
return JellyfinImageHelper.toImageUrl( return JellyfinImageHelper.toImageUrl(
url = _url.value, url = _url.value,

View File

@@ -8,23 +8,20 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import org.jellyfin.sdk.model.UUID
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeContent( fun HomeContent(
viewModel: HomePageViewModel = hiltViewModel(), libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.latestLibraryContent.collectAsState()
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -35,7 +32,9 @@ fun HomeContent(
} }
item { item {
ContinueWatchingSection( ContinueWatchingSection(
items = continueWatching items = continueWatching,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
) )
} }
items( items(
@@ -46,6 +45,9 @@ fun HomeContent(
title = item.name, title = item.name,
items = libraryContent[item.id] ?: emptyList(), items = libraryContent[item.id] ?: emptyList(),
action = "See All", action = "See All",
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
) )
} }
item { item {

View File

@@ -26,8 +26,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.home.HomePageViewModel
@Composable @Composable
fun HomeDrawerContent( fun HomeDrawerContent(
@@ -36,6 +34,8 @@ fun HomeDrawerContent(
primaryNavItems: List<HomeNavItem>, primaryNavItems: List<HomeNavItem>,
secondaryNavItems: List<HomeNavItem>, secondaryNavItems: List<HomeNavItem>,
user: HomeUser, user: HomeUser,
onLibrarySelected: (HomeNavItem) -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
@@ -45,10 +45,11 @@ fun HomeDrawerContent(
) )
HomeDrawerNav( HomeDrawerNav(
primaryItems = primaryNavItems, primaryItems = primaryNavItems,
secondaryItems = secondaryNavItems secondaryItems = secondaryNavItems,
onLibrarySelected = onLibrarySelected
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
HomeDrawerFooter(user = user) HomeDrawerFooter(user = user, onLogout = onLogout)
} }
} }
@@ -100,6 +101,7 @@ fun HomeDrawerHeader(
fun HomeDrawerNav( fun HomeDrawerNav(
primaryItems: List<HomeNavItem>, primaryItems: List<HomeNavItem>,
secondaryItems: List<HomeNavItem>, secondaryItems: List<HomeNavItem>,
onLibrarySelected: (HomeNavItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -108,7 +110,7 @@ fun HomeDrawerNav(
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
) { ) {
primaryItems.forEach { item -> primaryItems.forEach { item ->
HomeDrawerNavItem(item = item) HomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected)
} }
if (secondaryItems.isNotEmpty()) { if (secondaryItems.isNotEmpty()) {
HorizontalDivider( HorizontalDivider(
@@ -117,7 +119,7 @@ fun HomeDrawerNav(
color = MaterialTheme.colorScheme.outlineVariant color = MaterialTheme.colorScheme.outlineVariant
) )
secondaryItems.forEach { item -> secondaryItems.forEach { item ->
HomeDrawerNavItem(item = item) HomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected)
} }
} }
} }
@@ -127,7 +129,7 @@ fun HomeDrawerNav(
fun HomeDrawerNavItem( fun HomeDrawerNavItem(
item: HomeNavItem, item: HomeNavItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel(), onLibrarySelected: (HomeNavItem) -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent
@@ -137,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 { viewModel.onLibrarySelected(item) } .clickable { onLibrarySelected(item) }
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -158,8 +160,8 @@ fun HomeDrawerNavItem(
@Composable @Composable
fun HomeDrawerFooter ( fun HomeDrawerFooter (
viewModel: HomePageViewModel = hiltViewModel(),
user: HomeUser, user: HomeUser,
onLogout: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -181,7 +183,7 @@ fun HomeDrawerFooter (
iconTint = scheme.onBackground iconTint = scheme.onBackground
) )
Column(modifier = Modifier.padding(start = 12.dp) Column(modifier = Modifier.padding(start = 12.dp)
.clickable {viewModel.logout()}) { .clickable { onLogout() }) {
Text( Text(
text = user.name, text = user.name,
color = scheme.onBackground, color = scheme.onBackground,

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.app.home.ui
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -32,25 +33,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import coil3.request.ImageRequest
import hu.bbara.purefin.app.home.HomePageViewModel
import hu.bbara.purefin.common.ui.PosterCard import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
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.ImageType
import kotlin.math.nextUp import kotlin.math.nextUp
@Composable @Composable
fun ContinueWatchingSection( fun ContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
SectionHeader( SectionHeader(
@@ -65,7 +67,9 @@ fun ContinueWatchingSection(
items( items(
items = items, key = { it.id }) { item -> items = items, key = { it.id }) { item ->
ContinueWatchingCard( ContinueWatchingCard(
item = item item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
) )
} }
} }
@@ -75,42 +79,55 @@ fun ContinueWatchingSection(
fun ContinueWatchingCard( fun ContinueWatchingCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel() onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current
val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> item.movie?.heroImageUrl
BaseItemKind.EPISODE -> item.episode?.heroImageUrl
else -> null
}
val cardWidth = 280.dp
val cardHeight = cardWidth * 9 / 16
fun openItem(item: ContinueWatchingItem) { fun openItem(item: ContinueWatchingItem) {
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.movie!!.id) BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
val episode = item.episode!! val episode = item.episode!!
viewModel.onEpisodeSelected( onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
} }
else -> {} else -> {}
} }
} }
val imageRequest = ImageRequest.Builder(context)
.data(imageUrl)
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
.build()
Column( Column(
modifier = modifier modifier = modifier
.width(280.dp) .width(cardWidth)
.wrapContentHeight() .wrapContentHeight()
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.shadow(12.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
.background(scheme.surfaceVariant) .background(scheme.surfaceVariant)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = viewModel.getImageUrl(itemId = item.id, type = ImageType.PRIMARY), model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -187,7 +204,9 @@ fun LibraryPosterSection(
items: List<PosterItem>, items: List<PosterItem>,
action: String?, action: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomePageViewModel = hiltViewModel() onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
SectionHeader( SectionHeader(
title = title, title = title,
@@ -202,15 +221,9 @@ fun LibraryPosterSection(
items = items, key = { it.id }) { item -> items = items, key = { it.id }) { item ->
PosterCard( PosterCard(
item = item, item = item,
onMovieSelected = { viewModel.onMovieSelected(item.movie!!.id) }, onMovieSelected = onMovieSelected,
onSeriesSelected = { viewModel.onSeriesSelected(item.series!!.id) }, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = { onEpisodeSelected = onEpisodeSelected
viewModel.onEpisodeSelected(
seriesId = item.episode!!.seriesId,
seasonId = item.episode.seasonId,
episodeId = item.episode.id
)
}
) )
} }
} }

View File

@@ -42,19 +42,19 @@ class LibraryViewModel @Inject constructor(
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { mediaRepository.ensureReady() }
} }
fun onMovieSelected(movieId: String) { fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(Route.MovieRoute( navigationManager.navigate(Route.MovieRoute(
MovieDto( MovieDto(
id = UUID.fromString(movieId), id = movieId,
) )
)) ))
} }
fun onSeriesSelected(seriesId: String) { fun onSeriesSelected(seriesId: UUID) {
viewModelScope.launch { viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute( navigationManager.navigate(Route.SeriesRoute(
SeriesDto( SeriesDto(
id = UUID.fromString(seriesId), id = seriesId,
) )
)) ))
} }

View File

@@ -86,10 +86,9 @@ internal fun LibraryPosterGrid(
items(libraryItems) { item -> items(libraryItems) { item ->
PosterCard( PosterCard(
item = item, item = item,
onMovieSelected = { viewModel.onMovieSelected(item.id.toString()) }, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = { viewModel.onSeriesSelected(item.id.toString()) }, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { onEpisodeSelected = { _, _, _ -> }
}
) )
} }
} }

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.common.ui package hu.bbara.purefin.common.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -12,45 +13,62 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.request.ImageRequest
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@Composable @Composable
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMovieSelected: (String) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (String) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (String) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val density = LocalDensity.current
val posterWidth = 144.dp
val posterHeight = posterWidth * 3 / 2
fun openItem(posterItem: PosterItem) { fun openItem(posterItem: PosterItem) {
when (posterItem.type) { when (posterItem.type) {
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id.toString()) BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
BaseItemKind.SERIES -> onSeriesSelected(posterItem.id.toString()) BaseItemKind.SERIES -> onSeriesSelected(posterItem.id)
BaseItemKind.EPISODE -> onEpisodeSelected(posterItem.id.toString()) BaseItemKind.EPISODE -> onEpisodeSelected(
posterItem.episode!!.seriesId,
posterItem.episode.seasonId,
posterItem.episode.id
)
else -> {} else -> {}
} }
} }
val imageRequest = ImageRequest.Builder(context)
.data(item.imageUrl)
.size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() })
.build()
Column( Column(
modifier = Modifier modifier = Modifier
.width(144.dp) .width(posterWidth)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = item.imageUrl, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.shadow(10.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp))
.background(scheme.surfaceVariant) .background(scheme.surfaceVariant)
.clickable(onClick = { openItem(item) }), .clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
@@ -40,16 +41,19 @@ class InMemoryMediaRepository @Inject constructor(
private val ready = CompletableDeferred<Unit>() private val ready = CompletableDeferred<Unit>()
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading) private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
private val _movies : MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap()) private val _movies : MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
private val _series : MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap()) private val _series : MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList()) private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -65,6 +69,7 @@ class InMemoryMediaRepository @Inject constructor(
try { try {
loadLibraries() loadLibraries()
loadContinueWatching() loadContinueWatching()
loadLatestLibraryContent()
_state.value = MediaRepositoryState.Ready _state.value = MediaRepositoryState.Ready
ready.complete(Unit) ready.complete(Unit)
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -157,6 +162,46 @@ class InMemoryMediaRepository @Inject constructor(
} }
} }
suspend fun loadLatestLibraryContent() {
// TODO Make libraries accessible in a field or something that is not this ugly.
val librariesItem = jellyfinApiClient.getLibraries()
val filterLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val latestLibraryContents = filterLibraries.associate { library ->
val latestFromLibrary = jellyfinApiClient.getLatestFromLibrary(library.id)
library.id to when (library.collectionType) {
CollectionType.MOVIES -> {
latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id)
Media.MovieMedia(movieId = movie.id)
}
}
CollectionType.TVSHOWS -> {
latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl(), library.id)
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
val season = it.toSeason(serverUrl())
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl())
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
} else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
}
}
_latestLibraryContent.value = latestLibraryContents
//TODO Load seasons and episodes, other types are already loaded at this point.
}
override suspend fun getMovie(movieId: UUID): Movie { override suspend fun getMovie(movieId: UUID): Movie {
awaitReady() awaitReady()
localDataSource.getMovie(movieId)?.let { localDataSource.getMovie(movieId)?.let {

View File

@@ -9,5 +9,6 @@ sealed class Media(
) { ) {
class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE) class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE)
class SeriesMedia(val seriesId: UUID) : Media(seriesId, BaseItemKind.SERIES) class SeriesMedia(val seriesId: UUID) : Media(seriesId, BaseItemKind.SERIES)
class SeasonMedia(val seasonId: UUID, val seriesId: UUID) : Media(seasonId, BaseItemKind.SEASON)
class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, BaseItemKind.EPISODE) class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, BaseItemKind.EPISODE)
} }