mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 featuredLead = featuredItems.firstOrNull()
|
||||
val filteredContinueWatching = remember(continueWatching, featuredLead) {
|
||||
if (featuredLead?.source == FeaturedHomeSource.CONTINUE_WATCHING) {
|
||||
continueWatching.filterNot { it.id == featuredLead.id }
|
||||
} else {
|
||||
continueWatching
|
||||
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 filteredNextUp = remember(nextUp, featuredLead) {
|
||||
if (featuredLead?.source == FeaturedHomeSource.NEXT_UP) {
|
||||
nextUp.filterNot { it.id == featuredLead.id }
|
||||
} else {
|
||||
nextUp
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
suggestions.isNotEmpty(),
|
||||
pendingInitialSuggestionsReveal,
|
||||
userInteractedBeforeSuggestionsLoaded
|
||||
) {
|
||||
if (!suggestions.isNotEmpty() || !pendingInitialSuggestionsReveal) return@LaunchedEffect
|
||||
if (!userInteractedBeforeSuggestionsLoaded) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
pendingInitialSuggestionsReveal = false
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
@@ -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>>>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user