diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 9d36ff5..07504c1 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 8e85bdd..965f1f3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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, + val offset: Int = 0, ) data class HomePayload( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 4c9a464..766a9a9 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -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>(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(null) } var loadingMore by remember { mutableStateOf(false) } + var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) } + var activeLetter by remember(catalogId) { mutableStateOf(null) } + var continueMenu by remember { mutableStateOf(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, + ) + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index f116494..8454acd 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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 } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index a2ae59d..a5feb32 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -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 } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index e164041..406b52d 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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 } ); }); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 547aa49..f12007c 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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>` + 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>` + 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>` + 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>` + 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>` + 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>` + 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 => !!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>` + 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>` 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>` - 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>` + 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>` + 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>` + 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>` 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>` - 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>` + 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>` + 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, }; }