Show up to 5 Continue Watching items on home with a See all grid.

Cap the home row at five titles and open a continue-watching catalog when more exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 00:00:25 +02:00
parent 4b3c594609
commit 38a779c5f8
6 changed files with 68 additions and 12 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
versionCode = 11
versionName = "0.7.4"
versionCode = 12
versionName = "0.7.5"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -326,6 +326,7 @@ data class MediaItem(
val posterUrl: String? = null,
val backdropUrl: String? = null,
val mediaFileId: String? = null,
val seriesId: String? = null,
val positionMs: Long = 0,
val durationMs: Long = 0,
val progressId: String? = null,
@ -388,10 +389,11 @@ private fun JSONArray?.toItems(): List<MediaItem> {
posterUrl = o.optString("posterUrl").ifBlank { null },
backdropUrl = o.optString("backdropUrl").ifBlank { null },
mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null },
seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null },
positionMs = o.optLong("positionMs"),
durationMs = o.optLong("durationMs"),
progressId = o.optString("progressId").ifBlank { null },
badge = o.optString("badge").ifBlank { null },
progressId = o.optString("progressId").takeIf { it.isNotBlank() && it != "null" },
badge = o.optString("badge").takeIf { it.isNotBlank() && it != "null" },
)
)
}

View file

@ -49,6 +49,14 @@ fun BrowseScreen(
onBack: (() -> Unit)? = null,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
onPlayContinue: ((
mediaFileId: String,
movieId: String?,
episodeId: String?,
seriesId: String?,
type: String,
startMs: Long,
) -> Unit)? = null,
) {
var title by remember { mutableStateOf(titleHint ?: "Bibliotheek") }
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
@ -135,13 +143,31 @@ fun BrowseScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { "${it.type}:${it.id}" }) { item ->
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
width = 140.dp,
height = 210.dp,
onClick = {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() &&
(item.positionMs > 0 || catalogId == "continue-watching")
if (canResume) {
onPlayContinue!!(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
item.seriesId,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
} else if (item.type == "movie") {
onOpenMovie(item.id)
} else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
onOpenSeries(item.seriesId!!)
} else {
onOpenSeries(item.id)
}
},
)
}

View file

@ -131,6 +131,16 @@ fun McNav() {
onBack = { nav.popBackStack() },
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onPlayContinue = if (catalogId == "continue-watching") {
{ mediaFileId, movieId, episodeId, seriesId, type, startMs ->
val m = movieId?.ifBlank { null } ?: "-"
val e = episodeId?.ifBlank { null } ?: "-"
val s = seriesId?.ifBlank { null } ?: "-"
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
}
} else {
null
},
)
}
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
@ -479,22 +489,28 @@ private fun HomeScreen(
}
val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5)
item {
ShelfBlock(
title = "Verder kijken",
items = continueItems,
onSeeAll = null,
items = preview,
onSeeAll = if (continueItems.size > 5) {
{ onSeeAll("continue-watching", "Verder kijken") }
} else {
null
},
onClick = { item ->
when {
!item.mediaFileId.isNullOrBlank() -> onPlay(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
null,
item.seriesId,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
item.type == "movie" -> onOpenMovie(item.id)
item.type == "episode" && !item.seriesId.isNullOrBlank() -> onOpenSeries(item.seriesId!!)
else -> onOpenSeries(item.id)
}
},

View file

@ -185,12 +185,12 @@ export function registerViewerRoutes(
});
app.get("/api/v1/client/catalog/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
const q = request.query as { skip?: string; take?: string };
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100);
return viewers.browseCatalog(id, skip, take);
return viewers.browseCatalog(id, skip, take, auth.viewerId);
});
app.get("/api/v1/client/shelves/prefs", async (request) => {

View file

@ -875,9 +875,21 @@ export class ViewerService {
.slice(0, take);
}
async browseCatalog(catalogId: string, skip: number, take: number) {
async browseCatalog(catalogId: string, skip: number, take: number, viewerId?: string) {
const id = catalogId.trim().toLowerCase();
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
if (!viewerId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
const all = await this.continueWatching(viewerId);
return {
id: "continue-watching",
title: "Verder kijken",
kind: "MIXED" as const,
total: all.length,
items: all.slice(skip, skip + take),
};
}
if (id === "movies" || id === "films") {
const total = await prisma.movie.count({
where: { mediaFiles: { some: { available: true } } },