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:
parent
308160aa56
commit
00fa9cd9c7
7 changed files with 744 additions and 202 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,88 +176,245 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
Modifier
|
Column(
|
||||||
.fillMaxSize()
|
Modifier
|
||||||
.background(McColors.Bg)
|
.fillMaxSize()
|
||||||
.padding(horizontal = 36.dp, vertical = 20.dp),
|
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Row(
|
||||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
Modifier.fillMaxWidth(),
|
||||||
Text(
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
when {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
total > 0 -> "$total titels"
|
) {
|
||||||
loading -> "Laden…"
|
Column(Modifier.weight(1f)) {
|
||||||
else -> "Geen titels"
|
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||||
},
|
Text(
|
||||||
color = McColors.TextDim,
|
when {
|
||||||
fontSize = 14.sp,
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Text(error!!, color = McColors.Danger)
|
Text(error!!, color = McColors.Danger)
|
||||||
}
|
|
||||||
when {
|
|
||||||
loading && items.isEmpty() -> {
|
|
||||||
EmptyState(title = "Laden…", subtitle = "Even geduld.")
|
|
||||||
}
|
}
|
||||||
!loading && items.isEmpty() && error == null -> {
|
when {
|
||||||
EmptyState(
|
loading && items.isEmpty() -> {
|
||||||
title = "Geen titels gevonden",
|
EmptyState(title = "Laden…", subtitle = "Even geduld.")
|
||||||
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
|
}
|
||||||
actionLabel = "Opnieuw proberen",
|
!loading && items.isEmpty() && error == null -> {
|
||||||
onAction = { scope.launch { load(reset = true) } },
|
EmptyState(
|
||||||
)
|
title = "Geen titels gevonden",
|
||||||
}
|
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
|
||||||
else -> {
|
actionLabel = "Opnieuw proberen",
|
||||||
LazyVerticalGrid(
|
onAction = { scope.launch { load(reset = true) } },
|
||||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
)
|
||||||
state = gridState,
|
}
|
||||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
else -> {
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
LazyVerticalGrid(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
state = gridState,
|
||||||
) {
|
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
PosterCard(
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
item = item,
|
modifier = Modifier.fillMaxSize(),
|
||||||
fillWidth = true,
|
) {
|
||||||
modifier = Modifier.padding(6.dp),
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
onClick = {
|
PosterCard(
|
||||||
val canResume = onPlayContinue != null &&
|
item = item,
|
||||||
!item.mediaFileId.isNullOrBlank() &&
|
fillWidth = true,
|
||||||
(item.positionMs > 0 || catalogId == "continue-watching")
|
showCaption = isContinue,
|
||||||
if (canResume) {
|
modifier = Modifier.padding(6.dp),
|
||||||
onPlayContinue!!(
|
onLongClick = if (isContinue) {
|
||||||
item.mediaFileId!!,
|
{ continueMenu = item }
|
||||||
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 {
|
} 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,30 +672,48 @@ private fun MainHub(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitConfirm) {
|
if (exitConfirm) {
|
||||||
Box(
|
Dialog(
|
||||||
Modifier
|
onDismissRequest = { exitConfirm = false },
|
||||||
.fillMaxSize()
|
properties = DialogProperties(
|
||||||
.background(Color(0xCC000000)),
|
dismissOnBackPress = true,
|
||||||
contentAlignment = Alignment.Center,
|
dismissOnClickOutside = false,
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(420.dp)
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.background(Color(0xCC000000)),
|
||||||
.background(Color(0xFF1A1A1A))
|
contentAlignment = Alignment.Center,
|
||||||
.padding(28.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
Column(
|
||||||
Text(
|
Modifier
|
||||||
"Weet je zeker dat je de app wilt sluiten?",
|
.width(420.dp)
|
||||||
color = McColors.TextDim,
|
.clip(RoundedCornerShape(12.dp))
|
||||||
fontSize = 15.sp,
|
.background(Color(0xFF1A1A1A))
|
||||||
)
|
.padding(28.dp),
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
McButton("Annuleren") { exitConfirm = false }
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
McButton("Afsluiten", primary = true) { activity?.finish() }
|
) {
|
||||||
|
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) {
|
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,53 +1074,62 @@ private fun HomeScreen(
|
||||||
val menuItem = continueMenu
|
val menuItem = continueMenu
|
||||||
if (menuItem != null) {
|
if (menuItem != null) {
|
||||||
BackHandler { continueMenu = null }
|
BackHandler { continueMenu = null }
|
||||||
Box(
|
Dialog(
|
||||||
Modifier
|
onDismissRequest = { continueMenu = null },
|
||||||
.fillMaxSize()
|
properties = DialogProperties(
|
||||||
.background(Color(0xCC000000)),
|
dismissOnBackPress = true,
|
||||||
contentAlignment = Alignment.Center,
|
dismissOnClickOutside = false,
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(440.dp)
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.background(Color(0xCC000000)),
|
||||||
.background(Color(0xFF1A1A1A))
|
contentAlignment = Alignment.Center,
|
||||||
.padding(28.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
Column(
|
||||||
if (!menuItem.subtitle.isNullOrBlank()) {
|
Modifier
|
||||||
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
.width(440.dp)
|
||||||
}
|
.clip(RoundedCornerShape(12.dp))
|
||||||
Spacer(Modifier.height(4.dp))
|
.background(Color(0xFF1A1A1A))
|
||||||
McButton("Markeer als gezien") {
|
.padding(28.dp),
|
||||||
if (!menuReady) return@McButton
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
scope.launch {
|
) {
|
||||||
runCatching {
|
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||||
if (menuItem.type == "movie") {
|
if (!menuItem.subtitle.isNullOrBlank()) {
|
||||||
api.markWatched(movieId = menuItem.id)
|
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
||||||
} else {
|
|
||||||
api.markWatched(episodeId = menuItem.id)
|
|
||||||
}
|
|
||||||
reloadHome()
|
|
||||||
}.onFailure { error = it.message }
|
|
||||||
continueMenu = null
|
|
||||||
}
|
}
|
||||||
}
|
Spacer(Modifier.height(4.dp))
|
||||||
McButton("Verberg uit lijst") {
|
McButton("Verwijderen uit verder kijken", focusRequester = menuFocus) {
|
||||||
if (!menuReady) return@McButton
|
if (!menuReady) return@McButton
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val pid = menuItem.progressId
|
val pid = menuItem.progressId
|
||||||
if (!pid.isNullOrBlank()) {
|
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 {
|
runCatching {
|
||||||
api.dismissContinue(pid)
|
if (menuItem.type == "movie") {
|
||||||
|
api.markWatched(movieId = menuItem.id)
|
||||||
|
} else {
|
||||||
|
api.markWatched(episodeId = menuItem.id)
|
||||||
|
}
|
||||||
reloadHome()
|
reloadHome()
|
||||||
}.onFailure { error = it.message }
|
}.onFailure { error = it.message }
|
||||||
|
continueMenu = null
|
||||||
}
|
}
|
||||||
continueMenu = null
|
|
||||||
}
|
}
|
||||||
|
McButton("Annuleren") { continueMenu = null }
|
||||||
}
|
}
|
||||||
McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,20 +1094,35 @@ 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 =
|
||||||
SELECT mf.movie_id
|
sort === "title"
|
||||||
FROM media_files mf
|
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
SELECT mf.movie_id
|
||||||
AND mf.available = true
|
FROM media_files mf
|
||||||
AND mf.movie_id IS NOT NULL
|
INNER JOIN movies m ON m.id = mf.movie_id
|
||||||
${kidsMovie}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
GROUP BY mf.movie_id
|
AND mf.available = true
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
AND mf.movie_id IS NOT NULL
|
||||||
OFFSET ${skip}
|
${kidsMovie}
|
||||||
LIMIT ${take}
|
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);
|
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,21 +1185,37 @@ 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 =
|
||||||
SELECT e.series_id
|
sort === "title"
|
||||||
FROM media_files mf
|
? await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
SELECT e.series_id
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
FROM media_files mf
|
||||||
AND mf.available = true
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
AND mf.episode_id IS NOT NULL
|
INNER JOIN series s ON s.id = e.series_id
|
||||||
${kidsSeries}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
GROUP BY e.series_id
|
AND mf.available = true
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
AND mf.episode_id IS NOT NULL
|
||||||
OFFSET ${skip}
|
${kidsSeries}
|
||||||
LIMIT ${take}
|
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);
|
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,76 +1498,101 @@ 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";
|
||||||
cap: RECENT_CATALOG_CAP,
|
const page =
|
||||||
kidsMode,
|
sort === "title"
|
||||||
});
|
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
|
||||||
|
: await this.moviesByRecency(skip, take, Prisma.empty, {
|
||||||
|
cap: RECENT_CATALOG_CAP,
|
||||||
|
kidsMode,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
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";
|
||||||
cap: RECENT_CATALOG_CAP,
|
const page =
|
||||||
kidsMode,
|
sort === "title"
|
||||||
});
|
? await this.seriesByTitle(skip, take, { kidsMode, letter })
|
||||||
|
: await this.seriesByRecency(skip, take, {
|
||||||
|
cap: RECENT_CATALOG_CAP,
|
||||||
|
kidsMode,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue