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:
parent
07901c1d0c
commit
056078d6bc
15 changed files with 533 additions and 139 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1575,6 +1575,7 @@ export class ViewerService {
|
|||
});
|
||||
|
||||
const out = [];
|
||||
const seenSeries = new Set<string>();
|
||||
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,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue