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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 75 versionCode = 76
versionName = "0.14.12" versionName = "0.14.13"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -87,16 +87,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.optJSONArray("results").toItems() } 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) { withContext(Dispatchers.IO) {
val id = java.net.URLEncoder.encode(catalogId, "UTF-8") 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 -> parseObject(execute(req)) { o ->
BrowsePayload( BrowsePayload(
id = o.optString("id"), id = o.optString("id"),
title = o.optString("title"), title = o.optString("title"),
kind = o.optString("kind"), kind = o.optString("kind"),
total = o.optInt("total"), total = o.optInt("total"),
offset = o.optInt("offset", skip),
items = o.optJSONArray("items").toItems(), items = o.optJSONArray("items").toItems(),
) )
} }
@ -510,6 +522,7 @@ data class BrowsePayload(
val kind: String, val kind: String,
val total: Int, val total: Int,
val items: List<MediaItem>, val items: List<MediaItem>,
val offset: Int = 0,
) )
data class HomePayload( data class HomePayload(

View file

@ -3,21 +3,26 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -25,12 +30,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient 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.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun BrowseScreen( fun BrowseScreen(
@ -59,16 +74,28 @@ fun BrowseScreen(
startMs: Long, startMs: Long,
) -> Unit)? = null, ) -> 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 title by remember { mutableStateOf(titleHint ?: "Bibliotheek") }
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) } 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 loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loadingMore by remember { mutableStateOf(false) } 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 gridState = rememberLazyGridState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val showBrowseTools = !isContinue
suspend fun load(reset: Boolean) { suspend fun load(reset: Boolean, jumpLetter: String? = null) {
if (reset) { if (reset) {
loading = true loading = true
error = null error = null
@ -76,15 +103,25 @@ fun BrowseScreen(
loadingMore = true loadingMore = true
} }
try { try {
val skip = if (reset) 0 else items.size // Alleen bij letter-jump letter meesturen; bij sort/catalog-reset geen letter.
val page = api.browse(catalogId, skip = skip, take = 60) 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 } title = page.title.ifBlank { titleHint ?: catalogId }
total = page.total total = page.total
items = if (reset) { if (reset) {
page.items listOffset = page.offset
items = page.items
activeLetter = jumpLetter
} else { } else {
val existing = items.map { "${it.type}:${it.id}" }.toSet() val existing = items.map { "${it.type}:${it.id}:${it.progressId}" }.toSet()
items + page.items.filter { "${it.type}:${it.id}" !in existing } items = items + page.items.filter { "${it.type}:${it.id}:${it.progressId}" !in existing }
} }
} catch (e: Exception) { } catch (e: Exception) {
error = e.message 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 { snapshotFlow {
val info = gridState.layoutInfo val info = gridState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0 val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
@ -104,14 +144,25 @@ fun BrowseScreen(
} }
.distinctUntilChanged() .distinctUntilChanged()
.collect { nearEnd -> .collect { nearEnd ->
if (nearEnd && !loadingMore && !loading && items.size < total) { if (nearEnd && !loadingMore && !loading && listOffset + items.size < total) {
scope.launch { load(reset = false) } 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 val handleBack = onBack != null || onAtTopBack != null
if (handleBack) { if (handleBack && continueMenu == null) {
BackHandler { BackHandler {
val scrolled = gridState.firstVisibleItemIndex > 0 || val scrolled = gridState.firstVisibleItemIndex > 0 ||
gridState.firstVisibleItemScrollOffset > 0 gridState.firstVisibleItemScrollOffset > 0
@ -125,18 +176,18 @@ fun BrowseScreen(
} }
} }
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(McColors.Bg) .padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
.padding(horizontal = 36.dp, vertical = 20.dp),
) { ) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Column(Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text( Text(
when { when {
@ -152,6 +203,28 @@ fun BrowseScreen(
McButton("Terug", onClick = onBack) 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)) Spacer(Modifier.height(16.dp))
if (error != null) { if (error != null) {
Text(error!!, color = McColors.Danger) Text(error!!, color = McColors.Danger)
@ -181,11 +254,17 @@ fun BrowseScreen(
PosterCard( PosterCard(
item = item, item = item,
fillWidth = true, fillWidth = true,
showCaption = isContinue,
modifier = Modifier.padding(6.dp), modifier = Modifier.padding(6.dp),
onLongClick = if (isContinue) {
{ continueMenu = item }
} else {
null
},
onClick = { onClick = {
val canResume = onPlayContinue != null && val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() && !item.mediaFileId.isNullOrBlank() &&
(item.positionMs > 0 || catalogId == "continue-watching") (item.positionMs > 0 || isContinue)
if (canResume) { if (canResume) {
onPlayContinue!!( onPlayContinue!!(
item.mediaFileId!!, item.mediaFileId!!,
@ -209,4 +288,133 @@ fun BrowseScreen(
} }
} }
} }
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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -376,6 +378,8 @@ private fun MainHub(
var homeChromeCompact by remember { mutableStateOf(false) } var homeChromeCompact by remember { mutableStateOf(false) }
val navFocus = remember { FocusRequester() } val navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() } val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) }
val homeListState = rememberLazyListState() val homeListState = rememberLazyListState()
val hubScope = rememberCoroutineScope() val hubScope = rememberCoroutineScope()
val activity = LocalContext.current as? Activity val activity = LocalContext.current as? Activity
@ -385,6 +389,17 @@ private fun MainHub(
var movieSubId by remember { mutableStateOf("movies") } var movieSubId by remember { mutableStateOf("movies") }
var seriesSubId by remember { mutableStateOf("series") } var seriesSubId by remember { mutableStateOf("series") }
LaunchedEffect(exitConfirm) {
if (exitConfirm) {
exitReady = false
delay(60)
exitReady = true
runCatching { exitFocus.requestFocus() }
} else {
exitReady = false
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } } runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
runCatching { iptvEnabled = api.clientMe().iptvEnabled } runCatching { iptvEnabled = api.clientMe().iptvEnabled }
@ -657,6 +672,14 @@ private fun MainHub(
} }
if (exitConfirm) { if (exitConfirm) {
Dialog(
onDismissRequest = { exitConfirm = false },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -679,8 +702,18 @@ private fun MainHub(
fontSize = 15.sp, fontSize = 15.sp,
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
McButton("Annuleren") { exitConfirm = false } McButton(
McButton("Afsluiten", primary = true) { activity?.finish() } "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) { LaunchedEffect(continueMenu) {
if (continueMenu != null) { if (continueMenu != null) {
menuReady = false menuReady = false
delay(500) delay(80)
menuReady = true menuReady = true
runCatching { menuFocus.requestFocus() } runCatching { menuFocus.requestFocus() }
} else { } else {
@ -1041,6 +1074,14 @@ private fun HomeScreen(
val menuItem = continueMenu val menuItem = continueMenu
if (menuItem != null) { if (menuItem != null) {
BackHandler { continueMenu = null } BackHandler { continueMenu = null }
Dialog(
onDismissRequest = { continueMenu = null },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -1060,6 +1101,19 @@ private fun HomeScreen(
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
} }
Spacer(Modifier.height(4.dp)) 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") { McButton("Markeer als gezien") {
if (!menuReady) return@McButton if (!menuReady) return@McButton
scope.launch { scope.launch {
@ -1074,20 +1128,8 @@ private fun HomeScreen(
continueMenu = null continueMenu = null
} }
} }
McButton("Verberg uit lijst") { McButton("Annuleren") { continueMenu = null }
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("Annuleren", focusRequester = menuFocus) { continueMenu = null }
} }
} }
} }

View file

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

View file

@ -504,15 +504,19 @@ export function registerViewerRoutes(
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth); const profileId = requireProfile(auth);
const { id } = request.params as { id: string }; 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 skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100); 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( return viewers.browseCatalog(
id, id,
skip, skip,
take, take,
profileId, 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 allIds = idRows.map((r) => r.movie_id);
const total = allIds.length; const total = allIds.length;
const ids = allIds.slice(skip, skip + take); 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({ const movies = await prisma.movie.findMany({
where: { id: { in: ids } }, 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( private mapMovies(
@ -848,6 +919,7 @@ export class ViewerService {
backdropUrl: string | null; backdropUrl: string | null;
mediaFileId: string | null; mediaFileId: string | null;
}>, }>,
offset: skip,
}; };
} }
@ -867,6 +939,94 @@ export class ViewerService {
backdropUrl: s.backdropUrl, backdropUrl: s.backdropUrl,
mediaFileId: null as string | null, 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", kind: "MOVIE" | "SERIES",
skip: number, skip: number,
take: number, take: number,
kidsMode = false kidsMode = false,
opts: { sort?: "title" | "recent"; letter?: string } = {}
) { ) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
const sort = opts.sort ?? "title";
const letter = opts.letter?.trim().toUpperCase();
if (kind === "MOVIE") { 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 }>>` const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
FROM media_files mf FROM media_files mf
@ -909,7 +1094,22 @@ export class ViewerService {
${kidsMovie} ${kidsMovie}
`; `;
const total = Number(countRow?.count ?? 0); const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` 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 SELECT mf.movie_id
FROM media_files mf FROM media_files mf
WHERE mf.shelf_id = ${shelfId} WHERE mf.shelf_id = ${shelfId}
@ -918,11 +1118,11 @@ export class ViewerService {
${kidsMovie} ${kidsMovie}
GROUP BY mf.movie_id GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip} OFFSET ${offset}
LIMIT ${take} LIMIT ${take}
`; `;
const ids = idRows.map((r) => r.movie_id); 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({ const movies = await prisma.movie.findMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
@ -949,9 +1149,32 @@ export class ViewerService {
backdropUrl: m.backdropUrl, backdropUrl: m.backdropUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null, 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 }>>` const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT e.series_id)::bigint AS count SELECT COUNT(DISTINCT e.series_id)::bigint AS count
FROM media_files mf FROM media_files mf
@ -962,7 +1185,23 @@ export class ViewerService {
${kidsSeries} ${kidsSeries}
`; `;
const total = Number(countRow?.count ?? 0); const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>` 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 SELECT e.series_id
FROM media_files mf FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN episodes e ON e.id = mf.episode_id
@ -972,11 +1211,11 @@ export class ViewerService {
${kidsSeries} ${kidsSeries}
GROUP BY e.series_id GROUP BY e.series_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip} OFFSET ${offset}
LIMIT ${take} LIMIT ${take}
`; `;
const ids = idRows.map((r) => r.series_id); 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 series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s])); const byId = new Map(series.map((s) => [s.id, s]));
@ -994,6 +1233,7 @@ export class ViewerService {
backdropUrl: s.backdropUrl, backdropUrl: s.backdropUrl,
mediaFileId: null as string | null, mediaFileId: null as string | null,
})), })),
offset,
}; };
} }
@ -1243,9 +1483,12 @@ export class ViewerService {
skip: number, skip: number,
take: number, take: number,
profileId?: string, profileId?: string,
kidsMode = false kidsMode = false,
opts: { sort?: "title" | "recent"; letter?: string } = {}
) { ) {
const id = catalogId.trim().toLowerCase(); 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 (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401); if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
@ -1255,34 +1498,49 @@ export class ViewerService {
title: "Verder kijken", title: "Verder kijken",
kind: "MIXED" as const, kind: "MIXED" as const,
total: all.length, total: all.length,
offset: skip,
items: all.slice(skip, skip + take), items: all.slice(skip, skip + take),
}; };
} }
if (id === "movies" || id === "films") { 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 { return {
id, id,
title: "Films", title: "Films",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }
if (id === "series" || id === "shows") { 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 { return {
id, id,
title: "Series", title: "Series",
kind: "SERIES" as const, kind: "SERIES" as const,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }
if (id === "recent-movies") { if (id === "recent-movies") {
const page = await this.moviesByRecency(skip, take, Prisma.empty, { 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, cap: RECENT_CATALOG_CAP,
kidsMode, kidsMode,
}); });
@ -1291,12 +1549,17 @@ export class ViewerService {
title: "Recent toegevoegd", title: "Recent toegevoegd",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }
if (id === "recent-series") { if (id === "recent-series") {
const page = await this.seriesByRecency(skip, take, { const sort = requestedSort ?? "recent";
const page =
sort === "title"
? await this.seriesByTitle(skip, take, { kidsMode, letter })
: await this.seriesByRecency(skip, take, {
cap: RECENT_CATALOG_CAP, cap: RECENT_CATALOG_CAP,
kidsMode, kidsMode,
}); });
@ -1305,26 +1568,31 @@ export class ViewerService {
title: "Recent toegevoegde series", title: "Recent toegevoegde series",
kind: "SERIES" as const, kind: "SERIES" as const,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }
if (id === "4k-movies" || id === "4k") { if (id === "4k-movies" || id === "4k") {
const page = await this.moviesByRecency( const fourKSql = Prisma.sql`AND (
skip,
take,
Prisma.sql`AND (
mf.resolution ILIKE '%2160%' mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%' 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 { return {
id, id,
title: "4K films", title: "4K films",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }
@ -1339,18 +1607,21 @@ export class ViewerService {
})) || null; })) || null;
if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404); if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404);
const sort = requestedSort ?? "title";
const page = await this.shelfItemsPage( const page = await this.shelfItemsPage(
shelf.id, shelf.id,
shelf.kind as "MOVIE" | "SERIES", shelf.kind as "MOVIE" | "SERIES",
skip, skip,
take, take,
kidsMode kidsMode,
{ sort, letter }
); );
return { return {
id: shelf.slug, id: shelf.slug,
title: shelf.name, title: shelf.name,
kind: shelf.kind, kind: shelf.kind,
total: page.total, total: page.total,
offset: page.offset ?? skip,
items: page.items, items: page.items,
}; };
} }