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 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
val featuredLead = featuredItems.firstOrNull()
|
snapshotFlow { listState.isScrollInProgress }
|
||||||
val filteredContinueWatching = remember(continueWatching, featuredLead) {
|
.collect { isScrolling ->
|
||||||
if (featuredLead?.source == FeaturedHomeSource.CONTINUE_WATCHING) {
|
if (isScrolling) {
|
||||||
continueWatching.filterNot { it.id == featuredLead.id }
|
userInteractedBeforeSuggestionsLoaded = true
|
||||||
} else {
|
|
||||||
continueWatching
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(
|
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,
|
||||||
|
|||||||
@@ -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.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)
|
||||||
@@ -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) {
|
||||||
@@ -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>>>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user