From 38a779c5f88cbc037375e0f94e8bdfa119f7e3ec Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 1 Sep 2026 00:00:25 +0200 Subject: [PATCH] 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 --- apps/android-tv/app/build.gradle.kts | 4 +-- .../vonas/mediacluster/tv/data/ApiClient.kt | 6 ++-- .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 30 +++++++++++++++++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 22 ++++++++++++-- apps/master-api/src/viewer/routes.ts | 4 +-- apps/master-api/src/viewer/service.ts | 14 ++++++++- 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index ceaced4..a92734a 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 55d4f59..4d40596 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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 { 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" }, ) ) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 2b1d34c..a0bb4ed 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -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>(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) + } }, ) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index ab9cd60..3d7afc4 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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) } }, diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 40de170..240af17 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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) => { diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 38daaa7..f559672 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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 } } },