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.
This commit is contained in:
2026-03-31 15:51:07 +02:00
parent f2759b271e
commit f107085385
12 changed files with 230 additions and 285 deletions

View File

@@ -22,6 +22,7 @@ fun AppScreen(
val libraries by viewModel.libraries.collectAsState() val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.latestLibraryContent.collectAsState() val libraryContent by viewModel.latestLibraryContent.collectAsState()
val suggestions by viewModel.suggestions.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState()
@@ -43,6 +44,7 @@ fun AppScreen(
0 -> HomeScreen( 0 -> HomeScreen(
libraries = libraries, libraries = libraries,
libraryContent = libraryContent, libraryContent = libraryContent,
suggestions = suggestions,
continueWatching = continueWatching, continueWatching = continueWatching,
nextUp = nextUp, nextUp = nextUp,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,

View File

@@ -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.homePreviewNextUp
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem 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.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
@@ -33,6 +34,7 @@ import org.jellyfin.sdk.model.UUID
fun HomeScreen( fun HomeScreen(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>, libraryContent: Map<UUID, List<PosterItem>>,
suggestions: List<SuggestedItem>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>, nextUp: List<NextUpItem>,
isRefreshing: Boolean, isRefreshing: Boolean,
@@ -84,6 +86,7 @@ fun HomeScreen(
HomeContent( HomeContent(
libraries = libraries, libraries = libraries,
libraryContent = libraryContent, libraryContent = libraryContent,
suggestions = suggestions,
continueWatching = continueWatching, continueWatching = continueWatching,
nextUp = nextUp, nextUp = nextUp,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@@ -122,6 +125,7 @@ private fun HomeScreenPreview() {
HomeScreen( HomeScreen(
libraries = homePreviewLibraries(), libraries = homePreviewLibraries(),
libraryContent = homePreviewLibraryContent(), libraryContent = homePreviewLibraryContent(),
suggestions = emptyList(),
continueWatching = homePreviewContinueWatching(), continueWatching = homePreviewContinueWatching(),
nextUp = homePreviewNextUp(), nextUp = homePreviewNextUp(),
isRefreshing = false, isRefreshing = false,

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.app.home.ui package hu.bbara.purefin.app.home.ui
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable 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.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.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.continuewatching.ContinueWatchingSection 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.library.LibraryPosterSection
import hu.bbara.purefin.app.home.ui.nextup.NextUpSection import hu.bbara.purefin.app.home.ui.nextup.NextUpSection
import hu.bbara.purefin.app.home.ui.shared.HomeEmptyState 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.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.home.SuggestedItem
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@@ -38,6 +47,7 @@ import java.util.UUID as JavaUuid
fun HomeContent( fun HomeContent(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>, libraryContent: Map<UUID, List<PosterItem>>,
suggestions: List<SuggestedItem>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>, nextUp: List<NextUpItem>,
isRefreshing: Boolean, isRefreshing: Boolean,
@@ -53,33 +63,33 @@ fun HomeContent(
val visibleLibraries = remember(libraries, libraryContent) { val visibleLibraries = remember(libraries, libraryContent) {
libraries.filter { libraryContent[it.id].orEmpty().isNotEmpty() } libraries.filter { libraryContent[it.id].orEmpty().isNotEmpty() }
} }
val featuredItems = remember(continueWatching, nextUp, visibleLibraries, libraryContent) { val listState = rememberLazyListState()
buildFeaturedItems( var pendingInitialSuggestionsReveal by rememberSaveable { mutableStateOf(suggestions.isEmpty()) }
continueWatching = continueWatching, var userInteractedBeforeSuggestionsLoaded by rememberSaveable { mutableStateOf(false) }
nextUp = nextUp,
visibleLibraries = visibleLibraries, val hasContent = libraryContent.isNotEmpty() || continueWatching.isNotEmpty() || nextUp.isNotEmpty() || suggestions.isNotEmpty()
libraryContent = libraryContent
) 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) { LaunchedEffect(
if (featuredLead?.source == FeaturedHomeSource.CONTINUE_WATCHING) { suggestions.isNotEmpty(),
continueWatching.filterNot { it.id == featuredLead.id } pendingInitialSuggestionsReveal,
} else { userInteractedBeforeSuggestionsLoaded
continueWatching ) {
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( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@@ -92,40 +102,43 @@ fun HomeContent(
.background(scheme.background) .background(scheme.background)
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
if (featuredItems.isNotEmpty()) { if (suggestions.isNotEmpty()) {
item(key = "featured") { item(key = "featured") {
HomeFeaturedSection( SuggestionsSection(
items = featuredItems, items = suggestions,
onOpenFeaturedItem = { item -> onItemOpen = { item ->
openHomeDestination( when (item.type) {
destination = item.destination, BaseItemKind.MOVIE -> onMovieSelected(item.id)
onMovieSelected = onMovieSelected, BaseItemKind.SERIES -> onSeriesSelected(item.id)
onSeriesSelected = onSeriesSelected, BaseItemKind.EPISODE -> onEpisodeSelected(item.id, item.id, item.id)
onEpisodeSelected = onEpisodeSelected else -> {
) Log.e("HomeContent", "Unsupported item type: ${item.type}")
}
}
} }
) )
} }
} }
if (filteredContinueWatching.isNotEmpty()) { if (continueWatching.isNotEmpty()) {
item(key = "continue-watching") { item(key = "continue-watching") {
ContinueWatchingSection( ContinueWatchingSection(
items = filteredContinueWatching, items = continueWatching,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
} }
} }
if (filteredNextUp.isNotEmpty()) { if (nextUp.isNotEmpty()) {
item(key = "next-up") { item(key = "next-up") {
NextUpSection( NextUpSection(
items = filteredNextUp, items = nextUp,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
} }
@@ -158,197 +171,6 @@ fun HomeContent(
} }
} }
private fun buildFeaturedItems(
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
visibleLibraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>
): List<FeaturedHomeItem> {
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) @Preview(name = "Home Full", showBackground = true, widthDp = 412, heightDp = 915)
@Composable @Composable
private fun HomeContentPreview() { private fun HomeContentPreview() {
@@ -356,6 +178,7 @@ private fun HomeContentPreview() {
HomeContent( HomeContent(
libraries = homePreviewLibraries(), libraries = homePreviewLibraries(),
libraryContent = homePreviewLibraryContent(), libraryContent = homePreviewLibraryContent(),
suggestions = emptyList(),
continueWatching = homePreviewContinueWatching(), continueWatching = homePreviewContinueWatching(),
nextUp = homePreviewNextUp(), nextUp = homePreviewNextUp(),
isRefreshing = false, isRefreshing = false,
@@ -376,6 +199,7 @@ private fun HomeLibrariesOnlyPreview() {
HomeContent( HomeContent(
libraries = homePreviewLibraries(), libraries = homePreviewLibraries(),
libraryContent = homePreviewLibraryContent(), libraryContent = homePreviewLibraryContent(),
suggestions = emptyList(),
continueWatching = emptyList(), continueWatching = emptyList(),
nextUp = emptyList(), nextUp = emptyList(),
isRefreshing = false, isRefreshing = false,
@@ -396,6 +220,7 @@ private fun HomeEmptyPreview() {
HomeContent( HomeContent(
libraries = emptyList(), libraries = emptyList(),
libraryContent = emptyMap(), libraryContent = emptyMap(),
suggestions = emptyList(),
continueWatching = emptyList(), continueWatching = emptyList(),
nextUp = emptyList(), nextUp = emptyList(),
isRefreshing = false, isRefreshing = false,

View File

@@ -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<String>,
val imageUrl: String,
val ctaLabel: String,
val progress: Float? = null,
val destination: HomeDestination
)

View File

@@ -24,13 +24,13 @@ import androidx.compose.ui.layout.ContentScale
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 hu.bbara.purefin.app.home.ui.FeaturedHomeItem
import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.SuggestedItem
@Composable @Composable
internal fun HomeFeaturedCard( internal fun SuggestionCard(
item: FeaturedHomeItem, item: SuggestedItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier 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( MediaProgressBar(
progress = item.progress.coerceIn(0f, 1f), progress = item.progress!!.coerceIn(0f, 1f),
foregroundColor = scheme.primary, foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.26f), backgroundColor = Color.White.copy(alpha = 0.26f),
modifier = Modifier.align(Alignment.BottomStart) modifier = Modifier.align(Alignment.BottomStart)

View File

@@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -22,12 +20,12 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.FeaturedHomeItem import hu.bbara.purefin.feature.shared.home.SuggestedItem
@Composable @Composable
fun HomeFeaturedSection( fun SuggestionsSection(
items: List<FeaturedHomeItem>, items: List<SuggestedItem>,
onOpenFeaturedItem: (FeaturedHomeItem) -> Unit, onItemOpen: (SuggestedItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -44,9 +42,9 @@ fun HomeFeaturedSection(
pageSpacing = 16.dp, pageSpacing = 16.dp,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { page -> ) { page ->
HomeFeaturedCard( SuggestionCard(
item = items[page], item = items[page],
onClick = { onOpenFeaturedItem(items[page]) } onClick = { onItemOpen(items[page]) }
) )
} }
if (items.size > 1) { if (items.size > 1) {

View File

@@ -8,6 +8,7 @@ import java.util.UUID
interface AppContentRepository : MediaRepository { interface AppContentRepository : MediaRepository {
val libraries: StateFlow<List<Library>> val libraries: StateFlow<List<Library>>
val suggestions: StateFlow<List<Media>>
val continueWatching: StateFlow<List<Media>> val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>> val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>

View File

@@ -58,6 +58,9 @@ class InMemoryAppContentRepository @Inject constructor(
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList()) private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow() override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow()
private val _suggestions: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val suggestions: StateFlow<List<Media>> = _suggestions.asStateFlow()
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList()) private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow() override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
@@ -92,6 +95,9 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadFromCache() { private suspend fun loadFromCache() {
val cache = homeCacheDataStore.data.first() val cache = homeCacheDataStore.data.first()
if (cache.suggestions.isNotEmpty()) {
_suggestions.value = cache.suggestions.mapNotNull { it.toMedia() }
}
if (cache.continueWatching.isNotEmpty()) { if (cache.continueWatching.isNotEmpty()) {
_continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() } _continueWatching.value = cache.continueWatching.mapNotNull { it.toMedia() }
} }
@@ -108,6 +114,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun persistHomeCache() { private suspend fun persistHomeCache() {
val cache = HomeCache( val cache = HomeCache(
suggestions = _suggestions.value.map { it.toCachedItem() },
continueWatching = _continueWatching.value.map { it.toCachedItem() }, continueWatching = _continueWatching.value.map { it.toCachedItem() },
nextUp = _nextUp.value.map { it.toCachedItem() }, nextUp = _nextUp.value.map { it.toCachedItem() },
latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) -> latestLibraryContent = _latestLibraryContent.value.map { (uuid, items) ->
@@ -150,6 +157,7 @@ class InMemoryAppContentRepository @Inject constructor(
contentRepositoryReady.value = true contentRepositoryReady.value = true
loadJob?.cancel() loadJob?.cancel()
loadJob = scope.launch { loadJob = scope.launch {
loadSuggestions()
loadContinueWatching() loadContinueWatching()
loadNextUp() loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()
@@ -228,6 +236,36 @@ class InMemoryAppContentRepository @Inject constructor(
return updatedSeries 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() { suspend fun loadContinueWatching() {
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error -> .getOrElse { error ->
@@ -337,6 +375,7 @@ class InMemoryAppContentRepository @Inject constructor(
val isOnline = networkMonitor.isOnline.first() val isOnline = networkMonitor.isOnline.first()
if (!isOnline) return@runCatching if (!isOnline) return@runCatching
loadLibraries() loadLibraries()
loadSuggestions()
loadContinueWatching() loadContinueWatching()
loadNextUp() loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()

View File

@@ -11,6 +11,7 @@ data class CachedMediaItem(
@Serializable @Serializable
data class HomeCache( data class HomeCache(
val suggestions: List<CachedMediaItem> = emptyList(),
val continueWatching: List<CachedMediaItem> = emptyList(), val continueWatching: List<CachedMediaItem> = emptyList(),
val nextUp: List<CachedMediaItem> = emptyList(), val nextUp: List<CachedMediaItem> = emptyList(),
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap() val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap()

View File

@@ -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.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.playStateApi 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.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.CollectionType
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo 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.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.PlaybackInfoResponse import org.jellyfin.sdk.model.api.PlaybackInfoResponse
@@ -137,6 +139,25 @@ class JellyfinApiClient @Inject constructor(
response.content.items response.content.items
} }
suspend fun getSuggestions(): List<BaseItemDto> = 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<BaseItemDto> = withContext(Dispatchers.IO) { suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@withContext emptyList() return@withContext emptyList()

View File

@@ -39,12 +39,6 @@ class AppViewModel @Inject constructor(
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _url = userSessionRepository.serverUrl.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ""
)
val libraries = appContentRepository.libraries.map { libraries -> val libraries = appContentRepository.libraries.map { libraries ->
libraries.map { libraries.map {
LibraryItem( LibraryItem(
@@ -61,6 +55,32 @@ class AppViewModel @Inject constructor(
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.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( val continueWatching = combine(
appContentRepository.continueWatching, appContentRepository.continueWatching,
appContentRepository.movies, appContentRepository.movies,

View File

@@ -7,6 +7,76 @@ import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType 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<String>
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<String> =
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<String> =
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<String> =
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( data class ContinueWatchingItem(
val type: BaseItemKind, val type: BaseItemKind,
val movie: Movie? = null, val movie: Movie? = null,