Add TV catalog A–Z sort/jump and fix exit/continue focus.

Browse films/series alphabetically with sort chips and letter scrubber; trap dialog focus and fix long-press remove from continue watching (0.14.13).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-09 19:40:58 +02:00
parent 308160aa56
commit 00fa9cd9c7
7 changed files with 744 additions and 202 deletions

View file

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

View file

@ -87,16 +87,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.optJSONArray("results").toItems() }
}
suspend fun browse(catalogId: String, skip: Int = 0, take: Int = 60): BrowsePayload =
suspend fun browse(
catalogId: String,
skip: Int = 0,
take: Int = 60,
sort: String? = null,
letter: String? = null,
): BrowsePayload =
withContext(Dispatchers.IO) {
val id = java.net.URLEncoder.encode(catalogId, "UTF-8")
val req = authed("$baseUrl/api/v1/client/catalog/$id?skip=$skip&take=$take").get().build()
val qs = buildString {
append("skip=$skip&take=$take")
if (!sort.isNullOrBlank()) append("&sort=").append(java.net.URLEncoder.encode(sort, "UTF-8"))
if (!letter.isNullOrBlank()) append("&letter=").append(java.net.URLEncoder.encode(letter, "UTF-8"))
}
val req = authed("$baseUrl/api/v1/client/catalog/$id?$qs").get().build()
parseObject(execute(req)) { o ->
BrowsePayload(
id = o.optString("id"),
title = o.optString("title"),
kind = o.optString("kind"),
total = o.optInt("total"),
offset = o.optInt("offset", skip),
items = o.optJSONArray("items").toItems(),
)
}
@ -510,6 +522,7 @@ data class BrowsePayload(
val kind: String,
val total: Int,
val items: List<MediaItem>,
val offset: Int = 0,
)
data class HomePayload(

View file

@ -3,21 +3,26 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -25,12 +30,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
@ -40,6 +53,8 @@ import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun BrowseScreen(
@ -59,16 +74,28 @@ fun BrowseScreen(
startMs: Long,
) -> Unit)? = null,
) {
val isContinue = catalogId == "continue-watching"
val defaultSort = when (catalogId) {
"recent-movies", "recent-series", "4k-movies", "4k" -> "recent"
else -> "title"
}
var title by remember { mutableStateOf(titleHint ?: "Bibliotheek") }
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var total by remember { mutableStateOf(0) }
var total by remember { mutableIntStateOf(0) }
var listOffset by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var loadingMore by remember { mutableStateOf(false) }
var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by remember(catalogId) { mutableStateOf<String?>(null) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
val menuFocus = remember { FocusRequester() }
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val showBrowseTools = !isContinue
suspend fun load(reset: Boolean) {
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
if (reset) {
loading = true
error = null
@ -76,15 +103,25 @@ fun BrowseScreen(
loadingMore = true
}
try {
val skip = if (reset) 0 else items.size
val page = api.browse(catalogId, skip = skip, take = 60)
// Alleen bij letter-jump letter meesturen; bij sort/catalog-reset geen letter.
val letterForApi = if (reset && jumpLetter != null) jumpLetter else null
val skip = if (reset) 0 else listOffset + items.size
val page = api.browse(
catalogId,
skip = skip,
take = 60,
sort = if (isContinue) null else sort,
letter = letterForApi,
)
title = page.title.ifBlank { titleHint ?: catalogId }
total = page.total
items = if (reset) {
page.items
if (reset) {
listOffset = page.offset
items = page.items
activeLetter = jumpLetter
} else {
val existing = items.map { "${it.type}:${it.id}" }.toSet()
items + page.items.filter { "${it.type}:${it.id}" !in existing }
val existing = items.map { "${it.type}:${it.id}:${it.progressId}" }.toSet()
items = items + page.items.filter { "${it.type}:${it.id}:${it.progressId}" !in existing }
}
} catch (e: Exception) {
error = e.message
@ -94,9 +131,12 @@ fun BrowseScreen(
}
}
LaunchedEffect(catalogId) { load(reset = true) }
LaunchedEffect(catalogId, sort) {
load(reset = true, jumpLetter = null)
gridState.scrollToItem(0)
}
LaunchedEffect(gridState, items.size, total) {
LaunchedEffect(gridState, items.size, total, listOffset) {
snapshotFlow {
val info = gridState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
@ -104,14 +144,25 @@ fun BrowseScreen(
}
.distinctUntilChanged()
.collect { nearEnd ->
if (nearEnd && !loadingMore && !loading && items.size < total) {
if (nearEnd && !loadingMore && !loading && listOffset + items.size < total) {
scope.launch { load(reset = false) }
}
}
}
LaunchedEffect(continueMenu) {
if (continueMenu != null) {
menuReady = false
delay(80)
menuReady = true
runCatching { menuFocus.requestFocus() }
} else {
menuReady = false
}
}
val handleBack = onBack != null || onAtTopBack != null
if (handleBack) {
if (handleBack && continueMenu == null) {
BackHandler {
val scrolled = gridState.firstVisibleItemIndex > 0 ||
gridState.firstVisibleItemScrollOffset > 0
@ -125,88 +176,245 @@ fun BrowseScreen(
}
}
Column(
Modifier
.fillMaxSize()
.background(McColors.Bg)
.padding(horizontal = 36.dp, vertical = 20.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
Column(
Modifier
.fillMaxSize()
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
) {
Column {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(
when {
total > 0 -> "$total titels"
loading -> "Laden…"
else -> "Geen titels"
},
color = McColors.TextDim,
fontSize = 14.sp,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(
when {
total > 0 -> "$total titels"
loading -> "Laden…"
else -> "Geen titels"
},
color = McColors.TextDim,
fontSize = 14.sp,
)
}
if (onBack != null) {
McButton("Terug", onClick = onBack)
}
}
if (onBack != null) {
McButton("Terug", onClick = onBack)
if (showBrowseTools) {
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SortChip(
label = "A–Z",
selected = sort == "title",
onClick = { if (sort != "title") sort = "title" },
)
SortChip(
label = "Recent",
selected = sort == "recent",
onClick = {
if (sort != "recent") {
activeLetter = null
sort = "recent"
}
},
)
}
}
}
Spacer(Modifier.height(16.dp))
if (error != null) {
Text(error!!, color = McColors.Danger)
}
when {
loading && items.isEmpty() -> {
EmptyState(title = "Laden…", subtitle = "Even geduld.")
Spacer(Modifier.height(16.dp))
if (error != null) {
Text(error!!, color = McColors.Danger)
}
!loading && items.isEmpty() && error == null -> {
EmptyState(
title = "Geen titels gevonden",
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
actionLabel = "Opnieuw proberen",
onAction = { scope.launch { load(reset = true) } },
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
state = gridState,
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
fillWidth = true,
modifier = Modifier.padding(6.dp),
onClick = {
val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() &&
(item.positionMs > 0 || catalogId == "continue-watching")
if (canResume) {
onPlayContinue!!(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
item.seriesId,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
} else if (item.type == "movie") {
onOpenMovie(item.id)
} else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
onOpenSeries(item.seriesId!!)
when {
loading && items.isEmpty() -> {
EmptyState(title = "Laden…", subtitle = "Even geduld.")
}
!loading && items.isEmpty() && error == null -> {
EmptyState(
title = "Geen titels gevonden",
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
actionLabel = "Opnieuw proberen",
onAction = { scope.launch { load(reset = true) } },
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
state = gridState,
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
fillWidth = true,
showCaption = isContinue,
modifier = Modifier.padding(6.dp),
onLongClick = if (isContinue) {
{ continueMenu = item }
} else {
onOpenSeries(item.id)
null
},
onClick = {
val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() &&
(item.positionMs > 0 || isContinue)
if (canResume) {
onPlayContinue!!(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
item.seriesId,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
} else if (item.type == "movie") {
onOpenMovie(item.id)
} else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
onOpenSeries(item.seriesId!!)
} else {
onOpenSeries(item.id)
}
},
)
}
}
}
}
}
if (showBrowseTools && sort == "title") {
AzScrubber(
selected = activeLetter,
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.padding(vertical = 72.dp, horizontal = 6.dp),
onLetter = { letter ->
scope.launch {
load(reset = true, jumpLetter = letter)
gridState.scrollToItem(0)
}
},
)
}
val menuItem = continueMenu
if (menuItem != null) {
BackHandler { continueMenu = null }
Dialog(
onDismissRequest = { continueMenu = null },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(440.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
if (!menuItem.subtitle.isNullOrBlank()) {
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
}
Spacer(Modifier.height(4.dp))
McButton("Verwijderen uit verder kijken", focusRequester = menuFocus) {
if (!menuReady) return@McButton
scope.launch {
val pid = menuItem.progressId
if (!pid.isNullOrBlank()) {
runCatching { api.dismissContinue(pid) }
.onFailure { error = it.message }
.onSuccess {
items = items.filterNot { it.progressId == pid }
total = (total - 1).coerceAtLeast(0)
}
}
},
)
continueMenu = null
}
}
McButton("Annuleren") { continueMenu = null }
}
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SortChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
focusedContainerColor = Color.White,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = if (selected) Color.Black else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun AzScrubber(
selected: String?,
onLetter: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.width(28.dp),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally,
) {
AzLetters.forEach { letter ->
val isSelected = selected.equals(letter, ignoreCase = true)
Surface(
onClick = { onLetter(letter) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.25f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF),
),
) {
Text(
letter,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
)
}
}
}
}

View file

@ -64,6 +64,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -376,6 +378,8 @@ private fun MainHub(
var homeChromeCompact by remember { mutableStateOf(false) }
val navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) }
val homeListState = rememberLazyListState()
val hubScope = rememberCoroutineScope()
val activity = LocalContext.current as? Activity
@ -385,6 +389,17 @@ private fun MainHub(
var movieSubId by remember { mutableStateOf("movies") }
var seriesSubId by remember { mutableStateOf("series") }
LaunchedEffect(exitConfirm) {
if (exitConfirm) {
exitReady = false
delay(60)
exitReady = true
runCatching { exitFocus.requestFocus() }
} else {
exitReady = false
}
}
LaunchedEffect(Unit) {
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
runCatching { iptvEnabled = api.clientMe().iptvEnabled }
@ -657,30 +672,48 @@ private fun MainHub(
}
if (exitConfirm) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
Dialog(
onDismissRequest = { exitConfirm = false },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Column(
Box(
Modifier
.width(420.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(
"Weet je zeker dat je de app wilt sluiten?",
color = McColors.TextDim,
fontSize = 15.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
McButton("Annuleren") { exitConfirm = false }
McButton("Afsluiten", primary = true) { activity?.finish() }
Column(
Modifier
.width(420.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(
"Weet je zeker dat je de app wilt sluiten?",
color = McColors.TextDim,
fontSize = 15.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
McButton(
"Annuleren",
focusRequester = exitFocus,
) {
if (!exitReady) return@McButton
exitConfirm = false
}
McButton("Afsluiten", primary = true) {
if (!exitReady) return@McButton
activity?.finish()
}
}
}
}
}
@ -883,7 +916,7 @@ private fun HomeScreen(
LaunchedEffect(continueMenu) {
if (continueMenu != null) {
menuReady = false
delay(500)
delay(80)
menuReady = true
runCatching { menuFocus.requestFocus() }
} else {
@ -1041,53 +1074,62 @@ private fun HomeScreen(
val menuItem = continueMenu
if (menuItem != null) {
BackHandler { continueMenu = null }
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
Dialog(
onDismissRequest = { continueMenu = null },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Column(
Box(
Modifier
.width(440.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
if (!menuItem.subtitle.isNullOrBlank()) {
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
}
Spacer(Modifier.height(4.dp))
McButton("Markeer als gezien") {
if (!menuReady) return@McButton
scope.launch {
runCatching {
if (menuItem.type == "movie") {
api.markWatched(movieId = menuItem.id)
} else {
api.markWatched(episodeId = menuItem.id)
}
reloadHome()
}.onFailure { error = it.message }
continueMenu = null
Column(
Modifier
.width(440.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
if (!menuItem.subtitle.isNullOrBlank()) {
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
}
}
McButton("Verberg uit lijst") {
if (!menuReady) return@McButton
scope.launch {
val pid = menuItem.progressId
if (!pid.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
McButton("Verwijderen uit verder kijken", focusRequester = menuFocus) {
if (!menuReady) return@McButton
scope.launch {
val pid = menuItem.progressId
if (!pid.isNullOrBlank()) {
runCatching {
api.dismissContinue(pid)
reloadHome()
}.onFailure { error = it.message }
}
continueMenu = null
}
}
McButton("Markeer als gezien") {
if (!menuReady) return@McButton
scope.launch {
runCatching {
api.dismissContinue(pid)
if (menuItem.type == "movie") {
api.markWatched(movieId = menuItem.id)
} else {
api.markWatched(episodeId = menuItem.id)
}
reloadHome()
}.onFailure { error = it.message }
continueMenu = null
}
continueMenu = null
}
McButton("Annuleren") { continueMenu = null }
}
McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null }
}
}
}

View file

@ -114,11 +114,15 @@ private fun Modifier.tvShortOrLongPress(
return this.onPreviewKeyEvent { ev ->
when {
ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
downAt = System.currentTimeMillis()
false
// TV-remotes sturen KeyDown-herhalingen tijdens vasthouden — niet opnieuw starten.
if (downAt == 0L) downAt = System.currentTimeMillis()
true
}
ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
val held = System.currentTimeMillis() - downAt
val started = downAt
downAt = 0L
if (started == 0L) return@onPreviewKeyEvent true
val held = System.currentTimeMillis() - started
if (held >= 450) onLongPress() else onClick()
true
}

View file

@ -504,15 +504,19 @@ export function registerViewerRoutes(
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const { id } = request.params as { id: string };
const q = request.query as { skip?: string; take?: string };
const q = request.query as { skip?: string; take?: string; sort?: string; letter?: string };
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100);
const sort =
q.sort === "title" || q.sort === "recent" ? (q.sort as "title" | "recent") : undefined;
const letter = (q.letter || "").trim().slice(0, 1) || undefined;
return viewers.browseCatalog(
id,
skip,
take,
profileId,
auth.profile?.isKids === true
auth.profile?.isKids === true,
{ sort, letter }
);
});

View file

@ -775,7 +775,7 @@ export class ViewerService {
const allIds = idRows.map((r) => r.movie_id);
const total = allIds.length;
const ids = allIds.slice(skip, skip + take);
if (!ids.length) return { total, items: [] };
if (!ids.length) return { total, items: [], offset: skip };
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
@ -787,7 +787,78 @@ export class ViewerService {
},
},
});
return { total, items: this.mapMovies(ids, movies) };
return { total, items: this.mapMovies(ids, movies), offset: skip };
}
/** Alfabetisch op titel; optioneel spring naar letter (A–Z / #). */
private async moviesByTitle(
skip: number,
take: number,
extraWhereSql: Prisma.Sql = Prisma.empty,
opts: { kidsMode?: boolean; letter?: string } = {}
) {
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
let offset = Math.max(0, skip);
const letter = opts.letter?.trim().toUpperCase();
if (letter && letter !== "#") {
const L = letter.slice(0, 1);
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT mf.movie_id
FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
${kidsSql}
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
GROUP BY mf.movie_id
) t
`;
offset = Number(before?.count ?? 0);
} else if (letter === "#") {
offset = 0;
}
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
${kidsSql}
GROUP BY mf.movie_id
) t
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
${kidsSql}
GROUP BY mf.movie_id, m.title
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
OFFSET ${offset}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return { total, items: [], offset };
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
include: {
mediaFiles: {
where: { available: true },
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1,
},
},
});
return { total, items: this.mapMovies(ids, movies), offset };
}
private mapMovies(
@ -848,6 +919,7 @@ export class ViewerService {
backdropUrl: string | null;
mediaFileId: string | null;
}>,
offset: skip,
};
}
@ -867,6 +939,94 @@ export class ViewerService {
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
})),
offset: skip,
};
}
private async seriesByTitle(
skip: number,
take: number,
opts: { kidsMode?: boolean; letter?: string } = {}
) {
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
let offset = Math.max(0, skip);
const letter = opts.letter?.trim().toUpperCase();
if (letter && letter !== "#") {
const L = letter.slice(0, 1);
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
INNER JOIN series s ON s.id = e.series_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSql}
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
GROUP BY e.series_id
) t
`;
offset = Number(before?.count ?? 0);
} else if (letter === "#") {
offset = 0;
}
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSql}
GROUP BY e.series_id
) t
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
INNER JOIN series s ON s.id = e.series_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSql}
GROUP BY e.series_id, s.title
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
OFFSET ${offset}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (!ids.length) {
return {
total,
items: [] as Array<{
type: "series";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
mediaFileId: string | null;
}>,
offset,
};
}
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s]));
return {
total,
items: ids
.map((id) => byId.get(id))
.filter((s): s is NonNullable<typeof s> => !!s)
.map((s) => ({
type: "series" as const,
id: s.id,
title: s.title,
year: s.year,
posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
})),
offset,
};
}
@ -895,11 +1055,36 @@ export class ViewerService {
kind: "MOVIE" | "SERIES",
skip: number,
take: number,
kidsMode = false
kidsMode = false,
opts: { sort?: "title" | "recent"; letter?: string } = {}
) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
const sort = opts.sort ?? "title";
const letter = opts.letter?.trim().toUpperCase();
if (kind === "MOVIE") {
let offset = Math.max(0, skip);
if (sort === "title" && letter && letter !== "#") {
const L = letter.slice(0, 1);
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT mf.movie_id
FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
GROUP BY mf.movie_id
) t
`;
offset = Number(before?.count ?? 0);
} else if (sort === "title" && letter === "#") {
offset = 0;
}
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
FROM media_files mf
@ -909,20 +1094,35 @@ export class ViewerService {
${kidsMovie}
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const idRows =
sort === "title"
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
GROUP BY mf.movie_id, m.title
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
OFFSET ${offset}
LIMIT ${take}
`
: await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${offset}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return { total, items: [] };
if (!ids.length) return { total, items: [], offset };
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
@ -949,9 +1149,32 @@ export class ViewerService {
backdropUrl: m.backdropUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null,
})),
offset,
};
}
let offset = Math.max(0, skip);
if (sort === "title" && letter && letter !== "#") {
const L = letter.slice(0, 1);
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count FROM (
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
INNER JOIN series s ON s.id = e.series_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
GROUP BY e.series_id
) t
`;
offset = Number(before?.count ?? 0);
} else if (sort === "title" && letter === "#") {
offset = 0;
}
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
FROM media_files mf
@ -962,21 +1185,37 @@ export class ViewerService {
${kidsSeries}
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
GROUP BY e.series_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const idRows =
sort === "title"
? await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
INNER JOIN series s ON s.id = e.series_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
GROUP BY e.series_id, s.title
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
OFFSET ${offset}
LIMIT ${take}
`
: await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
GROUP BY e.series_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${offset}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (!ids.length) return { total, items: [] };
if (!ids.length) return { total, items: [], offset };
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s]));
@ -994,6 +1233,7 @@ export class ViewerService {
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
})),
offset,
};
}
@ -1243,9 +1483,12 @@ export class ViewerService {
skip: number,
take: number,
profileId?: string,
kidsMode = false
kidsMode = false,
opts: { sort?: "title" | "recent"; letter?: string } = {}
) {
const id = catalogId.trim().toLowerCase();
const letter = opts.letter?.trim().toUpperCase() || undefined;
const requestedSort = opts.sort === "recent" || opts.sort === "title" ? opts.sort : undefined;
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
@ -1255,76 +1498,101 @@ export class ViewerService {
title: "Verder kijken",
kind: "MIXED" as const,
total: all.length,
offset: skip,
items: all.slice(skip, skip + take),
};
}
if (id === "movies" || id === "films") {
const page = await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
const sort = requestedSort ?? "title";
const page =
sort === "title"
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
: await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
return {
id,
title: "Films",
kind: "MOVIE" as const,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}
if (id === "series" || id === "shows") {
const page = await this.seriesByRecency(skip, take, { kidsMode });
const sort = requestedSort ?? "title";
const page =
sort === "title"
? await this.seriesByTitle(skip, take, { kidsMode, letter })
: await this.seriesByRecency(skip, take, { kidsMode });
return {
id,
title: "Series",
kind: "SERIES" as const,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}
if (id === "recent-movies") {
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
const sort = requestedSort ?? "recent";
const page =
sort === "title"
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
: await this.moviesByRecency(skip, take, Prisma.empty, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return {
id,
title: "Recent toegevoegd",
kind: "MOVIE" as const,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}
if (id === "recent-series") {
const page = await this.seriesByRecency(skip, take, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
const sort = requestedSort ?? "recent";
const page =
sort === "title"
? await this.seriesByTitle(skip, take, { kidsMode, letter })
: await this.seriesByRecency(skip, take, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return {
id,
title: "Recent toegevoegde series",
kind: "SERIES" as const,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}
if (id === "4k-movies" || id === "4k") {
const page = await this.moviesByRecency(
skip,
take,
Prisma.sql`AND (
const fourKSql = Prisma.sql`AND (
mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)`,
{ cap: RECENT_CATALOG_CAP, kidsMode }
);
)`;
const sort = requestedSort ?? "recent";
const page =
sort === "title"
? await this.moviesByTitle(skip, take, fourKSql, { kidsMode, letter })
: await this.moviesByRecency(skip, take, fourKSql, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return {
id,
title: "4K films",
kind: "MOVIE" as const,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}
@ -1339,18 +1607,21 @@ export class ViewerService {
})) || null;
if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404);
const sort = requestedSort ?? "title";
const page = await this.shelfItemsPage(
shelf.id,
shelf.kind as "MOVIE" | "SERIES",
skip,
take,
kidsMode
kidsMode,
{ sort, letter }
);
return {
id: shelf.slug,
title: shelf.name,
kind: shelf.kind,
total: page.total,
offset: page.offset ?? skip,
items: page.items,
};
}