From 07901c1d0c9621c5df863fc80cf6ccf430647a34 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 12 Sep 2026 23:50:07 +0200 Subject: [PATCH] Add watchlist, next-episode playback, and fix TV shelf reorder. Probe-driven continue-watching advances to the next episode; Android TV gets next controls, red focus rings, and a Watchlist tab between Home and Films. --- .../mediacluster/tv/ui/components/TopNav.kt | 2 + apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 65 ++++++ .../vonas/mediacluster/tv/ui/DetailScreens.kt | 19 ++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 199 ++++++++++++------ .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 94 ++++++++- .../tv/ui/components/Components.kt | 2 +- .../mediacluster/tv/ui/components/TopNav.kt | 1 + apps/ios/VMC/UI/HomeScreen.swift | 2 +- apps/master-api/src/viewer/routes.ts | 8 + apps/master-api/src/viewer/service.ts | 87 +++++++- 11 files changed, 414 insertions(+), 69 deletions(-) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index b21f90c..1ab7b00 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -41,6 +41,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors enum class MainTab(val label: String) { Home("Home"), + Watchlist("Watchlist"), Movies("Films"), Series("Series"), Tv("TV"), @@ -56,6 +57,7 @@ data class SubNavItem( private fun MainTab.icon(): ImageVector = when (this) { MainTab.Home -> Icons.Filled.Home + MainTab.Watchlist -> Icons.Filled.Star MainTab.Movies -> Icons.Filled.PlayArrow MainTab.Series -> Icons.Filled.Menu MainTab.Tv -> Icons.Filled.Star diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 729eb80..45facff 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 86 - versionName = "0.14.23" + versionCode = 87 + versionName = "0.14.24" 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 917e482..4cf8197 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 @@ -151,6 +151,59 @@ class ApiClient( JSONObject(execute(req)) } + suspend fun nextEpisode(episodeId: String): NextEpisode? = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/episodes/$episodeId/next").get().build() + parseObject(execute(req)) { o -> + val n = o.optJSONObject("next") ?: return@parseObject null + val mediaFileId = n.optString("mediaFileId").ifBlank { null } ?: return@parseObject null + NextEpisode( + id = n.getString("id"), + seriesId = n.getString("seriesId"), + seasonNumber = n.optInt("seasonNumber"), + episodeNumber = n.optInt("episodeNumber"), + title = n.optString("title").ifBlank { null }, + seriesTitle = n.optString("seriesTitle").ifBlank { null }, + subtitle = n.optString("subtitle").ifBlank { null }, + mediaFileId = mediaFileId, + posterUrl = n.optString("posterUrl").ifBlank { null }, + ) + } + } + + suspend fun listFavorites(): List = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/favorites").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("items") ?: return@parseObject emptyList() + buildList { + for (i in 0 until arr.length()) { + val it = arr.optJSONObject(i) ?: continue + add( + MediaItem( + type = it.optString("type").ifBlank { "movie" }, + id = it.getString("id"), + title = it.optString("title"), + year = it.optInt("year").takeIf { y -> y > 0 }, + posterUrl = it.optString("posterUrl").ifBlank { null }, + backdropUrl = it.optString("backdropUrl").ifBlank { null }, + logoUrl = it.optString("logoUrl").ifBlank { null }, + mediaFileId = it.optString("mediaFileId").ifBlank { null }, + ) + ) + } + } + } + } + + suspend fun toggleFavorite(type: String, id: String): Boolean = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("type", type) + .put("id", id) + .toString() + .toRequestBody(jsonMedia) + val req = authed("$baseUrl/api/v1/client/favorites/toggle").post(body).build() + parseObject(execute(req)) { it.optBoolean("favorited") } + } + suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) { val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val req = authed("$baseUrl/api/v1/client/play").post(body).build() @@ -602,6 +655,18 @@ data class MediaItem( val badge: String? = null, ) +data class NextEpisode( + val id: String, + val seriesId: String, + val seasonNumber: Int, + val episodeNumber: Int, + val title: String?, + val seriesTitle: String?, + val subtitle: String?, + val mediaFileId: String, + val posterUrl: String?, +) + data class PlayResult( val streamUrl: String, val sessionId: String, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index 54b4c43..031e630 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -309,6 +309,7 @@ fun MovieDetailScreen( var versions by remember { mutableStateOf>(emptyList()) } var selectedVersionId by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + var onWatchlist by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val playFocus = remember { FocusRequester() } val scroll = rememberScrollState() @@ -327,6 +328,9 @@ fun MovieDetailScreen( genres = o.optJSONArray("genres").stringList() cast = o.optJSONArray("cast").stringList() director = o.optJSONArray("director").stringList() + onWatchlist = runCatching { + api.listFavorites().any { it.id == movieId && it.type.equals("movie", true) } + }.getOrDefault(false) trailerKey = o.optCleanString("trailerYoutubeKey") val arr = o.optJSONArray("mediaFiles") val list = mutableListOf() @@ -397,6 +401,11 @@ fun MovieDetailScreen( openYoutubeTrailer(context, trailerKey!!) } } + DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { + scope.launch { + runCatching { onWatchlist = api.toggleFavorite("movie", movieId) } + } + } DetailActionButton("Markeer als gezien") { scope.launch { runCatching { api.markWatched(movieId = movieId) } @@ -531,6 +540,8 @@ fun SeriesDetailScreen( var seasons by remember { mutableStateOf>(emptyList()) } var selectedSeason by remember { mutableIntStateOf(0) } var error by remember { mutableStateOf(null) } + var onWatchlist by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() val firstEpFocus = remember { FocusRequester() } LaunchedEffect(seriesId) { @@ -544,6 +555,9 @@ fun SeriesDetailScreen( rating = o.optCleanString("rating") genres = o.optJSONArray("genres").stringList() trailerKey = o.optCleanString("trailerYoutubeKey") + onWatchlist = runCatching { + api.listFavorites().any { it.id == seriesId && it.type.equals("series", true) } + }.getOrDefault(false) val arr = o.optJSONArray("seasons") val list = mutableListOf() if (arr != null) { @@ -627,6 +641,11 @@ fun SeriesDetailScreen( if (!trailerKey.isNullOrBlank()) { DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } } + DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { + scope.launch { + runCatching { onWatchlist = api.toggleFavorite("series", seriesId) } + } + } } if (seasons.size > 1) { Spacer(Modifier.height(8.dp)) 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 fc00c7d..72b7d24 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 @@ -368,6 +368,13 @@ fun McNav() { seriesId = dash(entry.arguments?.getString("seriesId")), startMs = entry.arguments?.getLong("startMs") ?: 0L, onBack = { nav.popBackStack() }, + onPlayNext = { nextMediaFileId, nextEpisodeId, nextSeriesId -> + nav.navigate("player/$nextMediaFileId/EPISODE/-/$nextEpisodeId/$nextSeriesId/0") { + popUpTo("player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}") { + inclusive = true + } + } + }, ) } composable( @@ -655,6 +662,12 @@ private fun MainHub( onSeeAll = onBrowse, onPlay = onPlay, ) + MainTab.Watchlist -> WatchlistScreen( + api = api, + onAtTopBack = { focusTopNav() }, + onOpenMovie = onOpenMovie, + onOpenSeries = onOpenSeries, + ) MainTab.Movies -> BrowseScreen( api = api, catalogId = movieCatalog, @@ -1702,73 +1715,61 @@ private fun SettingsScreen( Spacer(Modifier.height(8.dp)) } - itemsIndexed(prefs) { index, p -> - Surface( - onClick = { - val next = prefs.toMutableList() - next[index] = p.copy(visible = !p.visible) - persist(next) - }, - modifier = Modifier.fillMaxWidth(), - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0xFF1A1A22), - focusedContainerColor = Color(0xFF262632), - ), + itemsIndexed(prefs, key = { _, p -> p.shelfId }) { index, p -> + // Geen clickable parent-Surface: anders vangen ↑/↓ geen OK op Android TV. + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF1A1A22)) + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, ) { + Column(Modifier.weight(1f)) { + Text( + p.name, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Spacer(Modifier.height(3.dp)) + Text( + if (p.visible) "Zichtbaar op Home" else "Verborgen", + color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555), + fontSize = 13.sp, + ) + } Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(Modifier.weight(1f)) { - Text( - p.name, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) - Spacer(Modifier.height(3.dp)) - Text( - if (p.visible) "Zichtbaar op Home" else "Verborgen", - color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555), - fontSize = 13.sp, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - McButton("↑") { - if (index > 0) { - val next = prefs.toMutableList() - val tmp = next[index - 1] - next[index - 1] = next[index] - next[index] = tmp - persist(next) - } + McButton("↑") { + if (index > 0) { + val next = prefs.toMutableList() + val tmp = next[index - 1] + next[index - 1] = next[index] + next[index] = tmp + persist(next) } - McButton("↓") { - if (index < prefs.lastIndex) { - val next = prefs.toMutableList() - val tmp = next[index + 1] - next[index + 1] = next[index] - next[index] = tmp - persist(next) - } - } - Switch( - checked = p.visible, - onCheckedChange = { checked -> - val next = prefs.toMutableList() - next[index] = p.copy(visible = checked) - persist(next) - }, - ) } + McButton("↓") { + if (index < prefs.lastIndex) { + val next = prefs.toMutableList() + val tmp = next[index + 1] + next[index + 1] = next[index] + next[index] = tmp + persist(next) + } + } + Switch( + checked = p.visible, + onCheckedChange = { checked -> + val next = prefs.toMutableList() + next[index] = p.copy(visible = checked) + persist(next) + }, + ) } } } @@ -1787,6 +1788,82 @@ private fun SettingsScreen( } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun WatchlistScreen( + api: ApiClient, + onAtTopBack: () -> Unit, + onOpenMovie: (String) -> Unit, + onOpenSeries: (String) -> Unit, +) { + var items by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + val firstFocus = remember { FocusRequester() } + + LaunchedEffect(Unit) { + loading = true + try { + items = api.listFavorites() + error = null + } catch (e: Exception) { + error = e.message + } + loading = false + runCatching { firstFocus.requestFocus() } + } + + BackHandler(onBack = onAtTopBack) + Column( + Modifier + .fillMaxSize() + .background(Color(0xFF0B0B0F)) + .padding(horizontal = 40.dp, vertical = 20.dp), + ) { + Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + Text( + "Films en series die je later wilt kijken", + color = Color(0xFF8E8E9A), + fontSize = 14.sp, + ) + Spacer(Modifier.height(16.dp)) + when { + loading -> Text("Laden…", color = Color(0xFF8E8E9A)) + error != null -> Text(error!!, color = McColors.Danger) + items.isEmpty() -> Text( + "Nog niets op je watchlist. Voeg titels toe vanaf de detailpagina.", + color = Color(0xFF8E8E9A), + fontSize = 15.sp, + ) + else -> { + androidx.compose.foundation.lazy.grid.LazyVerticalGrid( + columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(bottom = 32.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(items.size, key = { items[it].id }) { idx -> + val item = items[idx] + PosterCard( + item = item, + onClick = { + when (item.type.lowercase()) { + "series" -> onOpenSeries(item.id) + else -> onOpenMovie(item.id) + } + }, + modifier = if (idx == 0) Modifier.focusRequester(firstFocus) else Modifier, + fillWidth = true, + ) + } + } + } + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SettingsActionRow( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index e8964c1..ca830ae 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -91,11 +92,13 @@ fun PlayerScreen( seriesId: String?, startMs: Long, onBack: () -> Unit, + onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null, ) { val context = LocalContext.current val player = remember { buildTvPlayer(context) } val controlsFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() } + val nextOverlayFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() var error by remember { mutableStateOf(null) } @@ -113,13 +116,23 @@ fun PlayerScreen( var osLoading by remember { mutableStateOf(false) } var osError by remember { mutableStateOf(null) } var osBusy by remember { mutableStateOf(false) } + var nextEp by remember { mutableStateOf(null) } + var showNextOverlay by remember { mutableStateOf(false) } + var nextCountdown by remember { mutableIntStateOf(10) } + var nextOverlayConsumed by remember { mutableStateOf(false) } fun showControls() { controls = true } fun hideControls() { - if (menu == null) controls = false + if (menu == null && !showNextOverlay) controls = false + } + + fun goNext() { + val n = nextEp ?: return + showNextOverlay = false + onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack() } fun togglePlay() { @@ -196,6 +209,10 @@ fun PlayerScreen( BackHandler { when { + showNextOverlay -> { + showNextOverlay = false + nextOverlayConsumed = true + } menu == "os" -> menu = "text" menu != null -> menu = null controls -> { @@ -207,6 +224,13 @@ fun PlayerScreen( } LaunchedEffect(mediaFileId) { + nextEp = null + showNextOverlay = false + nextOverlayConsumed = false + nextCountdown = 10 + if (mediaType == "EPISODE" && !episodeId.isNullOrBlank()) { + nextEp = runCatching { api.nextEpisode(episodeId) }.getOrNull() + } try { val play = api.play(mediaFileId) embeddedHints = play.embeddedTracks @@ -242,6 +266,17 @@ fun PlayerScreen( } } + LaunchedEffect(showNextOverlay, nextEp) { + if (!showNextOverlay || nextEp == null) return@LaunchedEffect + nextCountdown = 10 + runCatching { nextOverlayFocus.requestFocus() } + while (nextCountdown > 0 && showNextOverlay) { + kotlinx.coroutines.delay(1000) + nextCountdown-- + } + if (showNextOverlay && nextCountdown <= 0) goNext() + } + DisposableEffect(player) { val listener = object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { @@ -252,6 +287,12 @@ fun PlayerScreen( audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value) textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value) } + if (state == Player.STATE_ENDED && nextEp != null && !nextOverlayConsumed) { + nextOverlayConsumed = true + player.pause() + showNextOverlay = true + menu = null + } } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -550,6 +591,11 @@ fun PlayerScreen( verticalAlignment = Alignment.CenterVertically, ) { NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() } + if (nextEp != null && onPlayNext != null) { + NetflixIconButton("⏭") { + goNext() + } + } NetflixIconButton("↺ 10") { player.seekBack() showControls() @@ -572,6 +618,52 @@ fun PlayerScreen( } } + if (showNextOverlay && nextEp != null) { + val n = nextEp!! + Box( + Modifier + .fillMaxSize() + .background(Color(0xCC000000)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .width(520.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF14141C)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Volgende aflevering", color = Color(0xFF8E8E9A), fontSize = 14.sp) + Text( + n.seriesTitle ?: "Serie", + color = Color.White, + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + ) + Text( + n.subtitle ?: "S${n.seasonNumber}E${n.episodeNumber}", + color = Color.White, + fontSize = 18.sp, + ) + Text( + "Start over ${nextCountdown}s", + color = McColors.Brand, + fontSize = 15.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NetflixTextButton("Speel nu") { goNext() } + NetflixTextButton("Annuleren") { + showNextOverlay = false + nextOverlayConsumed = true + player.pause() + showControls() + } + } + } + } + } + if (menu != null) { val isAudio = menu == "audio" val isOs = menu == "os" diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index a5feb32..f9b1c95 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -171,7 +171,7 @@ fun PosterCard( scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f), border = ClickableSurfaceDefaults.border( focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(3.dp, Color.White), + border = androidx.compose.foundation.BorderStroke(3.dp, McColors.Brand), shape = RoundedCornerShape(4.dp), ), ), diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index e4f3d25..3b77742 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -51,6 +51,7 @@ object HomeChrome { enum class MainTab(val label: String) { Home("Home"), + Watchlist("Watchlist"), Movies("Films"), Series("Series"), Tv("TV"), diff --git a/apps/ios/VMC/UI/HomeScreen.swift b/apps/ios/VMC/UI/HomeScreen.swift index d3f12ab..3d0161e 100644 --- a/apps/ios/VMC/UI/HomeScreen.swift +++ b/apps/ios/VMC/UI/HomeScreen.swift @@ -36,7 +36,7 @@ struct HomeScreen: View { VStack(alignment: .leading, spacing: 22) { if !app.favorites.mediaItems.isEmpty { - ShelfSection(title: "Favorieten", items: app.favorites.mediaItems) + ShelfSection(title: "Watchlist", items: app.favorites.mediaItems) } if !home.continueWatching.isEmpty { diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 6431c6f..5863d15 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -535,6 +535,14 @@ export function registerViewerRoutes( return viewers.seriesDetail(id, auth.profile?.isKids === true); }); + app.get("/api/v1/client/episodes/:id/next", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + requireProfile(auth); + const { id } = request.params as { id: string }; + const next = await viewers.findNextPlayableEpisode(id); + return { next }; + }); + app.post("/api/v1/client/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); requireProfile(auth); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 2d6922a..226ac84 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -1564,7 +1564,11 @@ export class ViewerService { profileId, hiddenFromContinue: false, completed: false, - positionMs: { gt: 30_000 }, + OR: [ + { positionMs: { gt: 30_000 } }, + // Up-next na afgeronde aflevering (geseed met positionMs = 0) + { mediaType: "EPISODE", positionMs: { lte: 30_000 } }, + ], }, orderBy: { updatedAt: "desc" }, take: 24, @@ -1618,6 +1622,7 @@ export class ViewerService { positionMs: p.positionMs, durationMs: p.durationMs, progressId: p.id, + upNext: p.positionMs <= 30_000, }); } } @@ -1679,7 +1684,7 @@ export class ViewerService { const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } }); seriesId = ep?.seriesId ?? null; } - return prisma.watchProgress.upsert({ + const row = await prisma.watchProgress.upsert({ where: { profileId_episodeId: { profileId, episodeId: body.episodeId }, }, @@ -1707,6 +1712,81 @@ export class ViewerService { : {}), }, }); + if (autoComplete) { + await this.seedNextEpisodeContinue(profileId, body.episodeId); + } + return row; + } + + /** Volgende speelbare aflevering (zelfde seizoen of volgend seizoen). */ + async findNextPlayableEpisode(episodeId: string) { + const current = await prisma.episode.findUnique({ where: { id: episodeId } }); + if (!current) return null; + const episodes = await prisma.episode.findMany({ + where: { seriesId: current.seriesId }, + orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], + include: { + mediaFiles: { + where: { available: true }, + orderBy: { updatedAt: "desc" }, + take: 1, + }, + series: { select: { id: true, title: true, posterUrl: true, backdropUrl: true } }, + }, + }); + const idx = episodes.findIndex((e) => e.id === episodeId); + if (idx < 0) return null; + for (let i = idx + 1; i < episodes.length; i++) { + const ep = episodes[i]; + const file = ep.mediaFiles[0]; + if (!file) continue; + return { + id: ep.id, + seriesId: ep.seriesId, + seasonNumber: ep.seasonNumber, + episodeNumber: ep.episodeNumber, + title: ep.title, + seriesTitle: ep.series.title, + posterUrl: ep.series.posterUrl || ep.stillUrl, + backdropUrl: ep.series.backdropUrl, + mediaFileId: file.id, + subtitle: `S${ep.seasonNumber}E${ep.episodeNumber}${ep.title ? ` · ${ep.title}` : ""}`, + }; + } + return null; + } + + /** Zet volgende aflevering in Verder kijken na afronden. */ + async seedNextEpisodeContinue(profileId: string, completedEpisodeId: string) { + const next = await this.findNextPlayableEpisode(completedEpisodeId); + if (!next) return null; + const existing = await prisma.watchProgress.findUnique({ + where: { profileId_episodeId: { profileId, episodeId: next.id } }, + }); + // Niet overschrijven als gebruiker al serieus bezig is met die aflevering + if (existing && !existing.completed && existing.positionMs > 30_000) { + return existing; + } + return prisma.watchProgress.upsert({ + where: { profileId_episodeId: { profileId, episodeId: next.id } }, + create: { + profileId, + mediaType: "EPISODE", + episodeId: next.id, + seriesId: next.seriesId, + positionMs: 0, + durationMs: 0, + completed: false, + hiddenFromContinue: false, + }, + update: { + seriesId: next.seriesId, + completed: false, + hiddenFromContinue: false, + positionMs: existing && existing.positionMs > 0 && !existing.completed ? existing.positionMs : 0, + updatedAt: new Date(), + }, + }); } async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) { @@ -1721,7 +1801,7 @@ export class ViewerService { }); } if (opts.episodeId) { - return this.upsertProgress(profileId, { + const row = await this.upsertProgress(profileId, { mediaType: "EPISODE", episodeId: opts.episodeId, positionMs: 1, @@ -1729,6 +1809,7 @@ export class ViewerService { completed: true, hiddenFromContinue: true, }); + return row; } throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400); }