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

@@ -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()