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.
This commit is contained in:
Jos Vooges | STH 2026-09-12 23:50:07 +02:00
parent 6de2c767bf
commit 07901c1d0c
11 changed files with 414 additions and 69 deletions

View file

@ -41,6 +41,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
enum class MainTab(val label: String) { enum class MainTab(val label: String) {
Home("Home"), Home("Home"),
Watchlist("Watchlist"),
Movies("Films"), Movies("Films"),
Series("Series"), Series("Series"),
Tv("TV"), Tv("TV"),
@ -56,6 +57,7 @@ data class SubNavItem(
private fun MainTab.icon(): ImageVector = when (this) { private fun MainTab.icon(): ImageVector = when (this) {
MainTab.Home -> Icons.Filled.Home MainTab.Home -> Icons.Filled.Home
MainTab.Watchlist -> Icons.Filled.Star
MainTab.Movies -> Icons.Filled.PlayArrow MainTab.Movies -> Icons.Filled.PlayArrow
MainTab.Series -> Icons.Filled.Menu MainTab.Series -> Icons.Filled.Menu
MainTab.Tv -> Icons.Filled.Star MainTab.Tv -> Icons.Filled.Star

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 86 versionCode = 87
versionName = "0.14.23" versionName = "0.14.24"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -151,6 +151,59 @@ class ApiClient(
JSONObject(execute(req)) 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<MediaItem> = 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) { suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia) val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
val req = authed("$baseUrl/api/v1/client/play").post(body).build() val req = authed("$baseUrl/api/v1/client/play").post(body).build()
@ -602,6 +655,18 @@ data class MediaItem(
val badge: String? = null, 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( data class PlayResult(
val streamUrl: String, val streamUrl: String,
val sessionId: String, val sessionId: String,

View file

@ -309,6 +309,7 @@ fun MovieDetailScreen(
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) } var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) } var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val playFocus = remember { FocusRequester() } val playFocus = remember { FocusRequester() }
val scroll = rememberScrollState() val scroll = rememberScrollState()
@ -327,6 +328,9 @@ fun MovieDetailScreen(
genres = o.optJSONArray("genres").stringList() genres = o.optJSONArray("genres").stringList()
cast = o.optJSONArray("cast").stringList() cast = o.optJSONArray("cast").stringList()
director = o.optJSONArray("director").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") trailerKey = o.optCleanString("trailerYoutubeKey")
val arr = o.optJSONArray("mediaFiles") val arr = o.optJSONArray("mediaFiles")
val list = mutableListOf<MediaVersion>() val list = mutableListOf<MediaVersion>()
@ -397,6 +401,11 @@ fun MovieDetailScreen(
openYoutubeTrailer(context, trailerKey!!) openYoutubeTrailer(context, trailerKey!!)
} }
} }
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
}
}
DetailActionButton("Markeer als gezien") { DetailActionButton("Markeer als gezien") {
scope.launch { scope.launch {
runCatching { api.markWatched(movieId = movieId) } runCatching { api.markWatched(movieId = movieId) }
@ -531,6 +540,8 @@ fun SeriesDetailScreen(
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) } var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by remember { mutableIntStateOf(0) } var selectedSeason by remember { mutableIntStateOf(0) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val firstEpFocus = remember { FocusRequester() } val firstEpFocus = remember { FocusRequester() }
LaunchedEffect(seriesId) { LaunchedEffect(seriesId) {
@ -544,6 +555,9 @@ fun SeriesDetailScreen(
rating = o.optCleanString("rating") rating = o.optCleanString("rating")
genres = o.optJSONArray("genres").stringList() genres = o.optJSONArray("genres").stringList()
trailerKey = o.optCleanString("trailerYoutubeKey") 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 arr = o.optJSONArray("seasons")
val list = mutableListOf<JSONObject>() val list = mutableListOf<JSONObject>()
if (arr != null) { if (arr != null) {
@ -627,6 +641,11 @@ fun SeriesDetailScreen(
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
} }
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
scope.launch {
runCatching { onWatchlist = api.toggleFavorite("series", seriesId) }
}
}
} }
if (seasons.size > 1) { if (seasons.size > 1) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))

View file

@ -368,6 +368,13 @@ fun McNav() {
seriesId = dash(entry.arguments?.getString("seriesId")), seriesId = dash(entry.arguments?.getString("seriesId")),
startMs = entry.arguments?.getLong("startMs") ?: 0L, startMs = entry.arguments?.getLong("startMs") ?: 0L,
onBack = { nav.popBackStack() }, 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( composable(
@ -655,6 +662,12 @@ private fun MainHub(
onSeeAll = onBrowse, onSeeAll = onBrowse,
onPlay = onPlay, onPlay = onPlay,
) )
MainTab.Watchlist -> WatchlistScreen(
api = api,
onAtTopBack = { focusTopNav() },
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
)
MainTab.Movies -> BrowseScreen( MainTab.Movies -> BrowseScreen(
api = api, api = api,
catalogId = movieCatalog, catalogId = movieCatalog,
@ -1702,73 +1715,61 @@ private fun SettingsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
itemsIndexed(prefs) { index, p -> itemsIndexed(prefs, key = { _, p -> p.shelfId }) { index, p ->
Surface( // Geen clickable parent-Surface: anders vangen ↑/↓ geen OK op Android TV.
onClick = { Row(
val next = prefs.toMutableList() Modifier
next[index] = p.copy(visible = !p.visible) .fillMaxWidth()
persist(next) .clip(RoundedCornerShape(14.dp))
}, .background(Color(0xFF1A1A22))
modifier = Modifier.fillMaxWidth(), .padding(horizontal = 18.dp, vertical = 14.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), verticalAlignment = Alignment.CenterVertically,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A22),
focusedContainerColor = Color(0xFF262632),
),
) { ) {
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( Row(
Modifier horizontalArrangement = Arrangement.spacedBy(8.dp),
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { McButton("↑") {
Text( if (index > 0) {
p.name, val next = prefs.toMutableList()
color = Color.White, val tmp = next[index - 1]
fontSize = 17.sp, next[index - 1] = next[index]
fontWeight = FontWeight.Medium, next[index] = tmp
maxLines = 1, persist(next)
)
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 < 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<List<MediaItem>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun SettingsActionRow( private fun SettingsActionRow(

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -91,11 +92,13 @@ fun PlayerScreen(
seriesId: String?, seriesId: String?,
startMs: Long, startMs: Long,
onBack: () -> Unit, onBack: () -> Unit,
onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildTvPlayer(context) } val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() } val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() }
val nextOverlayFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@ -113,13 +116,23 @@ fun PlayerScreen(
var osLoading by remember { mutableStateOf(false) } var osLoading by remember { mutableStateOf(false) }
var osError by remember { mutableStateOf<String?>(null) } var osError by remember { mutableStateOf<String?>(null) }
var osBusy by remember { mutableStateOf(false) } var osBusy by remember { mutableStateOf(false) }
var nextEp by remember { mutableStateOf<nl.vonas.mediacluster.tv.data.NextEpisode?>(null) }
var showNextOverlay by remember { mutableStateOf(false) }
var nextCountdown by remember { mutableIntStateOf(10) }
var nextOverlayConsumed by remember { mutableStateOf(false) }
fun showControls() { fun showControls() {
controls = true controls = true
} }
fun hideControls() { 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() { fun togglePlay() {
@ -196,6 +209,10 @@ fun PlayerScreen(
BackHandler { BackHandler {
when { when {
showNextOverlay -> {
showNextOverlay = false
nextOverlayConsumed = true
}
menu == "os" -> menu = "text" menu == "os" -> menu = "text"
menu != null -> menu = null menu != null -> menu = null
controls -> { controls -> {
@ -207,6 +224,13 @@ fun PlayerScreen(
} }
LaunchedEffect(mediaFileId) { LaunchedEffect(mediaFileId) {
nextEp = null
showNextOverlay = false
nextOverlayConsumed = false
nextCountdown = 10
if (mediaType == "EPISODE" && !episodeId.isNullOrBlank()) {
nextEp = runCatching { api.nextEpisode(episodeId) }.getOrNull()
}
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
embeddedHints = play.embeddedTracks 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) { DisposableEffect(player) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
@ -252,6 +287,12 @@ fun PlayerScreen(
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, 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) { override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -550,6 +591,11 @@ fun PlayerScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() } NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
if (nextEp != null && onPlayNext != null) {
NetflixIconButton("⏭") {
goNext()
}
}
NetflixIconButton("↺ 10") { NetflixIconButton("↺ 10") {
player.seekBack() player.seekBack()
showControls() 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) { if (menu != null) {
val isAudio = menu == "audio" val isAudio = menu == "audio"
val isOs = menu == "os" val isOs = menu == "os"

View file

@ -171,7 +171,7 @@ fun PosterCard(
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(
focusedBorder = 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), shape = RoundedCornerShape(4.dp),
), ),
), ),

View file

@ -51,6 +51,7 @@ object HomeChrome {
enum class MainTab(val label: String) { enum class MainTab(val label: String) {
Home("Home"), Home("Home"),
Watchlist("Watchlist"),
Movies("Films"), Movies("Films"),
Series("Series"), Series("Series"),
Tv("TV"), Tv("TV"),

View file

@ -36,7 +36,7 @@ struct HomeScreen: View {
VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 22) {
if !app.favorites.mediaItems.isEmpty { if !app.favorites.mediaItems.isEmpty {
ShelfSection(title: "Favorieten", items: app.favorites.mediaItems) ShelfSection(title: "Watchlist", items: app.favorites.mediaItems)
} }
if !home.continueWatching.isEmpty { if !home.continueWatching.isEmpty {

View file

@ -535,6 +535,14 @@ export function registerViewerRoutes(
return viewers.seriesDetail(id, auth.profile?.isKids === true); 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) => { app.post("/api/v1/client/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth); requireProfile(auth);

View file

@ -1564,7 +1564,11 @@ export class ViewerService {
profileId, profileId,
hiddenFromContinue: false, hiddenFromContinue: false,
completed: 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" }, orderBy: { updatedAt: "desc" },
take: 24, take: 24,
@ -1618,6 +1622,7 @@ export class ViewerService {
positionMs: p.positionMs, positionMs: p.positionMs,
durationMs: p.durationMs, durationMs: p.durationMs,
progressId: p.id, 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 } }); const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
seriesId = ep?.seriesId ?? null; seriesId = ep?.seriesId ?? null;
} }
return prisma.watchProgress.upsert({ const row = await prisma.watchProgress.upsert({
where: { where: {
profileId_episodeId: { profileId, episodeId: body.episodeId }, 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 }) { async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
@ -1721,7 +1801,7 @@ export class ViewerService {
}); });
} }
if (opts.episodeId) { if (opts.episodeId) {
return this.upsertProgress(profileId, { const row = await this.upsertProgress(profileId, {
mediaType: "EPISODE", mediaType: "EPISODE",
episodeId: opts.episodeId, episodeId: opts.episodeId,
positionMs: 1, positionMs: 1,
@ -1729,6 +1809,7 @@ export class ViewerService {
completed: true, completed: true,
hiddenFromContinue: true, hiddenFromContinue: true,
}); });
return row;
} }
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400); throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
} }