From f107085385731a7b324b105286ed82b30b4a2ef8 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 31 Mar 2026 15:51:07 +0200 Subject: [PATCH] feat: add media suggestions to home screen Introduce a new Suggestions section on the home screen that fetches and displays recommended content from the Jellyfin API. This replaces the previous manual featured items logic with a more robust suggestion system supporting movies, series, and episodes. Key changes: - Implement `getSuggestions` in `JellyfinApiClient` to fetch video content. - Update `AppContentRepository` and its implementation to manage suggestions, including caching and background refreshing. - Add `SuggestedItem` models and update `AppViewModel` to expose suggestions as a state flow. - Replace `HomeFeaturedSection` with `SuggestionsSection` using a horizontal pager. - Implement auto-scrolling logic in `HomeContent` to ensure suggestions are visible upon initial load if the user hasn't already interacted. --- .../hu/bbara/purefin/app/home/AppScreen.kt | 2 + .../hu/bbara/purefin/app/home/HomeScreen.kt | 4 + .../bbara/purefin/app/home/ui/HomeContent.kt | 285 ++++-------------- .../app/home/ui/HomeDiscoveryModels.kt | 36 --- ...{HomeFeaturedCard.kt => SuggestionCard.kt} | 10 +- ...aturedSection.kt => SuggestionsSection.kt} | 14 +- .../purefin/core/data/AppContentRepository.kt | 1 + .../core/data/InMemoryAppContentRepository.kt | 39 +++ .../purefin/core/data/cache/HomeCache.kt | 1 + .../core/data/client/JellyfinApiClient.kt | 21 ++ .../feature/shared/home/AppViewModel.kt | 32 +- .../purefin/feature/shared/home/HomeModels.kt | 70 +++++ 12 files changed, 230 insertions(+), 285 deletions(-) delete mode 100644 app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt rename app/src/main/java/hu/bbara/purefin/app/home/ui/featured/{HomeFeaturedCard.kt => SuggestionCard.kt} (95%) rename app/src/main/java/hu/bbara/purefin/app/home/ui/featured/{HomeFeaturedSection.kt => SuggestionsSection.kt} (88%) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt index 4736242..ed676d4 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt @@ -22,6 +22,7 @@ fun AppScreen( val libraries by viewModel.libraries.collectAsState() val libraryContent by viewModel.latestLibraryContent.collectAsState() + val suggestions by viewModel.suggestions.collectAsState() val continueWatching by viewModel.continueWatching.collectAsState() val nextUp by viewModel.nextUp.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState() @@ -43,6 +44,7 @@ fun AppScreen( 0 -> HomeScreen( libraries = libraries, libraryContent = libraryContent, + suggestions = suggestions, continueWatching = continueWatching, nextUp = nextUp, isRefreshing = isRefreshing, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt index 4ab7559..dd27804 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt @@ -22,6 +22,7 @@ import hu.bbara.purefin.app.home.ui.homePreviewLibraryContent import hu.bbara.purefin.app.home.ui.homePreviewNextUp import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem +import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.PosterItem @@ -33,6 +34,7 @@ import org.jellyfin.sdk.model.UUID fun HomeScreen( libraries: List, libraryContent: Map>, + suggestions: List, continueWatching: List, nextUp: List, isRefreshing: Boolean, @@ -84,6 +86,7 @@ fun HomeScreen( HomeContent( libraries = libraries, libraryContent = libraryContent, + suggestions = suggestions, continueWatching = continueWatching, nextUp = nextUp, isRefreshing = isRefreshing, @@ -122,6 +125,7 @@ private fun HomeScreenPreview() { HomeScreen( libraries = homePreviewLibraries(), libraryContent = homePreviewLibraryContent(), + suggestions = emptyList(), continueWatching = homePreviewContinueWatching(), nextUp = homePreviewNextUp(), isRefreshing = false, 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 fc031e3..4241fca 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 @@ -1,5 +1,6 @@ package hu.bbara.purefin.app.home.ui +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -7,16 +8,23 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import hu.bbara.purefin.app.home.ui.continuewatching.ContinueWatchingSection -import hu.bbara.purefin.app.home.ui.featured.HomeFeaturedSection +import hu.bbara.purefin.app.home.ui.featured.SuggestionsSection import hu.bbara.purefin.app.home.ui.library.LibraryPosterSection import hu.bbara.purefin.app.home.ui.nextup.NextUpSection import hu.bbara.purefin.app.home.ui.shared.HomeEmptyState @@ -27,6 +35,7 @@ import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.PosterItem +import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.ui.theme.AppTheme import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind @@ -38,6 +47,7 @@ import java.util.UUID as JavaUuid fun HomeContent( libraries: List, libraryContent: Map>, + suggestions: List, continueWatching: List, nextUp: List, isRefreshing: Boolean, @@ -53,33 +63,33 @@ fun HomeContent( val visibleLibraries = remember(libraries, libraryContent) { libraries.filter { libraryContent[it.id].orEmpty().isNotEmpty() } } - val featuredItems = remember(continueWatching, nextUp, visibleLibraries, libraryContent) { - buildFeaturedItems( - continueWatching = continueWatching, - nextUp = nextUp, - visibleLibraries = visibleLibraries, - libraryContent = libraryContent - ) + val listState = rememberLazyListState() + var pendingInitialSuggestionsReveal by rememberSaveable { mutableStateOf(suggestions.isEmpty()) } + var userInteractedBeforeSuggestionsLoaded by rememberSaveable { mutableStateOf(false) } + + val hasContent = libraryContent.isNotEmpty() || continueWatching.isNotEmpty() || nextUp.isNotEmpty() || suggestions.isNotEmpty() + + LaunchedEffect(listState, pendingInitialSuggestionsReveal) { + if (!pendingInitialSuggestionsReveal) return@LaunchedEffect + snapshotFlow { listState.isScrollInProgress } + .collect { isScrolling -> + if (isScrolling) { + userInteractedBeforeSuggestionsLoaded = true + } + } } - val featuredLead = featuredItems.firstOrNull() - val filteredContinueWatching = remember(continueWatching, featuredLead) { - if (featuredLead?.source == FeaturedHomeSource.CONTINUE_WATCHING) { - continueWatching.filterNot { it.id == featuredLead.id } - } else { - continueWatching + + LaunchedEffect( + suggestions.isNotEmpty(), + pendingInitialSuggestionsReveal, + userInteractedBeforeSuggestionsLoaded + ) { + if (!suggestions.isNotEmpty() || !pendingInitialSuggestionsReveal) return@LaunchedEffect + if (!userInteractedBeforeSuggestionsLoaded) { + listState.scrollToItem(0) } + pendingInitialSuggestionsReveal = false } - val filteredNextUp = remember(nextUp, featuredLead) { - if (featuredLead?.source == FeaturedHomeSource.NEXT_UP) { - nextUp.filterNot { it.id == featuredLead.id } - } else { - nextUp - } - } - val hasContent = featuredItems.isNotEmpty() || - filteredContinueWatching.isNotEmpty() || - filteredNextUp.isNotEmpty() || - visibleLibraries.isNotEmpty() PullToRefreshBox( isRefreshing = isRefreshing, @@ -92,40 +102,43 @@ fun HomeContent( .background(scheme.background) ) { LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(24.dp) ) { - if (featuredItems.isNotEmpty()) { + if (suggestions.isNotEmpty()) { item(key = "featured") { - HomeFeaturedSection( - items = featuredItems, - onOpenFeaturedItem = { item -> - openHomeDestination( - destination = item.destination, - onMovieSelected = onMovieSelected, - onSeriesSelected = onSeriesSelected, - onEpisodeSelected = onEpisodeSelected - ) + SuggestionsSection( + items = suggestions, + onItemOpen = { item -> + when (item.type) { + BaseItemKind.MOVIE -> onMovieSelected(item.id) + BaseItemKind.SERIES -> onSeriesSelected(item.id) + BaseItemKind.EPISODE -> onEpisodeSelected(item.id, item.id, item.id) + else -> { + Log.e("HomeContent", "Unsupported item type: ${item.type}") + } + } } ) } } - if (filteredContinueWatching.isNotEmpty()) { + if (continueWatching.isNotEmpty()) { item(key = "continue-watching") { ContinueWatchingSection( - items = filteredContinueWatching, + items = continueWatching, onMovieSelected = onMovieSelected, onEpisodeSelected = onEpisodeSelected ) } } - if (filteredNextUp.isNotEmpty()) { + if (nextUp.isNotEmpty()) { item(key = "next-up") { NextUpSection( - items = filteredNextUp, + items = nextUp, onEpisodeSelected = onEpisodeSelected ) } @@ -158,197 +171,6 @@ fun HomeContent( } } -private fun buildFeaturedItems( - continueWatching: List, - nextUp: List, - visibleLibraries: List, - libraryContent: Map> -): List { - val candidates = buildList { - addAll(continueWatching.map { it.toFeaturedHomeItem() }) - addAll(nextUp.map { it.toFeaturedHomeItem() }) - visibleLibraries.forEach { library -> - libraryContent[library.id] - .orEmpty() - .firstOrNull() - ?.let { add(it.toFeaturedHomeItem(library)) } - } - } - return candidates - .distinctBy { "${it.destination.kind}:${it.id}" } - .take(5) -} - -private fun ContinueWatchingItem.toFeaturedHomeItem(): FeaturedHomeItem { - return when (type) { - BaseItemKind.MOVIE -> { - val movie = movie!! - FeaturedHomeItem( - id = movie.id, - source = FeaturedHomeSource.CONTINUE_WATCHING, - badge = "Continue watching", - title = movie.title, - supportingText = listOf(movie.year, movie.runtime) - .filter { it.isNotBlank() } - .joinToString(" • "), - description = movie.synopsis, - metadata = listOf(movie.year, movie.runtime, movie.rating, movie.format) - .filter { it.isNotBlank() }, - imageUrl = movie.heroImageUrl, - ctaLabel = "Continue", - progress = progress.toFloat() / 100f, - destination = HomeDestination( - kind = HomeDestinationKind.MOVIE, - id = movie.id - ) - ) - } - - BaseItemKind.EPISODE -> { - val episode = episode!! - FeaturedHomeItem( - id = episode.id, - source = FeaturedHomeSource.CONTINUE_WATCHING, - badge = "Continue watching", - title = episode.title, - supportingText = listOf("Episode ${episode.index}", episode.runtime) - .filter { it.isNotBlank() } - .joinToString(" • "), - description = episode.synopsis, - metadata = listOf(episode.releaseDate, episode.runtime, episode.rating, episode.format) - .filter { it.isNotBlank() }, - imageUrl = episode.heroImageUrl, - ctaLabel = "Continue", - progress = progress.toFloat() / 100f, - destination = HomeDestination( - kind = HomeDestinationKind.EPISODE, - id = episode.id, - seriesId = episode.seriesId, - seasonId = episode.seasonId - ) - ) - } - - else -> throw IllegalArgumentException("Unsupported featured type: $type") - } -} - -private fun NextUpItem.toFeaturedHomeItem(): FeaturedHomeItem { - return FeaturedHomeItem( - id = episode.id, - source = FeaturedHomeSource.NEXT_UP, - badge = "Next up", - title = episode.title, - supportingText = listOf("Episode ${episode.index}", episode.runtime) - .filter { it.isNotBlank() } - .joinToString(" • "), - description = episode.synopsis, - metadata = listOf(episode.releaseDate, episode.runtime, episode.rating) - .filter { it.isNotBlank() }, - imageUrl = episode.heroImageUrl, - ctaLabel = "Up next", - destination = HomeDestination( - kind = HomeDestinationKind.EPISODE, - id = episode.id, - seriesId = episode.seriesId, - seasonId = episode.seasonId - ) - ) -} - -private fun PosterItem.toFeaturedHomeItem(library: LibraryItem): FeaturedHomeItem { - return when (type) { - BaseItemKind.MOVIE -> { - val movie = movie!! - FeaturedHomeItem( - id = movie.id, - source = FeaturedHomeSource.LIBRARY, - badge = library.name, - title = movie.title, - supportingText = listOf(movie.year, movie.runtime) - .filter { it.isNotBlank() } - .joinToString(" • "), - description = movie.synopsis, - metadata = listOf(movie.year, movie.runtime, movie.rating) - .filter { it.isNotBlank() }, - imageUrl = movie.heroImageUrl, - ctaLabel = "Open", - destination = HomeDestination( - kind = HomeDestinationKind.MOVIE, - id = movie.id - ) - ) - } - - BaseItemKind.SERIES -> { - val series = series!! - FeaturedHomeItem( - id = series.id, - source = FeaturedHomeSource.LIBRARY, - badge = library.name, - title = series.name, - supportingText = when { - series.unwatchedEpisodeCount > 0 -> - "${series.unwatchedEpisodeCount} unwatched episodes" - else -> "${series.seasonCount} seasons" - }, - description = series.synopsis, - metadata = listOf(series.year, "${series.seasonCount} seasons") - .filter { it.isNotBlank() }, - imageUrl = series.heroImageUrl, - ctaLabel = "Open", - destination = HomeDestination( - kind = HomeDestinationKind.SERIES, - id = series.id - ) - ) - } - - BaseItemKind.EPISODE -> { - val episode = episode!! - FeaturedHomeItem( - id = episode.id, - source = FeaturedHomeSource.LIBRARY, - badge = library.name, - title = episode.title, - supportingText = listOf("Episode ${episode.index}", episode.runtime) - .filter { it.isNotBlank() } - .joinToString(" • "), - description = episode.synopsis, - metadata = listOf(episode.releaseDate, episode.runtime, episode.rating) - .filter { it.isNotBlank() }, - imageUrl = episode.heroImageUrl, - ctaLabel = "Open", - destination = HomeDestination( - kind = HomeDestinationKind.EPISODE, - id = episode.id, - seriesId = episode.seriesId, - seasonId = episode.seasonId - ) - ) - } - - else -> throw IllegalArgumentException("Unsupported featured type: $type") - } -} - -private fun openHomeDestination( - destination: HomeDestination, - onMovieSelected: (UUID) -> Unit, - onSeriesSelected: (UUID) -> Unit, - onEpisodeSelected: (UUID, UUID, UUID) -> Unit, -) { - when (destination.kind) { - HomeDestinationKind.MOVIE -> onMovieSelected(destination.id) - HomeDestinationKind.SERIES -> onSeriesSelected(destination.id) - HomeDestinationKind.EPISODE -> onEpisodeSelected( - destination.seriesId ?: return, - destination.seasonId ?: return, - destination.id - ) - } -} - @Preview(name = "Home Full", showBackground = true, widthDp = 412, heightDp = 915) @Composable private fun HomeContentPreview() { @@ -356,6 +178,7 @@ private fun HomeContentPreview() { HomeContent( libraries = homePreviewLibraries(), libraryContent = homePreviewLibraryContent(), + suggestions = emptyList(), continueWatching = homePreviewContinueWatching(), nextUp = homePreviewNextUp(), isRefreshing = false, @@ -376,6 +199,7 @@ private fun HomeLibrariesOnlyPreview() { HomeContent( libraries = homePreviewLibraries(), libraryContent = homePreviewLibraryContent(), + suggestions = emptyList(), continueWatching = emptyList(), nextUp = emptyList(), isRefreshing = false, @@ -396,6 +220,7 @@ private fun HomeEmptyPreview() { HomeContent( libraries = emptyList(), libraryContent = emptyMap(), + suggestions = emptyList(), continueWatching = emptyList(), nextUp = emptyList(), isRefreshing = false, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt deleted file mode 100644 index ac40b89..0000000 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt +++ /dev/null @@ -1,36 +0,0 @@ -package hu.bbara.purefin.app.home.ui - -import org.jellyfin.sdk.model.UUID - -enum class FeaturedHomeSource { - CONTINUE_WATCHING, - NEXT_UP, - LIBRARY -} - -enum class HomeDestinationKind { - MOVIE, - SERIES, - EPISODE -} - -data class HomeDestination( - val kind: HomeDestinationKind, - val id: UUID, - val seriesId: UUID? = null, - val seasonId: UUID? = null -) - -data class FeaturedHomeItem( - val id: UUID, - val source: FeaturedHomeSource, - val badge: String, - val title: String, - val supportingText: String, - val description: String, - val metadata: List, - val imageUrl: String, - val ctaLabel: String, - val progress: Float? = null, - val destination: HomeDestination -) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedCard.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionCard.kt similarity index 95% rename from app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedCard.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionCard.kt index 10f9ddf..513875d 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedCard.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionCard.kt @@ -24,13 +24,13 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import hu.bbara.purefin.app.home.ui.FeaturedHomeItem import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.feature.shared.home.SuggestedItem @Composable -internal fun HomeFeaturedCard( - item: FeaturedHomeItem, +internal fun SuggestionCard( + item: SuggestedItem, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -108,9 +108,9 @@ internal fun HomeFeaturedCard( } } } - if (item.progress != null && item.progress > 0f) { + if (item.progress != null && item.progress!! > 0f) { MediaProgressBar( - progress = item.progress.coerceIn(0f, 1f), + progress = item.progress!!.coerceIn(0f, 1f), foregroundColor = scheme.primary, backgroundColor = Color.White.copy(alpha = 0.26f), modifier = Modifier.align(Alignment.BottomStart) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedSection.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionsSection.kt similarity index 88% rename from app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedSection.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionsSection.kt index 20b545a..3eef825 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/HomeFeaturedSection.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/featured/SuggestionsSection.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -22,12 +20,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import hu.bbara.purefin.app.home.ui.FeaturedHomeItem +import hu.bbara.purefin.feature.shared.home.SuggestedItem @Composable -fun HomeFeaturedSection( - items: List, - onOpenFeaturedItem: (FeaturedHomeItem) -> Unit, +fun SuggestionsSection( + items: List, + onItemOpen: (SuggestedItem) -> Unit, modifier: Modifier = Modifier ) { if (items.isEmpty()) return @@ -44,9 +42,9 @@ fun HomeFeaturedSection( pageSpacing = 16.dp, modifier = Modifier.fillMaxWidth() ) { page -> - HomeFeaturedCard( + SuggestionCard( item = items[page], - onClick = { onOpenFeaturedItem(items[page]) } + onClick = { onItemOpen(items[page]) } ) } if (items.size > 1) { diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt index 2065dba..4fc1e86 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/AppContentRepository.kt @@ -8,6 +8,7 @@ import java.util.UUID interface AppContentRepository : MediaRepository { val libraries: StateFlow> + val suggestions: StateFlow> val continueWatching: StateFlow> val nextUp: StateFlow> val latestLibraryContent: StateFlow>> diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt index 5c98887..d4df879 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt @@ -58,6 +58,9 @@ class InMemoryAppContentRepository @Inject constructor( private val _libraries: MutableStateFlow> = MutableStateFlow(emptyList()) override val libraries: StateFlow> = _libraries.asStateFlow() + private val _suggestions: MutableStateFlow> = MutableStateFlow(emptyList()) + + override val suggestions: StateFlow> = _suggestions.asStateFlow() private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) override val continueWatching: StateFlow> = _continueWatching.asStateFlow() @@ -92,6 +95,9 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadFromCache() { val cache = homeCacheDataStore.data.first() + if (cache.suggestions.isNotEmpty()) { + _suggestions.value = cache.suggestions.mapNotNull { it.toMedia() } + } if (cache.continueWatching.isNotEmpty()) { _continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() } } @@ -108,6 +114,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun persistHomeCache() { val cache = HomeCache( + suggestions = _suggestions.value.map { it.toCachedItem() }, continueWatching = _continueWatching.value.map { it.toCachedItem() }, nextUp = _nextUp.value.map { it.toCachedItem() }, latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) -> @@ -150,6 +157,7 @@ class InMemoryAppContentRepository @Inject constructor( contentRepositoryReady.value = true loadJob?.cancel() loadJob = scope.launch { + loadSuggestions() loadContinueWatching() loadNextUp() loadLatestLibraryContent() @@ -228,6 +236,36 @@ class InMemoryAppContentRepository @Inject constructor( return updatedSeries } + suspend fun loadSuggestions() { + val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } + .getOrElse { error -> + Log.w(TAG, "Unable to load suggestions", error) + return + } + val items = suggestionsItems.mapNotNull { item -> + when (item.type) { + BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) + BaseItemKind.EPISODE -> Media.EpisodeMedia( + episodeId = item.id, + seriesId = item.seriesId!! + ) + else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}") + } + } + _suggestions.value = items + + //Load episodes, Movies are already loaded at this point + suggestionsItems.forEach { item -> + when (item.type) { + BaseItemKind.EPISODE -> { + val episode = item.toEpisode(serverUrl()) + mediaRepository.upsertEpisodes(listOf(episode)) + } + else -> { /* Do nothing */ } + } + } + } + suspend fun loadContinueWatching() { val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } .getOrElse { error -> @@ -337,6 +375,7 @@ class InMemoryAppContentRepository @Inject constructor( val isOnline = networkMonitor.isOnline.first() if (!isOnline) return@runCatching loadLibraries() + loadSuggestions() loadContinueWatching() loadNextUp() loadLatestLibraryContent() diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/cache/HomeCache.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/cache/HomeCache.kt index 2c01de2..5453f5d 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/cache/HomeCache.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/cache/HomeCache.kt @@ -11,6 +11,7 @@ data class CachedMediaItem( @Serializable data class HomeCache( + val suggestions: List = emptyList(), val continueWatching: List = emptyList(), val nextUp: List = emptyList(), val latestLibraryContent: Map> = emptyMap() diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt index 5c0438f..e464014 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt @@ -12,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -25,6 +26,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoResponse @@ -137,6 +139,25 @@ class JellyfinApiClient @Inject constructor( response.content.items } + suspend fun getSuggestions(): List = withContext(Dispatchers.IO) { + if (!ensureConfigured()) { + return@withContext emptyList() + } + val userId = getUserId() + if (userId == null) { + return@withContext emptyList() + } + val response = api.suggestionsApi.getSuggestions( + userId = userId, + mediaType = listOf(MediaType.VIDEO), + type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + limit = 8, + enableTotalRecordCount = true + ) + Log.d("getSuggestions", response.content.toString()) + response.content.items + } + suspend fun getContinueWatching(): List = withContext(Dispatchers.IO) { if (!ensureConfigured()) { return@withContext emptyList() diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt index 0927e32..119f0b1 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt @@ -39,12 +39,6 @@ class AppViewModel @Inject constructor( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - private val _url = userSessionRepository.serverUrl.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = "" - ) - val libraries = appContentRepository.libraries.map { libraries -> libraries.map { LibraryItem( @@ -61,6 +55,32 @@ class AppViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val suggestions = combine( + appContentRepository.suggestions, + appContentRepository.movies, + appContentRepository.series, + appContentRepository.episodes + ) { list, moviesMap, seriesMap, episodesMap -> + list.mapNotNull { media -> + when (media) { + is Media.MovieMedia -> moviesMap[media.movieId]?.let { + SuggestedMovie(movie = it) + } + is Media.SeriesMedia -> seriesMap[media.seriesId]?.let { + SuggestedSeries(series = it) + } + is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let { + SuggestedEpisode(episode = it) + } + else -> null + } + }.distinctBy { it.id } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + val continueWatching = combine( appContentRepository.continueWatching, appContentRepository.movies, diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt index 16c5a2f..947c34b 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomeModels.kt @@ -7,6 +7,76 @@ import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType + +sealed interface SuggestedItem { + val id: UUID + val badge: String + val title: String + val supportingText: String + val description: String + val metadata: List + val imageUrl: String + val ctaLabel: String + val progress: Float? + val type: BaseItemKind +} + +data class SuggestedEpisode ( + val episode: Episode, + override val badge: String = "", + override val supportingText: String = listOf("Episode ${episode.index}", episode.runtime) + .filter { it.isNotBlank() } + .joinToString(" • "), + override val metadata: List = + listOf(episode.releaseDate, episode.runtime, episode.rating, episode.format) + .filter { it.isNotBlank() }, + override val ctaLabel: String = "Open", + override val progress: Float? = episode.progress?.toFloat(), + override val type: BaseItemKind = BaseItemKind.EPISODE, + override val id: UUID = episode.id, + override val title: String = episode.title, + override val description: String = episode.synopsis, + override val imageUrl: String = episode.heroImageUrl +) : SuggestedItem + +data class SuggestedSeries ( + val series: Series, + override val badge: String = "", + override val supportingText: String = + if (series.unwatchedEpisodeCount > 0) { + "${series.unwatchedEpisodeCount} unwatched episodes" + } else { + "${series.seasonCount} seasons" + }, + override val metadata: List = + listOf(series.year, "${series.seasonCount} seasons").filter { it.isNotBlank() }, + override val ctaLabel: String = "Open", + override val progress: Float? = null, + override val type: BaseItemKind = BaseItemKind.SERIES, + override val id: UUID = series.id, + override val title: String = series.name, + override val description: String = series.synopsis, + override val imageUrl: String = series.heroImageUrl +) : SuggestedItem + +data class SuggestedMovie ( + val movie: Movie, + override val badge: String = "", + override val supportingText: String = listOf(movie.year, movie.runtime) + .filter { it.isNotBlank() } + .joinToString(" • "), + override val metadata: List = + listOf(movie.year, movie.runtime, movie.rating, movie.format) + .filter { it.isNotBlank() }, + override val ctaLabel: String = "Open", + override val progress: Float? = movie.progress?.toFloat(), + override val type: BaseItemKind = BaseItemKind.MOVIE, + override val id: UUID = movie.id, + override val title: String = movie.title, + override val description: String = movie.synopsis, + override val imageUrl: String = movie.heroImageUrl +) : SuggestedItem + data class ContinueWatchingItem( val type: BaseItemKind, val movie: Movie? = null,