From 056078d6bcfea772a52fdd090039336b035e0d9b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 13 Sep 2026 00:44:09 +0200 Subject: [PATCH] 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. --- apps/android-tv/app-mobile/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 91 ++++++++++++++--- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 82 +++++++++++----- .../tv/ui/profiles/ProfilesScreen.kt | 42 ++++++-- apps/android-tv/app/build.gradle.kts | 5 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 98 ++++++++++++++++--- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 82 +++++++++++----- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 11 ++- .../mediacluster/tv/ui/components/TopNav.kt | 37 ++++++- .../tv/ui/profiles/ProfilesScreen.kt | 42 ++++++-- apps/ios/VMC/Data/Models.swift | 14 +++ apps/ios/VMC/UI/DetailScreens.swift | 37 ++++++- apps/ios/VMC/UI/SettingsScreen.swift | 28 +++--- apps/master-api/src/viewer/routes.ts | 4 +- apps/master-api/src/viewer/service.ts | 95 ++++++++++++++---- 15 files changed, 533 insertions(+), 139 deletions(-) diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index b0006c5..d481ebc 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 17 - versionName = "1.0.16" + versionCode = 19 + versionName = "1.0.18" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index 08c3e07..4efc5cc 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -481,7 +481,7 @@ fun SeriesDetailScreen( api: ApiClient, seriesId: String, onBack: () -> Unit, - onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, + onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit, ) { val context = LocalContext.current var title by remember { mutableStateOf("…") } @@ -538,6 +538,9 @@ fun SeriesDetailScreen( overview = e.optString("overview"), stillUrl = e.optCleanString("stillUrl"), 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) Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - episodes.firstOrNull()?.let { ep -> - DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { - onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") + val playEp = remember(episodes) { + episodes.firstOrNull { !it.completed && it.positionMs > 30_000L } + ?: 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()) { @@ -620,7 +638,12 @@ fun SeriesDetailScreen( episode = ep, focusRequester = null, 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 stillUrl: String?, 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) @Composable @@ -658,20 +691,52 @@ private fun EpisodeCard( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage( - model = episode.stillUrl, - contentDescription = null, - modifier = Modifier + Box( + Modifier .width(McDimens.episodeStillW) .height(McDimens.episodeStillH) .clip(RoundedCornerShape(5.dp)) .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)) { Text( "S${episode.season}E${episode.number} · ${episode.title}", - color = Color.White, + color = if (episode.completed) Color(0xFFAAAAAA) else Color.White, fontSize = McDimens.bodySmall, fontWeight = FontWeight.SemiBold, maxLines = 1, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 3d84f32..3430e90 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -345,8 +345,8 @@ fun McNav( api = api, seriesId = id, onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, episodeId, _ -> - nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") + onPlay = { mediaFileId, episodeId, _, startMs -> + nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/$startMs") }, ) } @@ -471,7 +471,9 @@ private fun MainHub( } 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) { @@ -586,6 +588,18 @@ private fun MainHub( onSeeAll = onBrowse, 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( api = api, catalogId = movieCatalog, @@ -1343,12 +1357,23 @@ private fun SettingsScreen( .clickable(onClick = onManageProfiles) .padding(horizontal = 16.dp, vertical = 16.dp), ) { - Text( - "Profielen beheren", - color = Color.White, - fontSize = McDimens.body, - fontWeight = FontWeight.SemiBold, - ) + Column { + Text( + if (canManageDevices) "Profielen beheren" else "Je profiel beheren", + color = Color.White, + 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) { Spacer(Modifier.height(8.dp)) @@ -1375,21 +1400,30 @@ private fun SettingsScreen( } } } - Spacer(Modifier.height(8.dp)) - Box( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(McColors.BgElevated) - .clickable(onClick = onLogout) - .padding(horizontal = 16.dp, vertical = 16.dp), - ) { - Text( - "Uitloggen", - color = McColors.Danger, - fontSize = McDimens.body, - fontWeight = FontWeight.SemiBold, - ) + if (canManageDevices) { + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(McColors.BgElevated) + .clickable(onClick = onLogout) + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Column { + Text( + "Uitloggen", + color = McColors.Danger, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Server / account wisselen", + color = McColors.TextDim, + fontSize = McDimens.meta, + ) + } + } } Spacer(Modifier.height(20.dp)) SectionLabel("Home-planken") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index e98d4d9..1a6c39c 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -103,6 +103,11 @@ fun ProfilesScreen( .onSuccess { profiles = it.profiles 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" } loading = false @@ -161,6 +166,11 @@ fun ProfilesScreen( onSelect = { profile -> if (!switcherMode && canManage) { openEdit(profile) + } else if (!switcherMode && !canManage) { + scope.launch { + val id = store.profileId.first() + if (profile.id == id) openEdit(profile) + } } else if (profile.hasPin) { pending = profile pin = "" @@ -231,10 +241,14 @@ fun ProfilesScreen( busy = busy, error = error, onCancel = { - mode = ProfilesMode.Pick - editingId = null - pending = null - error = null + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + error = null + } }, onSave = { if (busy) return@ProfileFormContent @@ -265,10 +279,14 @@ fun ProfilesScreen( if (localId == saved.id || editId == localId) { store.setProfile(saved.id, saved.name, saved.avatarId) } - mode = ProfilesMode.Pick - editingId = null - pending = null - reload() + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + reload() + } }.onFailure { error = it.message ?: "Opslaan mislukt" } @@ -328,7 +346,11 @@ private fun PickContent( ) { Spacer(Modifier.height(28.dp)) Text( - if (switcherMode) "Wie kijkt er?" else "Profielen beheren", + when { + switcherMode -> "Wie kijkt er?" + canManage -> "Profielen beheren" + else -> "Je profiel beheren" + }, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold, @@ -339,7 +361,7 @@ private fun PickContent( when { !switcherMode && canManage -> "Tik om te bewerken · houd vast om te verwijderen" switcherMode -> "Kies je profiel om verder te gaan" - else -> "Alleen het hoofdprofiel mag beheren" + else -> "Pas naam en avatar aan" }, color = Color(0xFF9A9AA8), fontSize = 14.sp, diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 45facff..c393be5 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 = 87 - versionName = "0.14.24" + versionCode = 90 + versionName = "0.14.27" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") @@ -71,6 +71,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") 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 031e630..1becf97 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -526,7 +527,7 @@ fun SeriesDetailScreen( api: ApiClient, seriesId: String, onBack: () -> Unit, - onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, + onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit, ) { val context = LocalContext.current var title by remember { mutableStateOf("…") } @@ -588,6 +589,9 @@ fun SeriesDetailScreen( overview = e.optString("overview"), stillUrl = e.optCleanString("stillUrl"), 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) Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - episodes.firstOrNull()?.let { ep -> - DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { - onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") + val playEp = remember(episodes) { + episodes.firstOrNull { !it.completed && it.positionMs > 30_000L } + ?: 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()) { @@ -669,7 +688,12 @@ fun SeriesDetailScreen( episode = ep, focusRequester = null, 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 stillUrl: String?, 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) @Composable @@ -708,6 +742,12 @@ private fun EpisodeCard( contentColor = Color.White, focusedContentColor = Color.White, ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(8.dp), + ), + ), ) { Row( Modifier @@ -716,20 +756,52 @@ private fun EpisodeCard( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage( - model = episode.stillUrl, - contentDescription = null, - modifier = Modifier + Box( + Modifier .width(160.dp) .height(90.dp) .clip(RoundedCornerShape(6.dp)) .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)) { Text( "S${episode.season}E${episode.number} · ${episode.title}", - color = Color.White, + color = if (episode.completed) Color(0xFFAAAAAA) else Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, 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 72b7d24..d9d4a90 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 @@ -77,7 +77,6 @@ import androidx.navigation.navArgument import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface -import androidx.tv.material3.Switch import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.imageLoader @@ -117,6 +116,7 @@ fun McNav() { var ready by remember { mutableStateOf(false) } var authed 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 profileName by store.profileName.collectAsState(initial = null) // Blijft behouden als main uit compositie gaat (bijv. live player) @@ -141,16 +141,22 @@ fun McNav() { me?.profile != null -> { store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) hasProfile = true + isOwner = me.profile.isOwner } !localProfile.isNullOrBlank() -> { // Lokaal bekend, server nog niet — switcher afdwingen store.setProfile(null) hasProfile = false + isOwner = false + } + else -> { + hasProfile = false + isOwner = false } - else -> hasProfile = false } } else { hasProfile = false + isOwner = false } if (tvCategoryId == null) { store.tvLastCategoryId.first()?.let { tvCategoryId = it } @@ -209,6 +215,7 @@ fun McNav() { onLoggedIn = { authed = true hasProfile = false + isOwner = false nav.navigate("profiles") { popUpTo("server") { inclusive = true } } }, ) @@ -226,20 +233,27 @@ fun McNav() { ) { authed = true hasProfile = false + isOwner = false nav.navigate("profiles") { popUpTo("server") { inclusive = true } } } } composable("profiles") { ProfilesScreen(api = api, store = store, switcherMode = 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 } } } } composable("profiles_manage") { ProfilesScreen(api = api, store = store, switcherMode = false) { hasProfile = true - scope.launch { store.saveHomeCache("") } + scope.launch { + store.saveHomeCache("") + isOwner = runCatching { api.clientMe().profile?.isOwner == true }.getOrDefault(false) + } nav.navigate("main") { popUpTo("main") { inclusive = false } launchSingleTop = true @@ -283,6 +297,7 @@ fun McNav() { store.setProfile(null) store.saveHomeCache("") hasProfile = false + isOwner = false nav.navigate("profiles") { popUpTo("main") { inclusive = true } } @@ -297,9 +312,11 @@ fun McNav() { api.configure(store.apiBase.first(), null) authed = false hasProfile = false + isOwner = false nav.navigate("server") { popUpTo(0) { inclusive = true } } } }, + isOwner = isOwner, ) } composable( @@ -342,8 +359,8 @@ fun McNav() { api = api, seriesId = id, onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, episodeId, _ -> - nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") + onPlay = { mediaFileId, episodeId, _, startMs -> + nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/$startMs") }, ) } @@ -420,6 +437,7 @@ private fun MainHub( onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onLogout: () -> Unit, + isOwner: Boolean = false, ) { var iptvEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } @@ -456,7 +474,7 @@ private fun MainHub( } 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) { @@ -574,6 +592,12 @@ private fun MainHub( profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"), profileLabel = profileName, onProfileClick = onSwitchProfile, + onSettingsClick = { + onTabChange(MainTab.Settings) + exitConfirm = false + homeChromeCompact = false + navFocused = true + }, onSelect = { if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar onTabChange(it) @@ -707,6 +731,7 @@ private fun MainHub( api = api, profileAvatarId = profileAvatarId, profileName = profileName, + isOwner = isOwner, onBack = { onTabChange(MainTab.Home) focusTopNav() @@ -1540,6 +1565,7 @@ private fun SettingsScreen( api: ApiClient, profileAvatarId: String? = null, profileName: String? = null, + isOwner: Boolean = false, onBack: () -> Unit, onSwitchProfile: () -> Unit = {}, onManageProfiles: () -> Unit = {}, @@ -1658,8 +1684,12 @@ private fun SettingsScreen( } Spacer(Modifier.height(10.dp)) SettingsActionRow( - title = "Profielen beheren", - subtitle = "Toevoegen, bewerken of verwijderen", + title = if (isOwner) "Profielen beheren" else "Je profiel beheren", + subtitle = if (isOwner) { + "Toevoegen, bewerken of verwijderen" + } else { + "Naam en avatar wijzigen" + }, onClick = onManageProfiles, ) } @@ -1690,13 +1720,15 @@ private fun SettingsScreen( ) } } - Spacer(Modifier.height(10.dp)) - SettingsActionRow( - title = "Uitloggen", - subtitle = "Apparaat ontkoppelen van dit account", - danger = true, - onClick = onLogout, - ) + if (isOwner) { + Spacer(Modifier.height(10.dp)) + SettingsActionRow( + title = "Uitloggen", + subtitle = "Server / account wisselen", + danger = true, + onClick = onLogout, + ) + } } item { @@ -1762,14 +1794,14 @@ private fun SettingsScreen( persist(next) } } - Switch( - checked = p.visible, - onCheckedChange = { checked -> - val next = prefs.toMutableList() - next[index] = p.copy(visible = checked) - persist(next) - }, - ) + McButton( + label = if (p.visible) "Aan" else "Uit", + primary = p.visible, + ) { + val next = prefs.toMutableList() + 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), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = PaddingValues(bottom = 32.dp), + contentPadding = PaddingValues(top = 28.dp, bottom = 32.dp), modifier = Modifier.fillMaxSize(), ) { items(items.size, key = { items[it].id }) { idx -> 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 ca830ae..55d7a92 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 @@ -131,8 +131,15 @@ fun PlayerScreen( fun goNext() { val n = nextEp ?: return - showNextOverlay = false - onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack() + if (showNextOverlay) showNextOverlay = false + 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() { 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 3b77742..81ba729 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 @@ -11,10 +11,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items 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.getValue import androidx.compose.ui.Alignment @@ -85,6 +89,8 @@ fun TopNavBar( profileAvatarEmoji: String? = null, profileLabel: String? = null, onProfileClick: (() -> Unit)? = null, + /** Tandwiel links van het profiel-icoon (Settings-tab). */ + onSettingsClick: (() -> Unit)? = null, ) { val alpha = when { // Overlay: transparantie zit in de gradient, niet in ekstra fade @@ -177,6 +183,33 @@ fun TopNavBar( } } 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) { Surface( onClick = onProfileClick, @@ -199,9 +232,9 @@ fun TopNavBar( if (!profileLabel.isNullOrBlank()) { Text( profileLabel, - color = Color.Unspecified, + color = McColors.Brand, fontSize = 14.sp, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.SemiBold, maxLines = 1, ) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index cd4ef76..2c566a1 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -96,6 +96,11 @@ fun ProfilesScreen( .onSuccess { profiles = it.profiles 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" } loading = false @@ -147,7 +152,11 @@ fun ProfilesScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - if (switcherMode) "Wie kijkt er?" else "Profielen beheren", + when { + switcherMode -> "Wie kijkt er?" + canManage -> "Profielen beheren" + else -> "Je profiel beheren" + }, color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Bold, @@ -157,7 +166,7 @@ fun ProfilesScreen( when { !switcherMode && canManage -> "OK = bewerken · lang drukken = verwijderen" switcherMode -> "Kies je profiel" - else -> "Alleen het hoofdprofiel mag beheren" + else -> "Pas naam en avatar aan" }, color = Color(0xFFAAAAAA), fontSize = 16.sp, @@ -188,6 +197,11 @@ fun ProfilesScreen( onClick = { if (!switcherMode && canManage) { openEdit(p) + } else if (!switcherMode && !canManage) { + scope.launch { + val id = store.profileId.first() + if (p.id == id) openEdit(p) + } } else if (p.hasPin) { pending = p pin = "" @@ -311,10 +325,14 @@ fun ProfilesScreen( Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { ActionChip("Annuleer") { - mode = ProfilesMode.Pick - editingId = null - pending = null - error = null + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + error = null + } } ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) { scope.launch { @@ -343,10 +361,14 @@ fun ProfilesScreen( if (localId == saved.id || editId == localId) { store.setProfile(saved.id, saved.name, saved.avatarId) } - mode = ProfilesMode.Pick - editingId = null - pending = null - reload() + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + reload() + } }.onFailure { error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt" } diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 09ac50b..1715c9c 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -170,6 +170,20 @@ struct Episode: Decodable, Identifiable { let mediaFileId: String? let available: Bool? 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 { diff --git a/apps/ios/VMC/UI/DetailScreens.swift b/apps/ios/VMC/UI/DetailScreens.swift index 7c8841d..d362be8 100644 --- a/apps/ios/VMC/UI/DetailScreens.swift +++ b/apps/ios/VMC/UI/DetailScreens.swift @@ -227,7 +227,7 @@ struct SeriesDetailScreen: View { movieId: nil, episodeId: target.episodeId, seriesId: seriesId, - startMs: 0 + startMs: target.startMs ) .environmentObject(app) } @@ -243,13 +243,16 @@ struct SeriesDetailScreen: 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 playable = ep.mediaFileId.map { !$0.isEmpty } ?? false + let watched = ep.completed == true + let progress = ep.progressFraction Button { guard let mediaFileId = ep.mediaFileId, playable else { return } playTarget = EpisodePlay( mediaFileId: mediaFileId, episodeId: ep.id, - title: "\(seriesTitle) · \(title)" + title: "\(seriesTitle) · \(title)", + startMs: ep.resumeMs ) } label: { HStack(alignment: .top, spacing: 12) { @@ -257,18 +260,43 @@ struct SeriesDetailScreen: View { RemoteImage(url: ep.stillUrl ?? series?.backdropUrl) .frame(width: 128, height: 72) .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") .foregroundStyle(.white) .shadow(radius: 4) .padding(6) } } + .frame(width: 128, height: 72) VStack(alignment: .leading, spacing: 4) { Text("\(ep.episodeNumber ?? 0). \(title)") .font(McFonts.headline) - .foregroundStyle(playable ? McColors.text : McColors.textDim) + .foregroundStyle(watched ? McColors.textDim : (playable ? McColors.text : McColors.textDim)) .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -404,6 +432,7 @@ private struct EpisodePlay: Identifiable { let mediaFileId: String let episodeId: String let title: String + let startMs: Int64 } private extension Array { diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index cda4d22..ffac041 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -70,22 +70,26 @@ struct SettingsScreen: View { Section("Server") { LabeledContent("URL", value: app.store.apiBase) - Button { - Task { await app.logout() } - } label: { - Label("Server / account wisselen", systemImage: "server.rack") - .foregroundStyle(McColors.text) + if app.canManageProfiles { + Button { + Task { await app.logout() } + } label: { + Label("Server / account wisselen", systemImage: "server.rack") + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) } - .listRowBackground(McColors.bgElevated) } - Section { - Button(role: .destructive) { - Task { await app.logout() } - } label: { - Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right") + if app.canManageProfiles { + Section { + Button(role: .destructive) { + Task { await app.logout() } + } label: { + Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right") + } + .listRowBackground(McColors.bgElevated) } - .listRowBackground(McColors.bgElevated) } Section("Info") { diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 5863d15..ae042d3 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -530,9 +530,9 @@ export function registerViewerRoutes( app.get("/api/v1/client/series/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); - requireProfile(auth); + const profileId = requireProfile(auth); 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) => { diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 226ac84..f38d09b 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -1575,6 +1575,7 @@ export class ViewerService { }); const out = []; + const seenSeries = new Set(); for (const p of rows) { if (p.mediaType === "MOVIE" && p.movieId) { const movie = await prisma.movie.findUnique({ where: { id: p.movieId } }); @@ -1597,6 +1598,11 @@ export class ViewerService { progressId: p.id, }); } 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({ where: { id: p.episodeId }, include: { series: true }, @@ -1650,6 +1656,13 @@ export class ViewerService { if (body.mediaType === "MOVIE") { 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({ where: { profileId_movieId: { profileId, movieId: body.movieId }, @@ -1660,16 +1673,16 @@ export class ViewerService { movieId: body.movieId, positionMs, durationMs, - completed: autoComplete, + completed, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, - completed: body.completed ?? autoComplete, + completed, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } - : autoComplete + : completed ? { hiddenFromContinue: true } : positionMs > 30_000 ? { hiddenFromContinue: false } @@ -1684,6 +1697,14 @@ export class ViewerService { const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } }); 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({ where: { profileId_episodeId: { profileId, episodeId: body.episodeId }, @@ -1695,23 +1716,37 @@ export class ViewerService { seriesId, positionMs, durationMs, - completed: autoComplete, + completed, hiddenFromContinue: body.hiddenFromContinue ?? false, }, update: { positionMs, durationMs, seriesId, - completed: body.completed ?? autoComplete, + completed, ...(body.hiddenFromContinue !== undefined ? { hiddenFromContinue: body.hiddenFromContinue } - : autoComplete + : completed ? { hiddenFromContinue: true } : positionMs > 30_000 ? { 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) { 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({ where: { id: seriesId }, include: { @@ -2216,6 +2251,24 @@ export class ViewerService { .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 { type: "series" as const, id: series.id, @@ -2232,17 +2285,23 @@ export class ViewerService { trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, seasons: series.seasons.map((s) => ({ seasonNumber: s.seasonNumber, - episodes: s.episodes.map((e) => ({ - id: e.id, - seasonNumber: e.seasonNumber, - episodeNumber: e.episodeNumber, - title: e.title, - overview: e.overview, - stillUrl: e.stillUrl, - runtime: e.runtime, - mediaFileId: e.mediaFiles[0]?.id ?? null, - available: e.mediaFiles.length > 0, - })), + episodes: s.episodes.map((e) => { + const p = progressByEpisode.get(e.id); + return { + id: e.id, + seasonNumber: e.seasonNumber, + episodeNumber: e.episodeNumber, + title: e.title, + overview: e.overview, + stillUrl: e.stillUrl, + 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, + }; + }), })), }; }