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 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,

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.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<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
suggestions: List<SuggestedItem>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
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,

View File

@@ -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<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
suggestions: List<SuggestedItem>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
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<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)
@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,

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.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)

View File

@@ -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<FeaturedHomeItem>,
onOpenFeaturedItem: (FeaturedHomeItem) -> Unit,
fun SuggestionsSection(
items: List<SuggestedItem>,
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) {

View File

@@ -8,6 +8,7 @@ import java.util.UUID
interface AppContentRepository : MediaRepository {
val libraries: StateFlow<List<Library>>
val suggestions: StateFlow<List<Media>>
val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<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())
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())
override val continueWatching: StateFlow<List<Media>> = _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()

View File

@@ -11,6 +11,7 @@ data class CachedMediaItem(
@Serializable
data class HomeCache(
val suggestions: List<CachedMediaItem> = emptyList(),
val continueWatching: List<CachedMediaItem> = emptyList(),
val nextUp: List<CachedMediaItem> = emptyList(),
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.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<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) {
if (!ensureConfigured()) {
return@withContext emptyList()

View File

@@ -39,12 +39,6 @@ class AppViewModel @Inject constructor(
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _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,

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.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(
val type: BaseItemKind,
val movie: Movie? = null,