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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = episode.stillUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Profielen beheren",
|
if (canManageDevices) "Profielen beheren" else "Je profiel beheren",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = McDimens.body,
|
fontSize = McDimens.body,
|
||||||
fontWeight = FontWeight.SemiBold,
|
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,6 +1400,7 @@ private fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (canManageDevices) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -1384,12 +1410,20 @@ private fun SettingsScreen(
|
||||||
.clickable(onClick = onLogout)
|
.clickable(onClick = onLogout)
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Uitloggen",
|
"Uitloggen",
|
||||||
color = McColors.Danger,
|
color = McColors.Danger,
|
||||||
fontSize = McDimens.body,
|
fontSize = McDimens.body,
|
||||||
fontWeight = FontWeight.SemiBold,
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
if (!switcherMode && !canManage) {
|
||||||
|
onProfileSelected()
|
||||||
|
} else {
|
||||||
mode = ProfilesMode.Pick
|
mode = ProfilesMode.Pick
|
||||||
editingId = null
|
editingId = null
|
||||||
pending = null
|
pending = null
|
||||||
error = 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)
|
||||||
}
|
}
|
||||||
|
if (!switcherMode && !canManage) {
|
||||||
|
onProfileSelected()
|
||||||
|
} else {
|
||||||
mode = ProfilesMode.Pick
|
mode = ProfilesMode.Pick
|
||||||
editingId = null
|
editingId = null
|
||||||
pending = null
|
pending = null
|
||||||
reload()
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = episode.stillUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,14 +1720,16 @@ private fun SettingsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isOwner) {
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
SettingsActionRow(
|
SettingsActionRow(
|
||||||
title = "Uitloggen",
|
title = "Uitloggen",
|
||||||
subtitle = "Apparaat ontkoppelen van dit account",
|
subtitle = "Server / account wisselen",
|
||||||
danger = true,
|
danger = true,
|
||||||
onClick = onLogout,
|
onClick = onLogout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
@ -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()
|
val next = prefs.toMutableList()
|
||||||
next[index] = p.copy(visible = checked)
|
next[index] = p.copy(visible = !p.visible)
|
||||||
persist(next)
|
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 ->
|
||||||
|
|
|
||||||
|
|
@ -131,9 +131,16 @@ fun PlayerScreen(
|
||||||
|
|
||||||
fun goNext() {
|
fun goNext() {
|
||||||
val n = nextEp ?: return
|
val n = nextEp ?: return
|
||||||
showNextOverlay = false
|
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()
|
onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun togglePlay() {
|
fun togglePlay() {
|
||||||
if (player.isPlaying) player.pause() else player.play()
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,11 +325,15 @@ 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") {
|
||||||
|
if (!switcherMode && !canManage) {
|
||||||
|
onProfileSelected()
|
||||||
|
} else {
|
||||||
mode = ProfilesMode.Pick
|
mode = ProfilesMode.Pick
|
||||||
editingId = null
|
editingId = null
|
||||||
pending = null
|
pending = null
|
||||||
error = null
|
error = null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
|
ActionChip(if (isEdit) "Opslaan" else "Aanmaken", primary = true) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
error = null
|
error = null
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
if (!switcherMode && !canManage) {
|
||||||
|
onProfileSelected()
|
||||||
|
} else {
|
||||||
mode = ProfilesMode.Pick
|
mode = ProfilesMode.Pick
|
||||||
editingId = null
|
editingId = null
|
||||||
pending = null
|
pending = null
|
||||||
reload()
|
reload()
|
||||||
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt"
|
error = it.message ?: if (isEdit) "Opslaan mislukt" else "Aanmaken mislukt"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Server") {
|
Section("Server") {
|
||||||
LabeledContent("URL", value: app.store.apiBase)
|
LabeledContent("URL", value: app.store.apiBase)
|
||||||
|
if app.canManageProfiles {
|
||||||
Button {
|
Button {
|
||||||
Task { await app.logout() }
|
Task { await app.logout() }
|
||||||
} label: {
|
} label: {
|
||||||
|
|
@ -78,7 +79,9 @@ struct SettingsScreen: View {
|
||||||
}
|
}
|
||||||
.listRowBackground(McColors.bgElevated)
|
.listRowBackground(McColors.bgElevated)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.canManageProfiles {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task { await app.logout() }
|
Task { await app.logout() }
|
||||||
|
|
@ -87,6 +90,7 @@ struct SettingsScreen: View {
|
||||||
}
|
}
|
||||||
.listRowBackground(McColors.bgElevated)
|
.listRowBackground(McColors.bgElevated)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
LabeledContent("App", value: "VMC iOS")
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +2285,9 @@ 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) => {
|
||||||
|
const p = progressByEpisode.get(e.id);
|
||||||
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
seasonNumber: e.seasonNumber,
|
seasonNumber: e.seasonNumber,
|
||||||
episodeNumber: e.episodeNumber,
|
episodeNumber: e.episodeNumber,
|
||||||
|
|
@ -2242,7 +2297,11 @@ export class ViewerService {
|
||||||
runtime: e.runtime,
|
runtime: e.runtime,
|
||||||
mediaFileId: e.mediaFiles[0]?.id ?? null,
|
mediaFileId: e.mediaFiles[0]?.id ?? null,
|
||||||
available: e.mediaFiles.length > 0,
|
available: e.mediaFiles.length > 0,
|
||||||
})),
|
completed: p?.completed === true,
|
||||||
|
positionMs: p?.positionMs ?? 0,
|
||||||
|
durationMs: p?.durationMs ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue