From 88b34c47807fcc7722db37e0c309f67b69ae41ab Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 1 Feb 2026 07:56:46 +0100 Subject: [PATCH] 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. --- .../hu/bbara/purefin/app/home/HomePage.kt | 13 +- .../purefin/app/home/HomePageViewModel.kt | 200 ++++++++---------- .../bbara/purefin/app/home/ui/HomeContent.kt | 22 +- .../bbara/purefin/app/home/ui/HomeDrawer.kt | 22 +- .../bbara/purefin/app/home/ui/HomeSections.kt | 63 +++--- .../purefin/app/library/LibraryViewModel.kt | 8 +- .../purefin/app/library/ui/LibraryScreen.kt | 7 +- .../hu/bbara/purefin/common/ui/PosterCard.kt | 38 +++- .../purefin/data/InMemoryMediaRepository.kt | 53 ++++- .../java/hu/bbara/purefin/data/model/Media.kt | 1 + 10 files changed, 252 insertions(+), 175 deletions(-) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index 2fb1f58..1b5c384 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -35,7 +35,8 @@ fun HomePage( val drawerState = rememberDrawerState(DrawerValue.Closed) val coroutineScope = rememberCoroutineScope() - val libraries = viewModel.libraries.collectAsState().value.map { + val libraries = viewModel.libraries.collectAsState().value + val libraryNavItems = libraries.map { HomeNavItem( id = it.id, label = it.name, @@ -47,6 +48,7 @@ fun HomePage( ) } val continueWatching = viewModel.continueWatching.collectAsState() + val latestLibraryContent = viewModel.latestLibraryContent.collectAsState() ModalNavigationDrawer( drawerState = drawerState, @@ -61,9 +63,11 @@ fun HomePage( HomeDrawerContent( title = "Jellyfin", subtitle = "Library Dashboard", - primaryNavItems = libraries, + primaryNavItems = libraryNavItems, secondaryNavItems = HomeMockData.secondaryNavItems, user = HomeMockData.user, + onLibrarySelected = viewModel::onLibrarySelected, + onLogout = viewModel::logout ) } } @@ -79,7 +83,12 @@ fun HomePage( } ) { innerPadding -> HomeContent( + libraries = libraries, + libraryContent = latestLibraryContent.value, continueWatching = continueWatching.value, + onMovieSelected = viewModel::onMovieSelected, + onSeriesSelected = viewModel::onSeriesSelected, + onEpisodeSelected = viewModel::onEpisodeSelected, modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 95e446f..37f0fe4 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -18,14 +18,16 @@ import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.SeriesDto import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow 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.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind @@ -49,42 +51,106 @@ class HomePageViewModel @Inject constructor( private val _libraries = MutableStateFlow>(emptyList()) val libraries = _libraries.asStateFlow() - val continueWatching = mediaRepository.continueWatching.map { list -> - list.map { - when ( it ) { - is Media.MovieMedia -> { - val movie = mediaRepository.getMovie(it.movieId) - ContinueWatchingItem( - type = BaseItemKind.MOVIE, - movie = movie - ) - } - is Media.EpisodeMedia -> { - val episode = mediaRepository.getEpisode( - seriesId = it.seriesId, - episodeId = it.episodeId - ) - ContinueWatchingItem( - type = BaseItemKind.EPISODE, - episode = episode - ) - } - else -> throw UnsupportedOperationException("Unsupported item type: $it") + init { + viewModelScope.launch { + loadLibraries() + } + } + + val continueWatching = mediaRepository.continueWatching + .mapLatest { list -> + withContext(Dispatchers.IO) { + list.map { media -> + when (media) { + is Media.MovieMedia -> { + val movie = mediaRepository.getMovie(media.movieId) + ContinueWatchingItem( + type = BaseItemKind.MOVIE, + movie = movie + ) + } + + is Media.EpisodeMedia -> { + 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( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) - private val _latestLibraryContent = MutableStateFlow>>(emptyMap()) - val latestLibraryContent = _latestLibraryContent.asStateFlow() + val latestLibraryContent = mediaRepository.latestLibraryContent + .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 { viewModelScope.launch { mediaRepository.ensureReady() } - loadHomePageData() } fun onLibrarySelected(library : HomeNavItem) { @@ -132,19 +198,7 @@ class HomePageViewModel @Inject constructor( navigationManager.replaceAll(Route.Home) } - fun loadContinueWatching() { - viewModelScope.launch { -// mediaRepository.loadContinueWatching() - } - } - - fun loadLibraries() { - viewModelScope.launch { -// mediaRepository.loadLibraries() - } - } - - private suspend fun loadLibrariesInternal() { + private suspend fun loadLibraries() { val libraries: List = jellyfinApiClient.getLibraries() val mappedLibraries = libraries.map { LibraryItem( @@ -157,72 +211,6 @@ class HomePageViewModel @Inject constructor( _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 { return JellyfinImageHelper.toImageUrl( url = _url.value, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt index 4c2fa45..2d59b00 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt @@ -8,23 +8,20 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.home.HomePageViewModel +import org.jellyfin.sdk.model.UUID @Composable fun HomeContent( - viewModel: HomePageViewModel = hiltViewModel(), + libraries: List, + libraryContent: Map>, continueWatching: List, + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, modifier: Modifier = Modifier ) { - - val libraries by viewModel.libraries.collectAsState() - val libraryContent by viewModel.latestLibraryContent.collectAsState() - LazyColumn( modifier = modifier .fillMaxSize() @@ -35,7 +32,9 @@ fun HomeContent( } item { ContinueWatchingSection( - items = continueWatching + items = continueWatching, + onMovieSelected = onMovieSelected, + onEpisodeSelected = onEpisodeSelected ) } items( @@ -46,6 +45,9 @@ fun HomeContent( title = item.name, items = libraryContent[item.id] ?: emptyList(), action = "See All", + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected ) } item { diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt index 3bbbc1e..4c34a2c 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt @@ -26,8 +26,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.home.HomePageViewModel @Composable fun HomeDrawerContent( @@ -36,6 +34,8 @@ fun HomeDrawerContent( primaryNavItems: List, secondaryNavItems: List, user: HomeUser, + onLibrarySelected: (HomeNavItem) -> Unit, + onLogout: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxSize()) { @@ -45,10 +45,11 @@ fun HomeDrawerContent( ) HomeDrawerNav( primaryItems = primaryNavItems, - secondaryItems = secondaryNavItems + secondaryItems = secondaryNavItems, + onLibrarySelected = onLibrarySelected ) Spacer(modifier = Modifier.weight(1f)) - HomeDrawerFooter(user = user) + HomeDrawerFooter(user = user, onLogout = onLogout) } } @@ -100,6 +101,7 @@ fun HomeDrawerHeader( fun HomeDrawerNav( primaryItems: List, secondaryItems: List, + onLibrarySelected: (HomeNavItem) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -108,7 +110,7 @@ fun HomeDrawerNav( .padding(vertical = 16.dp) ) { primaryItems.forEach { item -> - HomeDrawerNavItem(item = item) + HomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected) } if (secondaryItems.isNotEmpty()) { HorizontalDivider( @@ -117,7 +119,7 @@ fun HomeDrawerNav( color = MaterialTheme.colorScheme.outlineVariant ) secondaryItems.forEach { item -> - HomeDrawerNavItem(item = item) + HomeDrawerNavItem(item = item, onLibrarySelected = onLibrarySelected) } } } @@ -127,7 +129,7 @@ fun HomeDrawerNav( fun HomeDrawerNavItem( item: HomeNavItem, modifier: Modifier = Modifier, - viewModel: HomePageViewModel = hiltViewModel(), + onLibrarySelected: (HomeNavItem) -> Unit ) { val scheme = MaterialTheme.colorScheme val background = if (item.selected) scheme.primary.copy(alpha = 0.12f) else Color.Transparent @@ -137,7 +139,7 @@ fun HomeDrawerNavItem( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) .background(background, RoundedCornerShape(12.dp)) - .clickable { viewModel.onLibrarySelected(item) } + .clickable { onLibrarySelected(item) } .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -158,8 +160,8 @@ fun HomeDrawerNavItem( @Composable fun HomeDrawerFooter ( - viewModel: HomePageViewModel = hiltViewModel(), user: HomeUser, + onLogout: () -> Unit, modifier: Modifier = Modifier, ) { val scheme = MaterialTheme.colorScheme @@ -181,7 +183,7 @@ fun HomeDrawerFooter ( iconTint = scheme.onBackground ) Column(modifier = Modifier.padding(start = 12.dp) - .clickable {viewModel.logout()}) { + .clickable { onLogout() }) { Text( text = user.name, color = scheme.onBackground, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 47035c4..5c065b6 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -2,6 +2,7 @@ package hu.bbara.purefin.app.home.ui import android.content.Intent 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 @@ -32,25 +33,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow 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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.home.HomePageViewModel +import coil3.request.ImageRequest import hu.bbara.purefin.common.ui.PosterCard import hu.bbara.purefin.common.ui.components.PurefinAsyncImage 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.ImageType import kotlin.math.nextUp @Composable fun ContinueWatchingSection( items: List, + onMovieSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, modifier: Modifier = Modifier ) { SectionHeader( @@ -65,7 +67,9 @@ fun ContinueWatchingSection( items( items = items, key = { it.id }) { item -> ContinueWatchingCard( - item = item + item = item, + onMovieSelected = onMovieSelected, + onEpisodeSelected = onEpisodeSelected ) } } @@ -75,42 +79,55 @@ fun ContinueWatchingSection( fun ContinueWatchingCard( item: ContinueWatchingItem, modifier: Modifier = Modifier, - viewModel: HomePageViewModel = hiltViewModel() + onMovieSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, ) { val scheme = MaterialTheme.colorScheme 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) { when (item.type) { - BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.movie!!.id) + BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id) BaseItemKind.EPISODE -> { val episode = item.episode!! - viewModel.onEpisodeSelected( - seriesId = episode.seriesId, - seasonId = episode.seasonId, - episodeId = episode.id - ) + onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id) } else -> {} } } + val imageRequest = ImageRequest.Builder(context) + .data(imageUrl) + .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) + .build() + Column( modifier = modifier - .width(280.dp) + .width(cardWidth) .wrapContentHeight() ) { Box( modifier = Modifier + .width(cardWidth) .aspectRatio(16f / 9f) - .shadow(12.dp, RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) .background(scheme.surfaceVariant) ) { PurefinAsyncImage( - model = viewModel.getImageUrl(itemId = item.id, type = ImageType.PRIMARY), + model = imageRequest, contentDescription = null, modifier = Modifier .fillMaxSize() @@ -187,7 +204,9 @@ fun LibraryPosterSection( items: List, action: String?, modifier: Modifier = Modifier, - viewModel: HomePageViewModel = hiltViewModel() + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, ) { SectionHeader( title = title, @@ -202,15 +221,9 @@ fun LibraryPosterSection( items = items, key = { it.id }) { item -> PosterCard( item = item, - onMovieSelected = { viewModel.onMovieSelected(item.movie!!.id) }, - onSeriesSelected = { viewModel.onSeriesSelected(item.series!!.id) }, - onEpisodeSelected = { - viewModel.onEpisodeSelected( - seriesId = item.episode!!.seriesId, - seasonId = item.episode.seasonId, - episodeId = item.episode.id - ) - } + onMovieSelected = onMovieSelected, + onSeriesSelected = onSeriesSelected, + onEpisodeSelected = onEpisodeSelected ) } } diff --git a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt index 53b7072..ac28a95 100644 --- a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt @@ -42,19 +42,19 @@ class LibraryViewModel @Inject constructor( viewModelScope.launch { mediaRepository.ensureReady() } } - fun onMovieSelected(movieId: String) { + fun onMovieSelected(movieId: UUID) { navigationManager.navigate(Route.MovieRoute( MovieDto( - id = UUID.fromString(movieId), + id = movieId, ) )) } - fun onSeriesSelected(seriesId: String) { + fun onSeriesSelected(seriesId: UUID) { viewModelScope.launch { navigationManager.navigate(Route.SeriesRoute( SeriesDto( - id = UUID.fromString(seriesId), + id = seriesId, ) )) } diff --git a/app/src/main/java/hu/bbara/purefin/app/library/ui/LibraryScreen.kt b/app/src/main/java/hu/bbara/purefin/app/library/ui/LibraryScreen.kt index 3c06df0..42aaa39 100644 --- a/app/src/main/java/hu/bbara/purefin/app/library/ui/LibraryScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/library/ui/LibraryScreen.kt @@ -86,10 +86,9 @@ internal fun LibraryPosterGrid( items(libraryItems) { item -> PosterCard( item = item, - onMovieSelected = { viewModel.onMovieSelected(item.id.toString()) }, - onSeriesSelected = { viewModel.onSeriesSelected(item.id.toString()) }, - onEpisodeSelected = { - } + onMovieSelected = viewModel::onMovieSelected, + onSeriesSelected = viewModel::onSeriesSelected, + onEpisodeSelected = { _, _, _ -> } ) } } diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt b/app/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt index 41d6e00..403db07 100644 --- a/app/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt +++ b/app/src/main/java/hu/bbara/purefin/common/ui/PosterCard.kt @@ -1,6 +1,7 @@ package hu.bbara.purefin.common.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio @@ -12,45 +13,62 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow 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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.request.ImageRequest import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun PosterCard( item: PosterItem, modifier: Modifier = Modifier, - onMovieSelected: (String) -> Unit, - onSeriesSelected: (String) -> Unit, - onEpisodeSelected: (String) -> Unit, + onMovieSelected: (UUID) -> Unit, + onSeriesSelected: (UUID) -> Unit, + onEpisodeSelected: (UUID, UUID, UUID) -> Unit, ) { 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) { when (posterItem.type) { - BaseItemKind.MOVIE -> onMovieSelected(posterItem.id.toString()) - BaseItemKind.SERIES -> onSeriesSelected(posterItem.id.toString()) - BaseItemKind.EPISODE -> onEpisodeSelected(posterItem.id.toString()) + BaseItemKind.MOVIE -> onMovieSelected(posterItem.id) + BaseItemKind.SERIES -> onSeriesSelected(posterItem.id) + BaseItemKind.EPISODE -> onEpisodeSelected( + posterItem.episode!!.seriesId, + posterItem.episode.seasonId, + posterItem.episode.id + ) else -> {} } } + + val imageRequest = ImageRequest.Builder(context) + .data(item.imageUrl) + .size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() }) + .build() Column( modifier = Modifier - .width(144.dp) + .width(posterWidth) ) { PurefinAsyncImage( - model = item.imageUrl, + model = imageRequest, contentDescription = null, modifier = Modifier .aspectRatio(2f / 3f) - .shadow(10.dp, RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp)) + .border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp)) .background(scheme.surfaceVariant) .clickable(onClick = { openItem(item) }), contentScale = ContentScale.Crop diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index da9b059..e5e83ea 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemDto @@ -40,16 +41,19 @@ class InMemoryMediaRepository @Inject constructor( private val ready = CompletableDeferred() private val _state: MutableStateFlow = MutableStateFlow(MediaRepositoryState.Loading) - override val state: StateFlow = _state + override val state: StateFlow = _state.asStateFlow() private val _movies : MutableStateFlow> = MutableStateFlow(emptyMap()) - override val movies: StateFlow> = _movies + override val movies: StateFlow> = _movies.asStateFlow() private val _series : MutableStateFlow> = MutableStateFlow(emptyMap()) - override val series: StateFlow> = _series + override val series: StateFlow> = _series.asStateFlow() private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) - val continueWatching: StateFlow> = _continueWatching + val continueWatching: StateFlow> = _continueWatching.asStateFlow() + + private val _latestLibraryContent: MutableStateFlow>> = MutableStateFlow(emptyMap()) + val latestLibraryContent: StateFlow>> = _latestLibraryContent.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -65,6 +69,7 @@ class InMemoryMediaRepository @Inject constructor( try { loadLibraries() loadContinueWatching() + loadLatestLibraryContent() _state.value = MediaRepositoryState.Ready ready.complete(Unit) } 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 { awaitReady() localDataSource.getMovie(movieId)?.let { diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Media.kt b/app/src/main/java/hu/bbara/purefin/data/model/Media.kt index 623d00c..20c19aa 100644 --- a/app/src/main/java/hu/bbara/purefin/data/model/Media.kt +++ b/app/src/main/java/hu/bbara/purefin/data/model/Media.kt @@ -9,5 +9,6 @@ sealed class Media( ) { class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE) 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) }