Improve profiles, watched episodes, and continue-watching across apps.

Subprofiles can edit their own profile and cannot switch server/account; series detail shows watched/progress; skip-to-next marks the prior episode done and keeps one continue-watching tile per series.
This commit is contained in:
Jos Vooges | STH 2026-09-13 00:44:09 +02:00
parent 07901c1d0c
commit 056078d6bc
15 changed files with 533 additions and 139 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 17 versionCode = 19
versionName = "1.0.16" versionName = "1.0.18"
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

@ -481,7 +481,7 @@ fun SeriesDetailScreen(
api: ApiClient, api: ApiClient,
seriesId: String, seriesId: String,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var title by remember { mutableStateOf("…") } var title by remember { mutableStateOf("…") }
@ -538,6 +538,9 @@ fun SeriesDetailScreen(
overview = e.optString("overview"), overview = e.optString("overview"),
stillUrl = e.optCleanString("stillUrl"), stillUrl = e.optCleanString("stillUrl"),
runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 }, runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 },
completed = e.optBoolean("completed", false),
positionMs = e.optLong("positionMs"),
durationMs = e.optLong("durationMs"),
) )
) )
} }
@ -577,9 +580,24 @@ fun SeriesDetailScreen(
MetaChips(metaParts) MetaChips(metaParts)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
episodes.firstOrNull()?.let { ep -> val playEp = remember(episodes) {
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") ?: episodes.firstOrNull { !it.completed }
?: episodes.firstOrNull()
}
playEp?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton(
if (resume) "Verder kijken" else "Afspelen",
primary = true,
focusRequester = firstEpFocus,
) {
onPlay(
ep.fileId,
ep.id,
"$title · S${ep.season}E${ep.number}",
if (ep.completed) 0L else ep.positionMs,
)
} }
} }
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
@ -620,7 +638,12 @@ fun SeriesDetailScreen(
episode = ep, episode = ep,
focusRequester = null, focusRequester = null,
onClick = { onClick = {
onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") onPlay(
ep.fileId,
ep.id,
"$title · S${ep.season}E${ep.number}",
if (ep.completed) 0L else ep.positionMs,
)
}, },
) )
} }
@ -638,7 +661,17 @@ private data class EpisodeRow(
val overview: String, val overview: String,
val stillUrl: String?, val stillUrl: String?,
val runtime: Int?, val runtime: Int?,
) val completed: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
) {
val progressFraction: Float
get() = when {
completed -> 1f
durationMs > 0L -> (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
else -> 0f
}
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -658,20 +691,52 @@ private fun EpisodeCard(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImage( Box(
model = episode.stillUrl, Modifier
contentDescription = null,
modifier = Modifier
.width(McDimens.episodeStillW) .width(McDimens.episodeStillW)
.height(McDimens.episodeStillH) .height(McDimens.episodeStillH)
.clip(RoundedCornerShape(5.dp)) .clip(RoundedCornerShape(5.dp))
.background(Color(0xFF1A1A1A)), .background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop, ) {
) AsyncImage(
model = episode.stillUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = if (episode.completed) 0.55f else 1f,
)
if (episode.completed) {
Box(
Modifier
.align(Alignment.Center)
.size(28.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Text("✓", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
} else if (episode.progressFraction > 0.02f) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.background(Color(0x66FFFFFF)),
)
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(episode.progressFraction)
.height(3.dp)
.background(McColors.Brand),
)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
"S${episode.season}E${episode.number} · ${episode.title}", "S${episode.season}E${episode.number} · ${episode.title}",
color = Color.White, color = if (episode.completed) Color(0xFFAAAAAA) else Color.White,
fontSize = McDimens.bodySmall, fontSize = McDimens.bodySmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,

View file

@ -345,8 +345,8 @@ fun McNav(
api = api, api = api,
seriesId = id, seriesId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ -> onPlay = { mediaFileId, episodeId, _, startMs ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/$startMs")
}, },
) )
} }
@ -471,7 +471,9 @@ private fun MainHub(
} }
val visibleTabs = remember(iptvEnabled) { val visibleTabs = remember(iptvEnabled) {
MainTab.entries.filter { it != MainTab.Tv || iptvEnabled } MainTab.entries.filter {
it != MainTab.Watchlist && (it != MainTab.Tv || iptvEnabled)
}
} }
val movieSubs = remember(shelfPrefs) { val movieSubs = remember(shelfPrefs) {
@ -586,6 +588,18 @@ private fun MainHub(
onSeeAll = onBrowse, onSeeAll = onBrowse,
onPlay = onPlay, onPlay = onPlay,
) )
MainTab.Watchlist -> {
Box(
Modifier.fillMaxSize().padding(McDimens.screenPad),
contentAlignment = Alignment.Center,
) {
Text(
"Watchlist komt binnenkort",
color = McColors.TextDim,
fontSize = McDimens.body,
)
}
}
MainTab.Movies -> BrowseScreen( MainTab.Movies -> BrowseScreen(
api = api, api = api,
catalogId = movieCatalog, catalogId = movieCatalog,
@ -1343,12 +1357,23 @@ private fun SettingsScreen(
.clickable(onClick = onManageProfiles) .clickable(onClick = onManageProfiles)
.padding(horizontal = 16.dp, vertical = 16.dp), .padding(horizontal = 16.dp, vertical = 16.dp),
) { ) {
Text( Column {
"Profielen beheren", Text(
color = Color.White, if (canManageDevices) "Profielen beheren" else "Je profiel beheren",
fontSize = McDimens.body, color = Color.White,
fontWeight = FontWeight.SemiBold, fontSize = McDimens.body,
) fontWeight = FontWeight.SemiBold,
)
Text(
if (canManageDevices) {
"Toevoegen, bewerken of verwijderen"
} else {
"Naam en avatar wijzigen"
},
color = McColors.TextDim,
fontSize = McDimens.meta,
)
}
} }
if (canManageDevices) { if (canManageDevices) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -1375,21 +1400,30 @@ private fun SettingsScreen(
} }
} }
} }
Spacer(Modifier.height(8.dp)) if (canManageDevices) {
Box( Spacer(Modifier.height(8.dp))
Modifier Box(
.fillMaxWidth() Modifier
.clip(RoundedCornerShape(12.dp)) .fillMaxWidth()
.background(McColors.BgElevated) .clip(RoundedCornerShape(12.dp))
.clickable(onClick = onLogout) .background(McColors.BgElevated)
.padding(horizontal = 16.dp, vertical = 16.dp), .clickable(onClick = onLogout)
) { .padding(horizontal = 16.dp, vertical = 16.dp),
Text( ) {
"Uitloggen", Column {
color = McColors.Danger, Text(
fontSize = McDimens.body, "Uitloggen",
fontWeight = FontWeight.SemiBold, color = McColors.Danger,
) fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
Text(
"Server / account wisselen",
color = McColors.TextDim,
fontSize = McDimens.meta,
)
}
}
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
SectionLabel("Home-planken") SectionLabel("Home-planken")

View file

@ -103,6 +103,11 @@ fun ProfilesScreen(
.onSuccess { .onSuccess {
profiles = it.profiles profiles = it.profiles
canManage = it.canManage canManage = it.canManage
if (!switcherMode && !it.canManage) {
val ownId = store.profileId.first()
val own = it.profiles.find { p -> p.id == ownId }
if (own != null) openEdit(own)
}
} }
.onFailure { error = it.message ?: "Laden mislukt" } .onFailure { error = it.message ?: "Laden mislukt" }
loading = false loading = false
@ -161,6 +166,11 @@ fun ProfilesScreen(
onSelect = { profile -> onSelect = { profile ->
if (!switcherMode && canManage) { if (!switcherMode && canManage) {
openEdit(profile) openEdit(profile)
} else if (!switcherMode && !canManage) {
scope.launch {
val id = store.profileId.first()
if (profile.id == id) openEdit(profile)
}
} else if (profile.hasPin) { } else if (profile.hasPin) {
pending = profile pending = profile
pin = "" pin = ""
@ -231,10 +241,14 @@ fun ProfilesScreen(
busy = busy, busy = busy,
error = error, error = error,
onCancel = { onCancel = {
mode = ProfilesMode.Pick if (!switcherMode && !canManage) {
editingId = null onProfileSelected()
pending = null } else {
error = null mode = ProfilesMode.Pick
editingId = null
pending = null
error = null
}
}, },
onSave = { onSave = {
if (busy) return@ProfileFormContent if (busy) return@ProfileFormContent
@ -265,10 +279,14 @@ fun ProfilesScreen(
if (localId == saved.id || editId == localId) { if (localId == saved.id || editId == localId) {
store.setProfile(saved.id, saved.name, saved.avatarId) store.setProfile(saved.id, saved.name, saved.avatarId)
} }
mode = ProfilesMode.Pick if (!switcherMode && !canManage) {
editingId = null onProfileSelected()
pending = null } else {
reload() mode = ProfilesMode.Pick
editingId = null
pending = null
reload()
}
}.onFailure { }.onFailure {
error = it.message ?: "Opslaan mislukt" error = it.message ?: "Opslaan mislukt"
} }
@ -328,7 +346,11 @@ private fun PickContent(
) { ) {
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
Text( Text(
if (switcherMode) "Wie kijkt er?" else "Profielen beheren", when {
switcherMode -> "Wie kijkt er?"
canManage -> "Profielen beheren"
else -> "Je profiel beheren"
},
color = Color.White, color = Color.White,
fontSize = 28.sp, fontSize = 28.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -339,7 +361,7 @@ private fun PickContent(
when { when {
!switcherMode && canManage -> "Tik om te bewerken · houd vast om te verwijderen" !switcherMode && canManage -> "Tik om te bewerken · houd vast om te verwijderen"
switcherMode -> "Kies je profiel om verder te gaan" switcherMode -> "Kies je profiel om verder te gaan"
else -> "Alleen het hoofdprofiel mag beheren" else -> "Pas naam en avatar aan"
}, },
color = Color(0xFF9A9AA8), color = Color(0xFF9A9AA8),
fontSize = 14.sp, fontSize = 14.sp,

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 = 87 versionCode = 90
versionName = "0.14.24" versionName = "0.14.27"
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")
@ -71,6 +71,7 @@ dependencies {
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -526,7 +527,7 @@ fun SeriesDetailScreen(
api: ApiClient, api: ApiClient,
seriesId: String, seriesId: String,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var title by remember { mutableStateOf("…") } var title by remember { mutableStateOf("…") }
@ -588,6 +589,9 @@ fun SeriesDetailScreen(
overview = e.optString("overview"), overview = e.optString("overview"),
stillUrl = e.optCleanString("stillUrl"), stillUrl = e.optCleanString("stillUrl"),
runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 }, runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 },
completed = e.optBoolean("completed", false),
positionMs = e.optLong("positionMs"),
durationMs = e.optLong("durationMs"),
) )
) )
} }
@ -633,9 +637,24 @@ fun SeriesDetailScreen(
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
episodes.firstOrNull()?.let { ep -> val playEp = remember(episodes) {
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") ?: episodes.firstOrNull { !it.completed }
?: episodes.firstOrNull()
}
playEp?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton(
if (resume) "Verder kijken" else "Afspelen",
primary = true,
focusRequester = firstEpFocus,
) {
onPlay(
ep.fileId,
ep.id,
"$title · S${ep.season}E${ep.number}",
if (ep.completed) 0L else ep.positionMs,
)
} }
} }
if (!trailerKey.isNullOrBlank()) { if (!trailerKey.isNullOrBlank()) {
@ -669,7 +688,12 @@ fun SeriesDetailScreen(
episode = ep, episode = ep,
focusRequester = null, focusRequester = null,
onClick = { onClick = {
onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") onPlay(
ep.fileId,
ep.id,
"$title · S${ep.season}E${ep.number}",
if (ep.completed) 0L else ep.positionMs,
)
}, },
) )
} }
@ -686,7 +710,17 @@ private data class EpisodeRow(
val overview: String, val overview: String,
val stillUrl: String?, val stillUrl: String?,
val runtime: Int?, val runtime: Int?,
) val completed: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
) {
val progressFraction: Float
get() = when {
completed -> 1f
durationMs > 0L -> (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
else -> 0f
}
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -708,6 +742,12 @@ private fun EpisodeCard(
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(8.dp),
),
),
) { ) {
Row( Row(
Modifier Modifier
@ -716,20 +756,52 @@ private fun EpisodeCard(
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImage( Box(
model = episode.stillUrl, Modifier
contentDescription = null,
modifier = Modifier
.width(160.dp) .width(160.dp)
.height(90.dp) .height(90.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(Color(0xFF1A1A1A)), .background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop, ) {
) AsyncImage(
model = episode.stillUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = if (episode.completed) 0.55f else 1f,
)
if (episode.completed) {
Box(
Modifier
.align(Alignment.Center)
.size(36.dp)
.clip(RoundedCornerShape(50))
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Text("✓", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
} else if (episode.progressFraction > 0.02f) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(4.dp)
.background(Color(0x66FFFFFF)),
)
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(episode.progressFraction)
.height(4.dp)
.background(McColors.Brand),
)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
"S${episode.season}E${episode.number} · ${episode.title}", "S${episode.season}E${episode.number} · ${episode.title}",
color = Color.White, color = if (episode.completed) Color(0xFFAAAAAA) else Color.White,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,

View file

@ -77,7 +77,6 @@ import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
@ -117,6 +116,7 @@ fun McNav() {
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) }
var hasProfile by remember { mutableStateOf(false) } var hasProfile by remember { mutableStateOf(false) }
var isOwner by remember { mutableStateOf(false) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileName by store.profileName.collectAsState(initial = null) val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player) // Blijft behouden als main uit compositie gaat (bijv. live player)
@ -141,16 +141,22 @@ fun McNav() {
me?.profile != null -> { me?.profile != null -> {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
hasProfile = true hasProfile = true
isOwner = me.profile.isOwner
} }
!localProfile.isNullOrBlank() -> { !localProfile.isNullOrBlank() -> {
// Lokaal bekend, server nog niet — switcher afdwingen // Lokaal bekend, server nog niet — switcher afdwingen
store.setProfile(null) store.setProfile(null)
hasProfile = false hasProfile = false
isOwner = false
}
else -> {
hasProfile = false
isOwner = false
} }
else -> hasProfile = false
} }
} else { } else {
hasProfile = false hasProfile = false
isOwner = false
} }
if (tvCategoryId == null) { if (tvCategoryId == null) {
store.tvLastCategoryId.first()?.let { tvCategoryId = it } store.tvLastCategoryId.first()?.let { tvCategoryId = it }
@ -209,6 +215,7 @@ fun McNav() {
onLoggedIn = { onLoggedIn = {
authed = true authed = true
hasProfile = false hasProfile = false
isOwner = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
}, },
) )
@ -226,20 +233,27 @@ fun McNav() {
) { ) {
authed = true authed = true
hasProfile = false hasProfile = false
isOwner = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
} }
} }
composable("profiles") { composable("profiles") {
ProfilesScreen(api = api, store = store, switcherMode = true) { ProfilesScreen(api = api, store = store, switcherMode = true) {
hasProfile = true hasProfile = true
scope.launch { store.saveHomeCache("") } scope.launch {
store.saveHomeCache("")
isOwner = runCatching { api.clientMe().profile?.isOwner == true }.getOrDefault(false)
}
nav.navigate("main") { popUpTo("profiles") { inclusive = true } } nav.navigate("main") { popUpTo("profiles") { inclusive = true } }
} }
} }
composable("profiles_manage") { composable("profiles_manage") {
ProfilesScreen(api = api, store = store, switcherMode = false) { ProfilesScreen(api = api, store = store, switcherMode = false) {
hasProfile = true hasProfile = true
scope.launch { store.saveHomeCache("") } scope.launch {
store.saveHomeCache("")
isOwner = runCatching { api.clientMe().profile?.isOwner == true }.getOrDefault(false)
}
nav.navigate("main") { nav.navigate("main") {
popUpTo("main") { inclusive = false } popUpTo("main") { inclusive = false }
launchSingleTop = true launchSingleTop = true
@ -283,6 +297,7 @@ fun McNav() {
store.setProfile(null) store.setProfile(null)
store.saveHomeCache("") store.saveHomeCache("")
hasProfile = false hasProfile = false
isOwner = false
nav.navigate("profiles") { nav.navigate("profiles") {
popUpTo("main") { inclusive = true } popUpTo("main") { inclusive = true }
} }
@ -297,9 +312,11 @@ fun McNav() {
api.configure(store.apiBase.first(), null) api.configure(store.apiBase.first(), null)
authed = false authed = false
hasProfile = false hasProfile = false
isOwner = false
nav.navigate("server") { popUpTo(0) { inclusive = true } } nav.navigate("server") { popUpTo(0) { inclusive = true } }
} }
}, },
isOwner = isOwner,
) )
} }
composable( composable(
@ -342,8 +359,8 @@ fun McNav() {
api = api, api = api,
seriesId = id, seriesId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ -> onPlay = { mediaFileId, episodeId, _, startMs ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/$startMs")
}, },
) )
} }
@ -420,6 +437,7 @@ private fun MainHub(
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
isOwner: Boolean = false,
) { ) {
var iptvEnabled by remember { mutableStateOf(false) } var iptvEnabled by remember { mutableStateOf(false) }
var navFocused by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) }
@ -456,7 +474,7 @@ private fun MainHub(
} }
val visibleTabs = remember(iptvEnabled) { val visibleTabs = remember(iptvEnabled) {
MainTab.entries.filter { it != MainTab.Tv || iptvEnabled } MainTab.entries.filter { it != MainTab.Settings && (it != MainTab.Tv || iptvEnabled) }
} }
val movieSubs = remember(shelfPrefs) { val movieSubs = remember(shelfPrefs) {
@ -574,6 +592,12 @@ private fun MainHub(
profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"), profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"),
profileLabel = profileName, profileLabel = profileName,
onProfileClick = onSwitchProfile, onProfileClick = onSwitchProfile,
onSettingsClick = {
onTabChange(MainTab.Settings)
exitConfirm = false
homeChromeCompact = false
navFocused = true
},
onSelect = { onSelect = {
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
onTabChange(it) onTabChange(it)
@ -707,6 +731,7 @@ private fun MainHub(
api = api, api = api,
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileName = profileName, profileName = profileName,
isOwner = isOwner,
onBack = { onBack = {
onTabChange(MainTab.Home) onTabChange(MainTab.Home)
focusTopNav() focusTopNav()
@ -1540,6 +1565,7 @@ private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null, profileAvatarId: String? = null,
profileName: String? = null, profileName: String? = null,
isOwner: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {}, onManageProfiles: () -> Unit = {},
@ -1658,8 +1684,12 @@ private fun SettingsScreen(
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
SettingsActionRow( SettingsActionRow(
title = "Profielen beheren", title = if (isOwner) "Profielen beheren" else "Je profiel beheren",
subtitle = "Toevoegen, bewerken of verwijderen", subtitle = if (isOwner) {
"Toevoegen, bewerken of verwijderen"
} else {
"Naam en avatar wijzigen"
},
onClick = onManageProfiles, onClick = onManageProfiles,
) )
} }
@ -1690,13 +1720,15 @@ private fun SettingsScreen(
) )
} }
} }
Spacer(Modifier.height(10.dp)) if (isOwner) {
SettingsActionRow( Spacer(Modifier.height(10.dp))
title = "Uitloggen", SettingsActionRow(
subtitle = "Apparaat ontkoppelen van dit account", title = "Uitloggen",
danger = true, subtitle = "Server / account wisselen",
onClick = onLogout, danger = true,
) onClick = onLogout,
)
}
} }
item { item {
@ -1762,14 +1794,14 @@ private fun SettingsScreen(
persist(next) persist(next)
} }
} }
Switch( McButton(
checked = p.visible, label = if (p.visible) "Aan" else "Uit",
onCheckedChange = { checked -> primary = p.visible,
val next = prefs.toMutableList() ) {
next[index] = p.copy(visible = checked) val next = prefs.toMutableList()
persist(next) next[index] = p.copy(visible = !p.visible)
}, persist(next)
) }
} }
} }
} }
@ -1841,7 +1873,7 @@ private fun WatchlistScreen(
columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp), columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(bottom = 32.dp), contentPadding = PaddingValues(top = 28.dp, bottom = 32.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(items.size, key = { items[it].id }) { idx -> items(items.size, key = { items[it].id }) { idx ->

View file

@ -131,8 +131,15 @@ fun PlayerScreen(
fun goNext() { fun goNext() {
val n = nextEp ?: return val n = nextEp ?: return
showNextOverlay = false if (showNextOverlay) showNextOverlay = false
onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack() val finishedEpisodeId = episodeId
// Markeer huidige aflevering als gezien vóór navigatie (ook bij skip tijdens aftiteling)
scope.launch {
if (mediaType.equals("EPISODE", true) && !finishedEpisodeId.isNullOrBlank()) {
runCatching { api.markWatched(episodeId = finishedEpisodeId) }
}
onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack()
}
} }
fun togglePlay() { fun togglePlay() {

View file

@ -11,10 +11,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -85,6 +89,8 @@ fun TopNavBar(
profileAvatarEmoji: String? = null, profileAvatarEmoji: String? = null,
profileLabel: String? = null, profileLabel: String? = null,
onProfileClick: (() -> Unit)? = null, onProfileClick: (() -> Unit)? = null,
/** Tandwiel links van het profiel-icoon (Settings-tab). */
onSettingsClick: (() -> Unit)? = null,
) { ) {
val alpha = when { val alpha = when {
// Overlay: transparantie zit in de gradient, niet in ekstra fade // Overlay: transparantie zit in de gradient, niet in ekstra fade
@ -177,6 +183,33 @@ fun TopNavBar(
} }
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (onSettingsClick != null) {
val settingsActive = selected == MainTab.Settings
Surface(
onClick = onSettingsClick,
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Box(
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Instellingen",
modifier = Modifier.size(22.dp),
tint = Color.White,
)
}
}
}
if (onProfileClick != null && profileAvatarEmoji != null) { if (onProfileClick != null && profileAvatarEmoji != null) {
Surface( Surface(
onClick = onProfileClick, onClick = onProfileClick,
@ -199,9 +232,9 @@ fun TopNavBar(
if (!profileLabel.isNullOrBlank()) { if (!profileLabel.isNullOrBlank()) {
Text( Text(
profileLabel, profileLabel,
color = Color.Unspecified, color = McColors.Brand,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
) )
} }

View file

@ -96,6 +96,11 @@ fun ProfilesScreen(
.onSuccess { .onSuccess {
profiles = it.profiles profiles = it.profiles
canManage = it.canManage canManage = it.canManage
if (!switcherMode && !it.canManage) {
val ownId = store.profileId.first()
val own = it.profiles.find { p -> p.id == ownId }
if (own != null) openEdit(own)
}
} }
.onFailure { error = it.message ?: "Laden mislukt" } .onFailure { error = it.message ?: "Laden mislukt" }
loading = false loading = false
@ -147,7 +152,11 @@ fun ProfilesScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
if (switcherMode) "Wie kijkt er?" else "Profielen beheren", when {
switcherMode -> "Wie kijkt er?"
canManage -> "Profielen beheren"
else -> "Je profiel beheren"
},
color = Color.White, color = Color.White,
fontSize = 36.sp, fontSize = 36.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -157,7 +166,7 @@ fun ProfilesScreen(
when { when {
!switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen" !switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen"
switcherMode -> "Kies je profiel" switcherMode -> "Kies je profiel"
else -> "Alleen het hoofdprofiel mag beheren" else -> "Pas naam en avatar aan"
}, },
color = Color(0xFFAAAAAA), color = Color(0xFFAAAAAA),
fontSize = 16.sp, fontSize = 16.sp,
@ -188,6 +197,11 @@ fun ProfilesScreen(
onClick = { onClick = {
if (!switcherMode && canManage) { if (!switcherMode && canManage) {
openEdit(p) openEdit(p)
} else if (!switcherMode && !canManage) {
scope.launch {
val id = store.profileId.first()
if (p.id == id) openEdit(p)
}
} else if (p.hasPin) { } else if (p.hasPin) {
pending = p pending = p
pin = "" pin = ""
@ -311,10 +325,14 @@ fun ProfilesScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") { ActionChip("Annuleer") {
mode = ProfilesMode.Pick if (!switcherMode && !canManage) {
editingId = null onProfileSelected()
pending = null } else {
error = null mode = ProfilesMode.Pick
editingId = null
pending = null
error = null
}
} }
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) { ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
scope.launch { scope.launch {
@ -343,10 +361,14 @@ fun ProfilesScreen(
if (localId == saved.id || editId == localId) { if (localId == saved.id || editId == localId) {
store.setProfile(saved.id, saved.name, saved.avatarId) store.setProfile(saved.id, saved.name, saved.avatarId)
} }
mode = ProfilesMode.Pick if (!switcherMode && !canManage) {
editingId = null onProfileSelected()
pending = null } else {
reload() mode = ProfilesMode.Pick
editingId = null
pending = null
reload()
}
}.onFailure { }.onFailure {
error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt" error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt"
} }

View file

@ -170,6 +170,20 @@ struct Episode: Decodable, Identifiable {
let mediaFileId: String? let mediaFileId: String?
let available: Bool? let available: Bool?
let runtime: Int? let runtime: Int?
let completed: Bool?
let positionMs: Int64?
let durationMs: Int64?
var progressFraction: CGFloat {
if completed == true { return 1 }
guard let dur = durationMs, dur > 0, let pos = positionMs else { return 0 }
return CGFloat(pos) / CGFloat(dur)
}
var resumeMs: Int64 {
if completed == true { return 0 }
return max(0, positionMs ?? 0)
}
} }
struct SearchPayload: Decodable { struct SearchPayload: Decodable {

View file

@ -227,7 +227,7 @@ struct SeriesDetailScreen: View {
movieId: nil, movieId: nil,
episodeId: target.episodeId, episodeId: target.episodeId,
seriesId: seriesId, seriesId: seriesId,
startMs: 0 startMs: target.startMs
) )
.environmentObject(app) .environmentObject(app)
} }
@ -243,13 +243,16 @@ struct SeriesDetailScreen: View {
private func episodeCard(_ ep: Episode, seriesTitle: String, width: CGFloat) -> some View { private func episodeCard(_ ep: Episode, seriesTitle: String, width: CGFloat) -> some View {
let title = (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)" let title = (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)"
let playable = ep.mediaFileId.map { !$0.isEmpty } ?? false let playable = ep.mediaFileId.map { !$0.isEmpty } ?? false
let watched = ep.completed == true
let progress = ep.progressFraction
Button { Button {
guard let mediaFileId = ep.mediaFileId, playable else { return } guard let mediaFileId = ep.mediaFileId, playable else { return }
playTarget = EpisodePlay( playTarget = EpisodePlay(
mediaFileId: mediaFileId, mediaFileId: mediaFileId,
episodeId: ep.id, episodeId: ep.id,
title: "\(seriesTitle) · \(title)" title: "\(seriesTitle) · \(title)",
startMs: ep.resumeMs
) )
} label: { } label: {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
@ -257,18 +260,43 @@ struct SeriesDetailScreen: View {
RemoteImage(url: ep.stillUrl ?? series?.backdropUrl) RemoteImage(url: ep.stillUrl ?? series?.backdropUrl)
.frame(width: 128, height: 72) .frame(width: 128, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
if playable { .opacity(watched ? 0.55 : 1)
if watched {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.white)
.shadow(radius: 3)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if progress > 0.02 {
GeometryReader { geo in
VStack {
Spacer()
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.35))
.frame(height: 3)
Rectangle()
.fill(McColors.accent)
.frame(width: max(0, geo.size.width * progress), height: 3)
}
}
}
.frame(width: 128, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
} else if playable {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.foregroundStyle(.white) .foregroundStyle(.white)
.shadow(radius: 4) .shadow(radius: 4)
.padding(6) .padding(6)
} }
} }
.frame(width: 128, height: 72)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("\(ep.episodeNumber ?? 0). \(title)") Text("\(ep.episodeNumber ?? 0). \(title)")
.font(McFonts.headline) .font(McFonts.headline)
.foregroundStyle(playable ? McColors.text : McColors.textDim) .foregroundStyle(watched ? McColors.textDim : (playable ? McColors.text : McColors.textDim))
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -404,6 +432,7 @@ private struct EpisodePlay: Identifiable {
let mediaFileId: String let mediaFileId: String
let episodeId: String let episodeId: String
let title: String let title: String
let startMs: Int64
} }
private extension Array { private extension Array {

View file

@ -70,22 +70,26 @@ struct SettingsScreen: View {
Section("Server") { Section("Server") {
LabeledContent("URL", value: app.store.apiBase) LabeledContent("URL", value: app.store.apiBase)
Button { if app.canManageProfiles {
Task { await app.logout() } Button {
} label: { Task { await app.logout() }
Label("Server / account wisselen", systemImage: "server.rack") } label: {
.foregroundStyle(McColors.text) Label("Server / account wisselen", systemImage: "server.rack")
.foregroundStyle(McColors.text)
}
.listRowBackground(McColors.bgElevated)
} }
.listRowBackground(McColors.bgElevated)
} }
Section { if app.canManageProfiles {
Button(role: .destructive) { Section {
Task { await app.logout() } Button(role: .destructive) {
} label: { Task { await app.logout() }
Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right") } label: {
Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right")
}
.listRowBackground(McColors.bgElevated)
} }
.listRowBackground(McColors.bgElevated)
} }
Section("Info") { Section("Info") {

View file

@ -530,9 +530,9 @@ export function registerViewerRoutes(
app.get("/api/v1/client/series/:id", async (request) => { app.get("/api/v1/client/series/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth); const profileId = requireProfile(auth);
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
return viewers.seriesDetail(id, auth.profile?.isKids === true); return viewers.seriesDetail(id, auth.profile?.isKids === true, profileId);
}); });
app.get("/api/v1/client/episodes/:id/next", async (request) => { app.get("/api/v1/client/episodes/:id/next", async (request) => {

View file

@ -1575,6 +1575,7 @@ export class ViewerService {
}); });
const out = []; const out = [];
const seenSeries = new Set<string>();
for (const p of rows) { for (const p of rows) {
if (p.mediaType === "MOVIE" && p.movieId) { if (p.mediaType === "MOVIE" && p.movieId) {
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } }); const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
@ -1597,6 +1598,11 @@ export class ViewerService {
progressId: p.id, progressId: p.id,
}); });
} else if (p.mediaType === "EPISODE" && p.episodeId) { } else if (p.mediaType === "EPISODE" && p.episodeId) {
// Eén Verder-kijken-tegel per serie (meest recent eerst)
if (p.seriesId) {
if (seenSeries.has(p.seriesId)) continue;
seenSeries.add(p.seriesId);
}
const episode = await prisma.episode.findUnique({ const episode = await prisma.episode.findUnique({
where: { id: p.episodeId }, where: { id: p.episodeId },
include: { series: true }, include: { series: true },
@ -1650,6 +1656,13 @@ export class ViewerService {
if (body.mediaType === "MOVIE") { if (body.mediaType === "MOVIE") {
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400); if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
const existingMovie = await prisma.watchProgress.findUnique({
where: { profileId_movieId: { profileId, movieId: body.movieId } },
});
const completed =
body.completed === false
? false
: autoComplete || existingMovie?.completed === true;
return prisma.watchProgress.upsert({ return prisma.watchProgress.upsert({
where: { where: {
profileId_movieId: { profileId, movieId: body.movieId }, profileId_movieId: { profileId, movieId: body.movieId },
@ -1660,16 +1673,16 @@ export class ViewerService {
movieId: body.movieId, movieId: body.movieId,
positionMs, positionMs,
durationMs, durationMs,
completed: autoComplete, completed,
hiddenFromContinue: body.hiddenFromContinue ?? false, hiddenFromContinue: body.hiddenFromContinue ?? false,
}, },
update: { update: {
positionMs, positionMs,
durationMs, durationMs,
completed: body.completed ?? autoComplete, completed,
...(body.hiddenFromContinue !== undefined ...(body.hiddenFromContinue !== undefined
? { hiddenFromContinue: body.hiddenFromContinue } ? { hiddenFromContinue: body.hiddenFromContinue }
: autoComplete : completed
? { hiddenFromContinue: true } ? { hiddenFromContinue: true }
: positionMs > 30_000 : positionMs > 30_000
? { hiddenFromContinue: false } ? { hiddenFromContinue: false }
@ -1684,6 +1697,14 @@ 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;
} }
const existingEp = await prisma.watchProgress.findUnique({
where: { profileId_episodeId: { profileId, episodeId: body.episodeId } },
});
// Eenmaal gezien blijft gezien (voorkomt undo via progress na 'volgende aflevering')
const completed =
body.completed === false
? false
: autoComplete || existingEp?.completed === true;
const row = await prisma.watchProgress.upsert({ const row = await prisma.watchProgress.upsert({
where: { where: {
profileId_episodeId: { profileId, episodeId: body.episodeId }, profileId_episodeId: { profileId, episodeId: body.episodeId },
@ -1695,23 +1716,37 @@ export class ViewerService {
seriesId, seriesId,
positionMs, positionMs,
durationMs, durationMs,
completed: autoComplete, completed,
hiddenFromContinue: body.hiddenFromContinue ?? false, hiddenFromContinue: body.hiddenFromContinue ?? false,
}, },
update: { update: {
positionMs, positionMs,
durationMs, durationMs,
seriesId, seriesId,
completed: body.completed ?? autoComplete, completed,
...(body.hiddenFromContinue !== undefined ...(body.hiddenFromContinue !== undefined
? { hiddenFromContinue: body.hiddenFromContinue } ? { hiddenFromContinue: body.hiddenFromContinue }
: autoComplete : completed
? { hiddenFromContinue: true } ? { hiddenFromContinue: true }
: positionMs > 30_000 : positionMs > 30_000
? { hiddenFromContinue: false } ? { hiddenFromContinue: false }
: {}), : {}),
}, },
}); });
// Max. één Verder-kijken-item per serie: verberg andere afleveringen
if (seriesId && (completed || positionMs > 30_000)) {
await prisma.watchProgress.updateMany({
where: {
profileId,
seriesId,
episodeId: { not: body.episodeId },
hiddenFromContinue: false,
},
data: { hiddenFromContinue: true },
});
}
if (autoComplete) { if (autoComplete) {
await this.seedNextEpisodeContinue(profileId, body.episodeId); await this.seedNextEpisodeContinue(profileId, body.episodeId);
} }
@ -2173,7 +2208,7 @@ export class ViewerService {
}; };
} }
async seriesDetail(seriesId: string, kidsMode = false) { async seriesDetail(seriesId: string, kidsMode = false, profileId?: string | null) {
const series = await prisma.series.findUnique({ const series = await prisma.series.findUnique({
where: { id: seriesId }, where: { id: seriesId },
include: { include: {
@ -2216,6 +2251,24 @@ export class ViewerService {
.catch(() => undefined); .catch(() => undefined);
} }
// Eén query voor alle aflevering-progress van dit profiel op deze serie
const progressRows = profileId
? await prisma.watchProgress.findMany({
where: { profileId, seriesId, episodeId: { not: null } },
select: {
episodeId: true,
positionMs: true,
durationMs: true,
completed: true,
},
})
: [];
const progressByEpisode = new Map(
progressRows
.filter((p): p is typeof p & { episodeId: string } => !!p.episodeId)
.map((p) => [p.episodeId, p])
);
return { return {
type: "series" as const, type: "series" as const,
id: series.id, id: series.id,
@ -2232,17 +2285,23 @@ export class ViewerService {
trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, trailerYoutubeKey: extras.trailers?.[0]?.source ?? null,
seasons: series.seasons.map((s) => ({ seasons: series.seasons.map((s) => ({
seasonNumber: s.seasonNumber, seasonNumber: s.seasonNumber,
episodes: s.episodes.map((e) => ({ episodes: s.episodes.map((e) => {
id: e.id, const p = progressByEpisode.get(e.id);
seasonNumber: e.seasonNumber, return {
episodeNumber: e.episodeNumber, id: e.id,
title: e.title, seasonNumber: e.seasonNumber,
overview: e.overview, episodeNumber: e.episodeNumber,
stillUrl: e.stillUrl, title: e.title,
runtime: e.runtime, overview: e.overview,
mediaFileId: e.mediaFiles[0]?.id ?? null, stillUrl: e.stillUrl,
available: e.mediaFiles.length > 0, runtime: e.runtime,
})), mediaFileId: e.mediaFiles[0]?.id ?? null,
available: e.mediaFiles.length > 0,
completed: p?.completed === true,
positionMs: p?.positionMs ?? 0,
durationMs: p?.durationMs ?? 0,
};
}),
})), })),
}; };
} }