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

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