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"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 75
|
||||
versionName = "0.14.12"
|
||||
versionCode = 76
|
||||
versionName = "0.14.13"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -87,16 +87,28 @@ class ApiClient(
|
|||
parseObject(execute(req)) { o -> o.optJSONArray("results").toItems() }
|
||||
}
|
||||
|
||||
suspend fun browse(catalogId: String, skip: Int = 0, take: Int = 60): BrowsePayload =
|
||||
suspend fun browse(
|
||||
catalogId: String,
|
||||
skip: Int = 0,
|
||||
take: Int = 60,
|
||||
sort: String? = null,
|
||||
letter: String? = null,
|
||||
): BrowsePayload =
|
||||
withContext(Dispatchers.IO) {
|
||||
val id = java.net.URLEncoder.encode(catalogId, "UTF-8")
|
||||
val req = authed("$baseUrl/api/v1/client/catalog/$id?skip=$skip&take=$take").get().build()
|
||||
val qs = buildString {
|
||||
append("skip=$skip&take=$take")
|
||||
if (!sort.isNullOrBlank()) append("&sort=").append(java.net.URLEncoder.encode(sort, "UTF-8"))
|
||||
if (!letter.isNullOrBlank()) append("&letter=").append(java.net.URLEncoder.encode(letter, "UTF-8"))
|
||||
}
|
||||
val req = authed("$baseUrl/api/v1/client/catalog/$id?$qs").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
BrowsePayload(
|
||||
id = o.optString("id"),
|
||||
title = o.optString("title"),
|
||||
kind = o.optString("kind"),
|
||||
total = o.optInt("total"),
|
||||
offset = o.optInt("offset", skip),
|
||||
items = o.optJSONArray("items").toItems(),
|
||||
)
|
||||
}
|
||||
|
|
@ -510,6 +522,7 @@ data class BrowsePayload(
|
|||
val kind: String,
|
||||
val total: Int,
|
||||
val items: List<MediaItem>,
|
||||
val offset: Int = 0,
|
||||
)
|
||||
|
||||
data class HomePayload(
|
||||
|
|
|
|||
|
|
@ -3,21 +3,26 @@ package nl.vonas.mediacluster.tv.ui
|
|||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -25,12 +30,20 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
|
|
@ -40,6 +53,8 @@ import nl.vonas.mediacluster.tv.ui.components.McButton
|
|||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun BrowseScreen(
|
||||
|
|
@ -59,16 +74,28 @@ fun BrowseScreen(
|
|||
startMs: Long,
|
||||
) -> Unit)? = null,
|
||||
) {
|
||||
val isContinue = catalogId == "continue-watching"
|
||||
val defaultSort = when (catalogId) {
|
||||
"recent-movies", "recent-series", "4k-movies", "4k" -> "recent"
|
||||
else -> "title"
|
||||
}
|
||||
var title by remember { mutableStateOf(titleHint ?: "Bibliotheek") }
|
||||
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||
var total by remember { mutableStateOf(0) }
|
||||
var total by remember { mutableIntStateOf(0) }
|
||||
var listOffset by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loadingMore by remember { mutableStateOf(false) }
|
||||
var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
|
||||
var activeLetter by remember(catalogId) { mutableStateOf<String?>(null) }
|
||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var menuReady by remember { mutableStateOf(false) }
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
val gridState = rememberLazyGridState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val showBrowseTools = !isContinue
|
||||
|
||||
suspend fun load(reset: Boolean) {
|
||||
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
|
||||
if (reset) {
|
||||
loading = true
|
||||
error = null
|
||||
|
|
@ -76,15 +103,25 @@ fun BrowseScreen(
|
|||
loadingMore = true
|
||||
}
|
||||
try {
|
||||
val skip = if (reset) 0 else items.size
|
||||
val page = api.browse(catalogId, skip = skip, take = 60)
|
||||
// Alleen bij letter-jump letter meesturen; bij sort/catalog-reset geen letter.
|
||||
val letterForApi = if (reset && jumpLetter != null) jumpLetter else null
|
||||
val skip = if (reset) 0 else listOffset + items.size
|
||||
val page = api.browse(
|
||||
catalogId,
|
||||
skip = skip,
|
||||
take = 60,
|
||||
sort = if (isContinue) null else sort,
|
||||
letter = letterForApi,
|
||||
)
|
||||
title = page.title.ifBlank { titleHint ?: catalogId }
|
||||
total = page.total
|
||||
items = if (reset) {
|
||||
page.items
|
||||
if (reset) {
|
||||
listOffset = page.offset
|
||||
items = page.items
|
||||
activeLetter = jumpLetter
|
||||
} else {
|
||||
val existing = items.map { "${it.type}:${it.id}" }.toSet()
|
||||
items + page.items.filter { "${it.type}:${it.id}" !in existing }
|
||||
val existing = items.map { "${it.type}:${it.id}:${it.progressId}" }.toSet()
|
||||
items = items + page.items.filter { "${it.type}:${it.id}:${it.progressId}" !in existing }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
|
|
@ -94,9 +131,12 @@ fun BrowseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(catalogId) { load(reset = true) }
|
||||
LaunchedEffect(catalogId, sort) {
|
||||
load(reset = true, jumpLetter = null)
|
||||
gridState.scrollToItem(0)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState, items.size, total) {
|
||||
LaunchedEffect(gridState, items.size, total, listOffset) {
|
||||
snapshotFlow {
|
||||
val info = gridState.layoutInfo
|
||||
val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
|
|
@ -104,14 +144,25 @@ fun BrowseScreen(
|
|||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { nearEnd ->
|
||||
if (nearEnd && !loadingMore && !loading && items.size < total) {
|
||||
if (nearEnd && !loadingMore && !loading && listOffset + items.size < total) {
|
||||
scope.launch { load(reset = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(continueMenu) {
|
||||
if (continueMenu != null) {
|
||||
menuReady = false
|
||||
delay(80)
|
||||
menuReady = true
|
||||
runCatching { menuFocus.requestFocus() }
|
||||
} else {
|
||||
menuReady = false
|
||||
}
|
||||
}
|
||||
|
||||
val handleBack = onBack != null || onAtTopBack != null
|
||||
if (handleBack) {
|
||||
if (handleBack && continueMenu == null) {
|
||||
BackHandler {
|
||||
val scrolled = gridState.firstVisibleItemIndex > 0 ||
|
||||
gridState.firstVisibleItemScrollOffset > 0
|
||||
|
|
@ -125,88 +176,245 @@ fun BrowseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(McColors.Bg)
|
||||
.padding(horizontal = 36.dp, vertical = 20.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
when {
|
||||
total > 0 -> "$total titels"
|
||||
loading -> "Laden…"
|
||||
else -> "Geen titels"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
when {
|
||||
total > 0 -> "$total titels"
|
||||
loading -> "Laden…"
|
||||
else -> "Geen titels"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
if (onBack != null) {
|
||||
McButton("Terug", onClick = onBack)
|
||||
}
|
||||
}
|
||||
if (onBack != null) {
|
||||
McButton("Terug", onClick = onBack)
|
||||
|
||||
if (showBrowseTools) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SortChip(
|
||||
label = "A–Z",
|
||||
selected = sort == "title",
|
||||
onClick = { if (sort != "title") sort = "title" },
|
||||
)
|
||||
SortChip(
|
||||
label = "Recent",
|
||||
selected = sort == "recent",
|
||||
onClick = {
|
||||
if (sort != "recent") {
|
||||
activeLetter = null
|
||||
sort = "recent"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (error != null) {
|
||||
Text(error!!, color = McColors.Danger)
|
||||
}
|
||||
when {
|
||||
loading && items.isEmpty() -> {
|
||||
EmptyState(title = "Laden…", subtitle = "Even geduld.")
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (error != null) {
|
||||
Text(error!!, color = McColors.Danger)
|
||||
}
|
||||
!loading && items.isEmpty() && error == null -> {
|
||||
EmptyState(
|
||||
title = "Geen titels gevonden",
|
||||
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
|
||||
actionLabel = "Opnieuw proberen",
|
||||
onAction = { scope.launch { load(reset = true) } },
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
fillWidth = true,
|
||||
modifier = Modifier.padding(6.dp),
|
||||
onClick = {
|
||||
val canResume = onPlayContinue != null &&
|
||||
!item.mediaFileId.isNullOrBlank() &&
|
||||
(item.positionMs > 0 || catalogId == "continue-watching")
|
||||
if (canResume) {
|
||||
onPlayContinue!!(
|
||||
item.mediaFileId!!,
|
||||
item.id.takeIf { item.type == "movie" },
|
||||
item.id.takeIf { item.type == "episode" },
|
||||
item.seriesId,
|
||||
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||
item.positionMs,
|
||||
)
|
||||
} else if (item.type == "movie") {
|
||||
onOpenMovie(item.id)
|
||||
} else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
|
||||
onOpenSeries(item.seriesId!!)
|
||||
when {
|
||||
loading && items.isEmpty() -> {
|
||||
EmptyState(title = "Laden…", subtitle = "Even geduld.")
|
||||
}
|
||||
!loading && items.isEmpty() && error == null -> {
|
||||
EmptyState(
|
||||
title = "Geen titels gevonden",
|
||||
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
|
||||
actionLabel = "Opnieuw proberen",
|
||||
onAction = { scope.launch { load(reset = true) } },
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
fillWidth = true,
|
||||
showCaption = isContinue,
|
||||
modifier = Modifier.padding(6.dp),
|
||||
onLongClick = if (isContinue) {
|
||||
{ continueMenu = item }
|
||||
} else {
|
||||
onOpenSeries(item.id)
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
val canResume = onPlayContinue != null &&
|
||||
!item.mediaFileId.isNullOrBlank() &&
|
||||
(item.positionMs > 0 || isContinue)
|
||||
if (canResume) {
|
||||
onPlayContinue!!(
|
||||
item.mediaFileId!!,
|
||||
item.id.takeIf { item.type == "movie" },
|
||||
item.id.takeIf { item.type == "episode" },
|
||||
item.seriesId,
|
||||
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||
item.positionMs,
|
||||
)
|
||||
} else if (item.type == "movie") {
|
||||
onOpenMovie(item.id)
|
||||
} else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
|
||||
onOpenSeries(item.seriesId!!)
|
||||
} else {
|
||||
onOpenSeries(item.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBrowseTools && sort == "title") {
|
||||
AzScrubber(
|
||||
selected = activeLetter,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 72.dp, horizontal = 6.dp),
|
||||
onLetter = { letter ->
|
||||
scope.launch {
|
||||
load(reset = true, jumpLetter = letter)
|
||||
gridState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val menuItem = continueMenu
|
||||
if (menuItem != null) {
|
||||
BackHandler { continueMenu = null }
|
||||
Dialog(
|
||||
onDismissRequest = { continueMenu = null },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(440.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||
if (!menuItem.subtitle.isNullOrBlank()) {
|
||||
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
McButton("Verwijderen uit verder kijken", focusRequester = menuFocus) {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
val pid = menuItem.progressId
|
||||
if (!pid.isNullOrBlank()) {
|
||||
runCatching { api.dismissContinue(pid) }
|
||||
.onFailure { error = it.message }
|
||||
.onSuccess {
|
||||
items = items.filterNot { it.progressId == pid }
|
||||
total = (total - 1).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
continueMenu = null
|
||||
}
|
||||
}
|
||||
McButton("Annuleren") { continueMenu = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SortChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
|
||||
focusedContainerColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = if (selected) Color.Black else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AzScrubber(
|
||||
selected: String?,
|
||||
onLetter: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.width(28.dp),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AzLetters.forEach { letter ->
|
||||
val isSelected = selected.equals(letter, ignoreCase = true)
|
||||
Surface(
|
||||
onClick = { onLetter(letter) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.25f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x44FFFFFF),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
|
|
@ -376,6 +378,8 @@ private fun MainHub(
|
|||
var homeChromeCompact by remember { mutableStateOf(false) }
|
||||
val navFocus = remember { FocusRequester() }
|
||||
val homeContentFocus = remember { FocusRequester() }
|
||||
val exitFocus = remember { FocusRequester() }
|
||||
var exitReady by remember { mutableStateOf(false) }
|
||||
val homeListState = rememberLazyListState()
|
||||
val hubScope = rememberCoroutineScope()
|
||||
val activity = LocalContext.current as? Activity
|
||||
|
|
@ -385,6 +389,17 @@ private fun MainHub(
|
|||
var movieSubId by remember { mutableStateOf("movies") }
|
||||
var seriesSubId by remember { mutableStateOf("series") }
|
||||
|
||||
LaunchedEffect(exitConfirm) {
|
||||
if (exitConfirm) {
|
||||
exitReady = false
|
||||
delay(60)
|
||||
exitReady = true
|
||||
runCatching { exitFocus.requestFocus() }
|
||||
} else {
|
||||
exitReady = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
|
||||
runCatching { iptvEnabled = api.clientMe().iptvEnabled }
|
||||
|
|
@ -657,30 +672,48 @@ private fun MainHub(
|
|||
}
|
||||
|
||||
if (exitConfirm) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
Dialog(
|
||||
onDismissRequest = { exitConfirm = false },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
Modifier
|
||||
.width(420.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Weet je zeker dat je de app wilt sluiten?",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Annuleren") { exitConfirm = false }
|
||||
McButton("Afsluiten", primary = true) { activity?.finish() }
|
||||
Column(
|
||||
Modifier
|
||||
.width(420.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Weet je zeker dat je de app wilt sluiten?",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton(
|
||||
"Annuleren",
|
||||
focusRequester = exitFocus,
|
||||
) {
|
||||
if (!exitReady) return@McButton
|
||||
exitConfirm = false
|
||||
}
|
||||
McButton("Afsluiten", primary = true) {
|
||||
if (!exitReady) return@McButton
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -883,7 +916,7 @@ private fun HomeScreen(
|
|||
LaunchedEffect(continueMenu) {
|
||||
if (continueMenu != null) {
|
||||
menuReady = false
|
||||
delay(500)
|
||||
delay(80)
|
||||
menuReady = true
|
||||
runCatching { menuFocus.requestFocus() }
|
||||
} else {
|
||||
|
|
@ -1041,53 +1074,62 @@ private fun HomeScreen(
|
|||
val menuItem = continueMenu
|
||||
if (menuItem != null) {
|
||||
BackHandler { continueMenu = null }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
Dialog(
|
||||
onDismissRequest = { continueMenu = null },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
Modifier
|
||||
.width(440.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||
if (!menuItem.subtitle.isNullOrBlank()) {
|
||||
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
McButton("Markeer als gezien") {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
runCatching {
|
||||
if (menuItem.type == "movie") {
|
||||
api.markWatched(movieId = menuItem.id)
|
||||
} else {
|
||||
api.markWatched(episodeId = menuItem.id)
|
||||
}
|
||||
reloadHome()
|
||||
}.onFailure { error = it.message }
|
||||
continueMenu = null
|
||||
Column(
|
||||
Modifier
|
||||
.width(440.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||
if (!menuItem.subtitle.isNullOrBlank()) {
|
||||
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
McButton("Verberg uit lijst") {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
val pid = menuItem.progressId
|
||||
if (!pid.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
McButton("Verwijderen uit verder kijken", focusRequester = menuFocus) {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
val pid = menuItem.progressId
|
||||
if (!pid.isNullOrBlank()) {
|
||||
runCatching {
|
||||
api.dismissContinue(pid)
|
||||
reloadHome()
|
||||
}.onFailure { error = it.message }
|
||||
}
|
||||
continueMenu = null
|
||||
}
|
||||
}
|
||||
McButton("Markeer als gezien") {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.dismissContinue(pid)
|
||||
if (menuItem.type == "movie") {
|
||||
api.markWatched(movieId = menuItem.id)
|
||||
} else {
|
||||
api.markWatched(episodeId = menuItem.id)
|
||||
}
|
||||
reloadHome()
|
||||
}.onFailure { error = it.message }
|
||||
continueMenu = null
|
||||
}
|
||||
continueMenu = null
|
||||
}
|
||||
McButton("Annuleren") { continueMenu = null }
|
||||
}
|
||||
McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,11 +114,15 @@ private fun Modifier.tvShortOrLongPress(
|
|||
return this.onPreviewKeyEvent { ev ->
|
||||
when {
|
||||
ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
|
||||
downAt = System.currentTimeMillis()
|
||||
false
|
||||
// TV-remotes sturen KeyDown-herhalingen tijdens vasthouden — niet opnieuw starten.
|
||||
if (downAt == 0L) downAt = System.currentTimeMillis()
|
||||
true
|
||||
}
|
||||
ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
|
||||
val held = System.currentTimeMillis() - downAt
|
||||
val started = downAt
|
||||
downAt = 0L
|
||||
if (started == 0L) return@onPreviewKeyEvent true
|
||||
val held = System.currentTimeMillis() - started
|
||||
if (held >= 450) onLongPress() else onClick()
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -504,15 +504,19 @@ export function registerViewerRoutes(
|
|||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
const profileId = requireProfile(auth);
|
||||
const { id } = request.params as { id: string };
|
||||
const q = request.query as { skip?: string; take?: string };
|
||||
const q = request.query as { skip?: string; take?: string; sort?: string; letter?: string };
|
||||
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
|
||||
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100);
|
||||
const sort =
|
||||
q.sort === "title" || q.sort === "recent" ? (q.sort as "title" | "recent") : undefined;
|
||||
const letter = (q.letter || "").trim().slice(0, 1) || undefined;
|
||||
return viewers.browseCatalog(
|
||||
id,
|
||||
skip,
|
||||
take,
|
||||
profileId,
|
||||
auth.profile?.isKids === true
|
||||
auth.profile?.isKids === true,
|
||||
{ sort, letter }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -775,7 +775,7 @@ export class ViewerService {
|
|||
const allIds = idRows.map((r) => r.movie_id);
|
||||
const total = allIds.length;
|
||||
const ids = allIds.slice(skip, skip + take);
|
||||
if (!ids.length) return { total, items: [] };
|
||||
if (!ids.length) return { total, items: [], offset: skip };
|
||||
|
||||
const movies = await prisma.movie.findMany({
|
||||
where: { id: { in: ids } },
|
||||
|
|
@ -787,7 +787,78 @@ export class ViewerService {
|
|||
},
|
||||
},
|
||||
});
|
||||
return { total, items: this.mapMovies(ids, movies) };
|
||||
return { total, items: this.mapMovies(ids, movies), offset: skip };
|
||||
}
|
||||
|
||||
/** Alfabetisch op titel; optioneel spring naar letter (A–Z / #). */
|
||||
private async moviesByTitle(
|
||||
skip: number,
|
||||
take: number,
|
||||
extraWhereSql: Prisma.Sql = Prisma.empty,
|
||||
opts: { kidsMode?: boolean; letter?: string } = {}
|
||||
) {
|
||||
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
|
||||
let offset = Math.max(0, skip);
|
||||
const letter = opts.letter?.trim().toUpperCase();
|
||||
if (letter && letter !== "#") {
|
||||
const L = letter.slice(0, 1);
|
||||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
INNER JOIN movies m ON m.id = mf.movie_id
|
||||
WHERE mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${extraWhereSql}
|
||||
${kidsSql}
|
||||
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
|
||||
GROUP BY mf.movie_id
|
||||
) t
|
||||
`;
|
||||
offset = Number(before?.count ?? 0);
|
||||
} else if (letter === "#") {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
WHERE mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${extraWhereSql}
|
||||
${kidsSql}
|
||||
GROUP BY mf.movie_id
|
||||
) t
|
||||
`;
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
INNER JOIN movies m ON m.id = mf.movie_id
|
||||
WHERE mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${extraWhereSql}
|
||||
${kidsSql}
|
||||
GROUP BY mf.movie_id, m.title
|
||||
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const ids = idRows.map((r) => r.movie_id);
|
||||
if (!ids.length) return { total, items: [], offset };
|
||||
|
||||
const movies = await prisma.movie.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
mediaFiles: {
|
||||
where: { available: true },
|
||||
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { total, items: this.mapMovies(ids, movies), offset };
|
||||
}
|
||||
|
||||
private mapMovies(
|
||||
|
|
@ -848,6 +919,7 @@ export class ViewerService {
|
|||
backdropUrl: string | null;
|
||||
mediaFileId: string | null;
|
||||
}>,
|
||||
offset: skip,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -867,6 +939,94 @@ export class ViewerService {
|
|||
backdropUrl: s.backdropUrl,
|
||||
mediaFileId: null as string | null,
|
||||
})),
|
||||
offset: skip,
|
||||
};
|
||||
}
|
||||
|
||||
private async seriesByTitle(
|
||||
skip: number,
|
||||
take: number,
|
||||
opts: { kidsMode?: boolean; letter?: string } = {}
|
||||
) {
|
||||
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||||
let offset = Math.max(0, skip);
|
||||
const letter = opts.letter?.trim().toUpperCase();
|
||||
if (letter && letter !== "#") {
|
||||
const L = letter.slice(0, 1);
|
||||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
INNER JOIN series s ON s.id = e.series_id
|
||||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||
${kidsSql}
|
||||
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
|
||||
GROUP BY e.series_id
|
||||
) t
|
||||
`;
|
||||
offset = Number(before?.count ?? 0);
|
||||
} else if (letter === "#") {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||
${kidsSql}
|
||||
GROUP BY e.series_id
|
||||
) t
|
||||
`;
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
INNER JOIN series s ON s.id = e.series_id
|
||||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||
${kidsSql}
|
||||
GROUP BY e.series_id, s.title
|
||||
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const ids = idRows.map((r) => r.series_id);
|
||||
if (!ids.length) {
|
||||
return {
|
||||
total,
|
||||
items: [] as Array<{
|
||||
type: "series";
|
||||
id: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
backdropUrl: string | null;
|
||||
mediaFileId: string | null;
|
||||
}>,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||||
const byId = new Map(series.map((s) => [s.id, s]));
|
||||
return {
|
||||
total,
|
||||
items: ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((s): s is NonNullable<typeof s> => !!s)
|
||||
.map((s) => ({
|
||||
type: "series" as const,
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
year: s.year,
|
||||
posterUrl: s.posterUrl,
|
||||
backdropUrl: s.backdropUrl,
|
||||
mediaFileId: null as string | null,
|
||||
})),
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -895,11 +1055,36 @@ export class ViewerService {
|
|||
kind: "MOVIE" | "SERIES",
|
||||
skip: number,
|
||||
take: number,
|
||||
kidsMode = false
|
||||
kidsMode = false,
|
||||
opts: { sort?: "title" | "recent"; letter?: string } = {}
|
||||
) {
|
||||
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
|
||||
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||||
const sort = opts.sort ?? "title";
|
||||
const letter = opts.letter?.trim().toUpperCase();
|
||||
|
||||
if (kind === "MOVIE") {
|
||||
let offset = Math.max(0, skip);
|
||||
if (sort === "title" && letter && letter !== "#") {
|
||||
const L = letter.slice(0, 1);
|
||||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
INNER JOIN movies m ON m.id = mf.movie_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${kidsMovie}
|
||||
AND UPPER(LEFT(TRIM(m.title), 1)) < ${L}
|
||||
GROUP BY mf.movie_id
|
||||
) t
|
||||
`;
|
||||
offset = Number(before?.count ?? 0);
|
||||
} else if (sort === "title" && letter === "#") {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
|
||||
FROM media_files mf
|
||||
|
|
@ -909,20 +1094,35 @@ export class ViewerService {
|
|||
${kidsMovie}
|
||||
`;
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${kidsMovie}
|
||||
GROUP BY mf.movie_id
|
||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||
OFFSET ${skip}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const idRows =
|
||||
sort === "title"
|
||||
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
INNER JOIN movies m ON m.id = mf.movie_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${kidsMovie}
|
||||
GROUP BY mf.movie_id, m.title
|
||||
ORDER BY LOWER(TRIM(m.title)) ASC, m.title ASC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`
|
||||
: await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||
SELECT mf.movie_id
|
||||
FROM media_files mf
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.movie_id IS NOT NULL
|
||||
${kidsMovie}
|
||||
GROUP BY mf.movie_id
|
||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const ids = idRows.map((r) => r.movie_id);
|
||||
if (!ids.length) return { total, items: [] };
|
||||
if (!ids.length) return { total, items: [], offset };
|
||||
|
||||
const movies = await prisma.movie.findMany({
|
||||
where: { id: { in: ids } },
|
||||
|
|
@ -949,9 +1149,32 @@ export class ViewerService {
|
|||
backdropUrl: m.backdropUrl,
|
||||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
||||
})),
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
let offset = Math.max(0, skip);
|
||||
if (sort === "title" && letter && letter !== "#") {
|
||||
const L = letter.slice(0, 1);
|
||||
const [before] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count FROM (
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
INNER JOIN series s ON s.id = e.series_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.episode_id IS NOT NULL
|
||||
${kidsSeries}
|
||||
AND UPPER(LEFT(TRIM(s.title), 1)) < ${L}
|
||||
GROUP BY e.series_id
|
||||
) t
|
||||
`;
|
||||
offset = Number(before?.count ?? 0);
|
||||
} else if (sort === "title" && letter === "#") {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
|
||||
FROM media_files mf
|
||||
|
|
@ -962,21 +1185,37 @@ export class ViewerService {
|
|||
${kidsSeries}
|
||||
`;
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.episode_id IS NOT NULL
|
||||
${kidsSeries}
|
||||
GROUP BY e.series_id
|
||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||
OFFSET ${skip}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const idRows =
|
||||
sort === "title"
|
||||
? await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
INNER JOIN series s ON s.id = e.series_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.episode_id IS NOT NULL
|
||||
${kidsSeries}
|
||||
GROUP BY e.series_id, s.title
|
||||
ORDER BY LOWER(TRIM(s.title)) ASC, s.title ASC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`
|
||||
: await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||
SELECT e.series_id
|
||||
FROM media_files mf
|
||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||
WHERE mf.shelf_id = ${shelfId}
|
||||
AND mf.available = true
|
||||
AND mf.episode_id IS NOT NULL
|
||||
${kidsSeries}
|
||||
GROUP BY e.series_id
|
||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${take}
|
||||
`;
|
||||
const ids = idRows.map((r) => r.series_id);
|
||||
if (!ids.length) return { total, items: [] };
|
||||
if (!ids.length) return { total, items: [], offset };
|
||||
|
||||
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||||
const byId = new Map(series.map((s) => [s.id, s]));
|
||||
|
|
@ -994,6 +1233,7 @@ export class ViewerService {
|
|||
backdropUrl: s.backdropUrl,
|
||||
mediaFileId: null as string | null,
|
||||
})),
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1243,9 +1483,12 @@ export class ViewerService {
|
|||
skip: number,
|
||||
take: number,
|
||||
profileId?: string,
|
||||
kidsMode = false
|
||||
kidsMode = false,
|
||||
opts: { sort?: "title" | "recent"; letter?: string } = {}
|
||||
) {
|
||||
const id = catalogId.trim().toLowerCase();
|
||||
const letter = opts.letter?.trim().toUpperCase() || undefined;
|
||||
const requestedSort = opts.sort === "recent" || opts.sort === "title" ? opts.sort : undefined;
|
||||
|
||||
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
|
||||
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
|
||||
|
|
@ -1255,76 +1498,101 @@ export class ViewerService {
|
|||
title: "Verder kijken",
|
||||
kind: "MIXED" as const,
|
||||
total: all.length,
|
||||
offset: skip,
|
||||
items: all.slice(skip, skip + take),
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "movies" || id === "films") {
|
||||
const page = await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
|
||||
const sort = requestedSort ?? "title";
|
||||
const page =
|
||||
sort === "title"
|
||||
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
|
||||
: await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
|
||||
return {
|
||||
id,
|
||||
title: "Films",
|
||||
kind: "MOVIE" as const,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "series" || id === "shows") {
|
||||
const page = await this.seriesByRecency(skip, take, { kidsMode });
|
||||
const sort = requestedSort ?? "title";
|
||||
const page =
|
||||
sort === "title"
|
||||
? await this.seriesByTitle(skip, take, { kidsMode, letter })
|
||||
: await this.seriesByRecency(skip, take, { kidsMode });
|
||||
return {
|
||||
id,
|
||||
title: "Series",
|
||||
kind: "SERIES" as const,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "recent-movies") {
|
||||
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
|
||||
cap: RECENT_CATALOG_CAP,
|
||||
kidsMode,
|
||||
});
|
||||
const sort = requestedSort ?? "recent";
|
||||
const page =
|
||||
sort === "title"
|
||||
? await this.moviesByTitle(skip, take, Prisma.empty, { kidsMode, letter })
|
||||
: await this.moviesByRecency(skip, take, Prisma.empty, {
|
||||
cap: RECENT_CATALOG_CAP,
|
||||
kidsMode,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: "Recent toegevoegd",
|
||||
kind: "MOVIE" as const,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "recent-series") {
|
||||
const page = await this.seriesByRecency(skip, take, {
|
||||
cap: RECENT_CATALOG_CAP,
|
||||
kidsMode,
|
||||
});
|
||||
const sort = requestedSort ?? "recent";
|
||||
const page =
|
||||
sort === "title"
|
||||
? await this.seriesByTitle(skip, take, { kidsMode, letter })
|
||||
: await this.seriesByRecency(skip, take, {
|
||||
cap: RECENT_CATALOG_CAP,
|
||||
kidsMode,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: "Recent toegevoegde series",
|
||||
kind: "SERIES" as const,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === "4k-movies" || id === "4k") {
|
||||
const page = await this.moviesByRecency(
|
||||
skip,
|
||||
take,
|
||||
Prisma.sql`AND (
|
||||
const fourKSql = Prisma.sql`AND (
|
||||
mf.resolution ILIKE '%2160%'
|
||||
OR mf.resolution ILIKE '%4K%'
|
||||
OR mf.resolution ILIKE '%UHD%'
|
||||
)`,
|
||||
{ cap: RECENT_CATALOG_CAP, kidsMode }
|
||||
);
|
||||
)`;
|
||||
const sort = requestedSort ?? "recent";
|
||||
const page =
|
||||
sort === "title"
|
||||
? await this.moviesByTitle(skip, take, fourKSql, { kidsMode, letter })
|
||||
: await this.moviesByRecency(skip, take, fourKSql, {
|
||||
cap: RECENT_CATALOG_CAP,
|
||||
kidsMode,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: "4K films",
|
||||
kind: "MOVIE" as const,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
|
@ -1339,18 +1607,21 @@ export class ViewerService {
|
|||
})) || null;
|
||||
if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404);
|
||||
|
||||
const sort = requestedSort ?? "title";
|
||||
const page = await this.shelfItemsPage(
|
||||
shelf.id,
|
||||
shelf.kind as "MOVIE" | "SERIES",
|
||||
skip,
|
||||
take,
|
||||
kidsMode
|
||||
kidsMode,
|
||||
{ sort, letter }
|
||||
);
|
||||
return {
|
||||
id: shelf.slug,
|
||||
title: shelf.name,
|
||||
kind: shelf.kind,
|
||||
total: page.total,
|
||||
offset: page.offset ?? skip,
|
||||
items: page.items,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue