From 1b0d5a9f8de6277e20e8eef26a8d3a77fa0b8f76 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 31 Aug 2026 02:06:24 +0200 Subject: [PATCH] Netflix-style TV nav: Films/Series tabs, search, browse-all catalogs. --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 29 ++ .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 150 ++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 332 +++++++++++------- .../vonas/mediacluster/tv/ui/SearchScreen.kt | 147 ++++++++ .../mediacluster/tv/ui/components/TopNav.kt | 55 +++ apps/master-api/src/viewer/routes.ts | 18 + apps/master-api/src/viewer/service.ts | 164 +++++++++ 8 files changed, 762 insertions(+), 137 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index c096c2f..cb325c8 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 4 - versionName = "0.4.0" + versionCode = 5 + versionName = "0.5.0" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") } 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 0981a00..1e16a33 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 @@ -77,6 +77,27 @@ class ApiClient( } } + suspend fun search(query: String): List = withContext(Dispatchers.IO) { + val q = java.net.URLEncoder.encode(query, "UTF-8") + val req = authed("$baseUrl/api/v1/client/search?q=$q&take=60").get().build() + parseObject(execute(req)) { o -> o.optJSONArray("results").toItems() } + } + + suspend fun browse(catalogId: String, skip: Int = 0, take: Int = 60): 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() + parseObject(execute(req)) { o -> + BrowsePayload( + id = o.optString("id"), + title = o.optString("title"), + kind = o.optString("kind"), + total = o.optInt("total"), + items = o.optJSONArray("items").toItems(), + ) + } + } + suspend fun movie(id: String): JSONObject = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/movies/$id").get().build() JSONObject(execute(req)) @@ -217,6 +238,14 @@ sealed class PollResult { data class Linked(val refreshToken: String, val viewerName: String) : PollResult() } +data class BrowsePayload( + val id: String, + val title: String, + val kind: String, + val total: Int, + val items: List, +) + data class HomePayload( val continueWatching: List, val shelves: List, 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 new file mode 100644 index 0000000..2b1d34c --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -0,0 +1,150 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun BrowseScreen( + api: ApiClient, + catalogId: String, + titleHint: String? = null, + onBack: (() -> Unit)? = null, + onOpenMovie: (String) -> Unit, + onOpenSeries: (String) -> Unit, +) { + var title by remember { mutableStateOf(titleHint ?: "Bibliotheek") } + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableStateOf(0) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var loadingMore by remember { mutableStateOf(false) } + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + + suspend fun load(reset: Boolean) { + if (reset) { + loading = true + error = null + } else { + loadingMore = true + } + try { + val skip = if (reset) 0 else items.size + val page = api.browse(catalogId, skip = skip, take = 60) + title = page.title.ifBlank { titleHint ?: catalogId } + total = page.total + items = if (reset) page.items else items + page.items.filter { n -> items.none { it.id == n.id } } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + loadingMore = false + } + } + + LaunchedEffect(catalogId) { load(reset = true) } + + LaunchedEffect(gridState, items.size, total) { + snapshotFlow { + val info = gridState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0 + last >= items.lastIndex - 6 && items.isNotEmpty() + } + .distinctUntilChanged() + .collect { nearEnd -> + if (nearEnd && !loadingMore && !loading && items.size < total) { + scope.launch { load(reset = false) } + } + } + } + + if (onBack != null) { + BackHandler(onBack = onBack) + } + + Column( + Modifier + .fillMaxSize() + .background(McColors.Bg) + .padding(horizontal = 36.dp, vertical = 20.dp), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Text( + if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten", + color = McColors.TextDim, + fontSize = 14.sp, + ) + } + if (onBack != null) { + McButton("Terug", onClick = onBack) + } + } + Spacer(Modifier.height(16.dp)) + if (error != null) { + Text(error!!, color = McColors.Danger) + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + state = gridState, + contentPadding = PaddingValues(bottom = 40.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(items, key = { "${it.type}:${it.id}" }) { item -> + PosterCard( + item = item, + width = 140.dp, + height = 210.dp, + onClick = { + if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) + }, + ) + } + } + } +} 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 34eddf4..04c6ed8 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 @@ -1,6 +1,7 @@ package nl.vonas.mediacluster.tv.ui import android.os.Build +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +30,8 @@ import androidx.compose.runtime.setValue 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -55,8 +58,10 @@ import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.PollResult import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ShelfPref +import nl.vonas.mediacluster.tv.ui.components.MainTab import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.TopNavBar import nl.vonas.mediacluster.tv.ui.theme.McColors @Composable @@ -82,25 +87,27 @@ fun McNav() { return } - NavHost(navController = nav, startDestination = if (authed) "home" else "link") { + NavHost(navController = nav, startDestination = if (authed) "main" else "link") { composable("link") { LinkScreen(api, store) { authed = true - nav.navigate("home") { popUpTo("link") { inclusive = true } } + nav.navigate("main") { popUpTo("link") { inclusive = true } } } } - composable("home") { - HomeScreen( + composable("main") { + MainHub( api = api, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, - onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs, _title -> + onBrowse = { catalogId, _title -> + nav.navigate("browse/$catalogId") + }, + onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs -> val m = movieId?.ifBlank { null } ?: "-" val e = episodeId?.ifBlank { null } ?: "-" val s = seriesId?.ifBlank { null } ?: "-" nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs") }, - onSettings = { nav.navigate("settings") }, onLogout = { scope.launch { store.setToken(null) @@ -111,13 +118,26 @@ fun McNav() { }, ) } + composable( + "browse/{catalogId}", + arguments = listOf(navArgument("catalogId") { type = NavType.StringType }), + ) { entry -> + val catalogId = entry.arguments?.getString("catalogId").orEmpty() + BrowseScreen( + api = api, + catalogId = catalogId, + onBack = { nav.popBackStack() }, + onOpenMovie = { nav.navigate("movie/$it") }, + onOpenSeries = { nav.navigate("series/$it") }, + ) + } composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> val id = entry.arguments?.getString("id") ?: return@composable MovieDetailScreen( api = api, movieId = id, onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, title, startMs -> + onPlay = { mediaFileId, _, startMs -> nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") }, ) @@ -128,7 +148,7 @@ fun McNav() { api = api, seriesId = id, onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, episodeId, _title -> + onPlay = { mediaFileId, episodeId, _ -> nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") }, ) @@ -156,8 +176,81 @@ fun McNav() { onBack = { nav.popBackStack() }, ) } - composable("settings") { - SettingsScreen(api = api, onBack = { nav.popBackStack() }) + } +} + +@Composable +private fun MainHub( + api: ApiClient, + onOpenMovie: (String) -> Unit, + onOpenSeries: (String) -> Unit, + onBrowse: (catalogId: String, title: String) -> Unit, + onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, + onLogout: () -> Unit, +) { + var tab by remember { mutableStateOf(MainTab.Home) } + var navFocused by remember { mutableStateOf(false) } + val navFocus = remember { FocusRequester() } + + // Netflix-achtig: Back brengt je naar de top-nav + BackHandler(enabled = !navFocused && tab != MainTab.Home) { + tab = MainTab.Home + navFocused = true + } + BackHandler(enabled = !navFocused && tab == MainTab.Home) { + navFocused = true + runCatching { navFocus.requestFocus() } + } + + LaunchedEffect(navFocused) { + if (navFocused) { + delay(40) + runCatching { navFocus.requestFocus() } + } + } + + Column(Modifier.fillMaxSize().background(McColors.Bg)) { + Box(Modifier.focusRequester(navFocus)) { + TopNavBar( + selected = tab, + onSelect = { + tab = it + navFocused = true + }, + ) + } + when (tab) { + MainTab.Home -> HomeScreen( + api = api, + onOpenMovie = onOpenMovie, + onOpenSeries = onOpenSeries, + onSeeAll = onBrowse, + onPlay = onPlay, + ) + MainTab.Movies -> BrowseScreen( + api = api, + catalogId = "movies", + titleHint = "Films", + onOpenMovie = onOpenMovie, + onOpenSeries = onOpenSeries, + ) + MainTab.Series -> BrowseScreen( + api = api, + catalogId = "series", + titleHint = "Series", + onOpenMovie = onOpenMovie, + onOpenSeries = onOpenSeries, + ) + MainTab.Search -> SearchScreen( + api = api, + onOpenMovie = onOpenMovie, + onOpenSeries = onOpenSeries, + ) + MainTab.Settings -> SettingsScreen( + api = api, + onBack = { tab = MainTab.Home }, + onLogout = onLogout, + ) } } } @@ -165,10 +258,7 @@ fun McNav() { @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Splash() { - Box( - Modifier.fillMaxSize().background(McColors.Bg), - contentAlignment = Alignment.Center, - ) { + Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp) Spacer(Modifier.height(8.dp)) @@ -224,12 +314,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit Box( Modifier .fillMaxSize() - .background( - Brush.radialGradient( - colors = listOf(Color(0xFF12203A), McColors.Bg), - radius = 900f, - ), - ), + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)), contentAlignment = Alignment.Center, ) { Column( @@ -242,23 +327,10 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit .padding(40.dp), ) { Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) - Text("Koppel dit apparaat", color = McColors.Text, fontSize = 32.sp, fontWeight = FontWeight.Bold) - Text( - "Open de link hieronder op je telefoon of computer, log in en voer de code in.", - color = McColors.TextDim, - fontSize = 15.sp, - ) - Spacer(Modifier.height(8.dp)) - Text( - code ?: "····-····", - color = McColors.Accent, - fontSize = 52.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 3.sp, - ) - if (linkUrl.isNotBlank()) { - Text(linkUrl, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium) - } + Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) + Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp) + Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) + if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) } @@ -271,35 +343,27 @@ private fun HomeScreen( api: ApiClient, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, - onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long, title: String) -> Unit, - onSettings: () -> Unit, - onLogout: () -> Unit, + onSeeAll: (catalogId: String, title: String) -> Unit, + onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, ) { var home by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } - var loading by remember { mutableStateOf(true) } - suspend fun reload() { - loading = true + LaunchedEffect(Unit) { try { home = api.home() - error = null } catch (e: Exception) { error = e.message - } finally { - loading = false } } - LaunchedEffect(Unit) { reload() } - val hero = home?.continueWatching?.firstOrNull() ?: home?.shelves?.firstOrNull()?.items?.firstOrNull() LazyColumn( - Modifier.fillMaxSize().background(McColors.Bg), + Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 48.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), + verticalArrangement = Arrangement.spacedBy(26.dp), ) { item { HeroHeader( @@ -314,7 +378,6 @@ private fun HomeScreen( null, if (h.type == "episode") "EPISODE" else "MOVIE", h.positionMs, - h.title, ) h.type == "movie" -> onOpenMovie(h.id) else -> onOpenSeries(h.id) @@ -324,44 +387,48 @@ private fun HomeScreen( val h = hero ?: return@HeroHeader if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id) }, - onSettings = onSettings, - onLogout = onLogout, ) } if (error != null) { - item { - Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) - } - } - if (loading && home == null) { - item { - Text("Bibliotheek laden…", color = McColors.TextDim, modifier = Modifier.padding(40.dp)) - } + item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } } val continueItems = home?.continueWatching.orEmpty() if (continueItems.isNotEmpty()) { item { - ShelfBlock("Verder kijken", continueItems) { item -> - when { - !item.mediaFileId.isNullOrBlank() -> onPlay( - item.mediaFileId!!, - item.id.takeIf { item.type == "movie" }, - item.id.takeIf { item.type == "episode" }, - null, - if (item.type == "episode") "EPISODE" else "MOVIE", - item.positionMs, - item.title, - ) - item.type == "movie" -> onOpenMovie(item.id) - else -> onOpenSeries(item.id) - } - } + ShelfBlock( + title = "Verder kijken", + items = continueItems, + onSeeAll = null, + onClick = { item -> + when { + !item.mediaFileId.isNullOrBlank() -> onPlay( + item.mediaFileId!!, + item.id.takeIf { item.type == "movie" }, + item.id.takeIf { item.type == "episode" }, + null, + if (item.type == "episode") "EPISODE" else "MOVIE", + item.positionMs, + ) + item.type == "movie" -> onOpenMovie(item.id) + else -> onOpenSeries(item.id) + } + }, + ) } } items(home?.shelves.orEmpty()) { shelf -> - ShelfBlock(shelf.title, shelf.items) { item -> - if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) + val catalogId = when (shelf.slug) { + "recent-movies", "recent-series", "4k-movies" -> shelf.slug + else -> shelf.slug.ifBlank { shelf.id } } + ShelfBlock( + title = shelf.title, + items = shelf.items, + onSeeAll = { onSeeAll(catalogId, shelf.title) }, + onClick = { item -> + if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) + }, + ) } } } @@ -372,14 +439,8 @@ private fun HeroHeader( hero: MediaItem?, onPlayHero: () -> Unit, onOpenHero: () -> Unit, - onSettings: () -> Unit, - onLogout: () -> Unit, ) { - Box( - Modifier - .fillMaxWidth() - .height(420.dp), - ) { + Box(Modifier.fillMaxWidth().height(420.dp)) { AsyncImage( model = hero?.backdropUrl ?: hero?.posterUrl, contentDescription = null, @@ -388,41 +449,35 @@ private fun HeroHeader( ) Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) Box( - Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - listOf(McColors.Bg, Color.Transparent, Color.Transparent), - ), - ), + Modifier.fillMaxSize().background( + Brush.horizontalGradient(listOf(McColors.Bg, Color.Transparent, Color.Transparent)), + ), ) Column( Modifier .align(Alignment.BottomStart) - .fillMaxWidth(0.55f) + .fillMaxWidth(0.58f) .padding(start = 40.dp, end = 24.dp, bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) Text( hero?.title ?: "Welkom terug", - color = McColors.Text, - fontSize = 40.sp, + color = Color.White, + fontSize = 42.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, - lineHeight = 44.sp, + lineHeight = 46.sp, ) if (!hero?.subtitle.isNullOrBlank()) { Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (hero != null) { - McButton(if ((hero.positionMs) > 30_000) "Verder kijken" else "Afspelen", primary = true, onClick = onPlayHero) + McButton(if (hero.positionMs > 30_000) "Verder kijken" else "Afspelen", primary = true, onClick = onPlayHero) McButton("Details", onClick = onOpenHero) } - McButton("Planken", onClick = onSettings) - McButton("Uitloggen", onClick = onLogout) } } } @@ -430,15 +485,29 @@ private fun HeroHeader( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ShelfBlock(title: String, items: List, onClick: (MediaItem) -> Unit) { - Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - title.uppercase(), - color = Color(0xFFE8EEF8), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.2.sp, - ) +private fun ShelfBlock( + title: String, + items: List, + onSeeAll: (() -> Unit)?, + onClick: (MediaItem) -> Unit, +) { + Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + Modifier.fillMaxWidth().padding(end = 40.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title.uppercase(), + color = Color(0xFFE8EEF8), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.1.sp, + ) + if (onSeeAll != null) { + McButton("Alles bekijken") { onSeeAll() } + } + } LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(end = 40.dp), @@ -483,9 +552,7 @@ private fun MovieDetailScreen( f.optString("hdrType").ifBlank { null }?.let(::add) f.optString("videoCodec").ifBlank { null }?.let(::add) f.optString("audioCodec").ifBlank { null }?.let(::add) - f.optString("releaseName").ifBlank { null }?.let { rn -> - if (isEmpty()) add(rn.take(42)) - } + f.optString("releaseName").ifBlank { null }?.let { if (isEmpty()) add(it.take(42)) } f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) } }.joinToString(" · ").ifBlank { "Versie ${i + 1}" } list += f.getString("id") to label @@ -497,24 +564,20 @@ private fun MovieDetailScreen( } } + BackHandler(onBack = onBack) Box(Modifier.fillMaxSize().background(McColors.Bg)) { AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent)))) Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg)))) Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(0.55f) - .padding(40.dp), + Modifier.fillMaxHeight().fillMaxWidth(0.58f).padding(40.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { McButton("Terug", onClick = onBack) - Spacer(Modifier.height(8.dp)) - Text(title, color = McColors.Text, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3) + Text(title, color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3) if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp) Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp) if (error != null) Text(error!!, color = McColors.Danger) - Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { files.firstOrNull()?.let { (id, _) -> McButton("Afspelen", primary = true) { onPlay(id, title, 0) } @@ -527,12 +590,10 @@ private fun MovieDetailScreen( } } if (files.isNotEmpty()) { - Text("Versies", color = Color(0xFFF5F7FB), fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text("Versies", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { items(files) { (id, label) -> - McButton(label.take(48), primary = id == files.first().first) { - onPlay(id, title, 0) - } + McButton(label.take(48), primary = id == files.first().first) { onPlay(id, title, 0) } } } } @@ -581,22 +642,19 @@ private fun SeriesDetailScreen( } } + BackHandler(onBack = onBack) Box(Modifier.fillMaxSize().background(McColors.Bg)) { AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f) Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) - LazyColumn( - contentPadding = PaddingValues(40.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + LazyColumn(contentPadding = PaddingValues(40.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { item { McButton("Terug", onClick = onBack) Spacer(Modifier.height(12.dp)) - Text(title, color = McColors.Text, fontSize = 34.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(6.dp)) + Text(title, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold) Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis) if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(16.dp)) - Text("Afleveringen", color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text("Afleveringen", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) } items(episodes) { (id, label, fileId) -> McButton(label) { onPlay(fileId, id, "$title · $label") } @@ -607,7 +665,11 @@ private fun SeriesDetailScreen( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) { +private fun SettingsScreen( + api: ApiClient, + onBack: () -> Unit, + onLogout: () -> Unit = {}, +) { var prefs by remember { mutableStateOf>(emptyList()) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -636,12 +698,12 @@ private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - McButton("Terug", onClick = onBack) - Spacer(Modifier.height(12.dp)) - Text("Planken", color = McColors.Text, fontSize = 30.sp, fontWeight = FontWeight.Bold) - Text("Kies wat je op home ziet en in welke volgorde.", color = McColors.TextDim, fontSize = 15.sp) + Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) + Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp) if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(8.dp)) + McButton("Uitloggen", onClick = onLogout) + Spacer(Modifier.height(8.dp)) } itemsIndexed(prefs) { index, p -> Row( @@ -653,7 +715,7 @@ private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(p.name, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { McButton("↑") { if (index > 0) { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt new file mode 100644 index 0000000..0a60e43 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt @@ -0,0 +1,147 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +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.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SearchScreen( + api: ApiClient, + onOpenMovie: (String) -> Unit, + onOpenSeries: (String) -> Unit, +) { + var query by remember { mutableStateOf("") } + var results by remember { mutableStateOf>(emptyList()) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + var searchJob by remember { mutableStateOf(null) } + + fun scheduleSearch(next: String) { + query = next + searchJob?.cancel() + searchJob = scope.launch { + delay(250) + if (next.isBlank()) { + results = emptyList() + return@launch + } + busy = true + error = null + try { + results = api.search(next) + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + + val rows = listOf( + "ABCDEFGHIJ", + "KLMNOPQRST", + "UVWXYZ1234", + "567890", + ) + + Column( + Modifier + .fillMaxSize() + .background(McColors.Bg) + .padding(36.dp), + ) { + Text("Zoeken", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text( + if (query.isBlank()) "Typ een titel…" else query, + color = if (query.isBlank()) McColors.TextDim else McColors.Accent, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(16.dp)) + + rows.forEach { row -> + Row( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { ch -> + McButton(ch.toString()) { scheduleSearch(query + ch) } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + McButton("Spatie") { scheduleSearch("$query ") } + McButton("Wis") { + if (query.isNotEmpty()) scheduleSearch(query.dropLast(1)) + } + McButton("Leeg") { scheduleSearch("") } + } + + Spacer(Modifier.height(20.dp)) + Text( + when { + busy -> "Zoeken…" + error != null -> error!! + query.isBlank() -> "Resultaten verschijnen hier" + results.isEmpty() -> "Niets gevonden" + else -> "${results.size} resultaten" + }, + color = if (error != null) McColors.Danger else McColors.TextDim, + fontSize = 14.sp, + ) + Spacer(Modifier.height(12.dp)) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(bottom = 40.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(results, key = { "${it.type}:${it.id}" }) { item -> + PosterCard( + item = item, + width = 140.dp, + height = 210.dp, + onClick = { + if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) + }, + ) + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt new file mode 100644 index 0000000..8aaa07f --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -0,0 +1,55 @@ +package nl.vonas.mediacluster.tv.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import nl.vonas.mediacluster.tv.ui.theme.McColors + +enum class MainTab(val label: String) { + Home("Home"), + Movies("Films"), + Series("Series"), + Search("Zoeken"), + Settings("Instellingen"), +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun TopNavBar( + selected: MainTab, + onSelect: (MainTab) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .padding(horizontal = 28.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "MC", + color = McColors.Accent, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(end = 12.dp), + ) + MainTab.entries.forEach { tab -> + McButton( + label = tab.label, + primary = selected == tab, + onClick = { onSelect(tab) }, + ) + } + } +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index bd1a0da..221c32f 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -169,6 +169,24 @@ export function registerViewerRoutes(app: FastifyInstance, config: Config) { return viewers.dismissContinue(auth.viewerId, id); }); + app.get("/api/v1/client/search", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const q = request.query as { q?: string; take?: string }; + const query = (q.q || "").trim(); + if (query.length < 1) return { results: [] }; + const take = Math.min(parseInt(q.take || "48", 10) || 48, 100); + return { results: await viewers.search(query, take) }; + }); + + app.get("/api/v1/client/catalog/:id", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const q = request.query as { skip?: string; take?: string }; + const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0); + const take = Math.min(parseInt(q.take || "60", 10) || 60, 100); + return viewers.browseCatalog(id, skip, take); + }); + app.get("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); return { shelves: await viewers.getShelfPrefs(auth.viewerId) }; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 96af123..636f5f7 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -687,6 +687,170 @@ export class ViewerService { return { ok: true }; } + async search(query: string, take: number) { + const q = query.trim(); + if (!q) return []; + const [movies, series] = await Promise.all([ + prisma.movie.findMany({ + where: { + title: { contains: q, mode: "insensitive" }, + mediaFiles: { some: { available: true } }, + }, + orderBy: { title: "asc" }, + take, + include: { + mediaFiles: { + where: { available: true }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }), + prisma.series.findMany({ + where: { + title: { contains: q, mode: "insensitive" }, + episodes: { some: { mediaFiles: { some: { available: true } } } }, + }, + orderBy: { title: "asc" }, + take, + }), + ]); + const movieItems = movies.map((m) => ({ + type: "movie" as const, + id: m.id, + title: m.title, + year: m.year, + posterUrl: m.posterUrl, + backdropUrl: m.backdropUrl, + mediaFileId: m.mediaFiles[0]?.id ?? null, + })); + const seriesItems = series.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, + })); + return [...movieItems, ...seriesItems] + .sort((a, b) => a.title.localeCompare(b.title, "nl")) + .slice(0, take); + } + + async browseCatalog(catalogId: string, skip: number, take: number) { + const id = catalogId.trim().toLowerCase(); + + if (id === "movies" || id === "films") { + const total = await prisma.movie.count({ + where: { mediaFiles: { some: { available: true } } }, + }); + const movies = await prisma.movie.findMany({ + where: { mediaFiles: { some: { available: true } } }, + orderBy: [{ title: "asc" }, { year: "asc" }], + skip, + take, + include: { + mediaFiles: { where: { available: true }, orderBy: { createdAt: "desc" }, take: 1 }, + }, + }); + return { + id, + title: "Films", + kind: "MOVIE" as const, + total, + items: movies.map((m) => ({ + type: "movie" as const, + id: m.id, + title: m.title, + year: m.year, + posterUrl: m.posterUrl, + backdropUrl: m.backdropUrl, + mediaFileId: m.mediaFiles[0]?.id ?? null, + })), + }; + } + + if (id === "series" || id === "shows") { + const total = await prisma.series.count({ + where: { episodes: { some: { mediaFiles: { some: { available: true } } } } }, + }); + const series = await prisma.series.findMany({ + where: { episodes: { some: { mediaFiles: { some: { available: true } } } } }, + orderBy: [{ title: "asc" }, { year: "asc" }], + skip, + take, + }); + return { + id, + title: "Series", + kind: "SERIES" as const, + total, + items: series.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, + })), + }; + } + + if (id === "recent-movies") { + const items = await this.recentMovies(skip + take); + return { + id, + title: "Recent toegevoegd", + kind: "MOVIE" as const, + total: items.length + skip, + items: items.slice(skip, skip + take), + }; + } + + if (id === "recent-series") { + const items = await this.recentSeries(skip + take); + return { + id, + title: "Recent toegevoegde series", + kind: "SERIES" as const, + total: items.length + skip, + items: items.slice(skip, skip + take), + }; + } + + if (id === "4k-movies" || id === "4k") { + const items = await this.fourKMovies(skip + take); + return { + id, + title: "4K films", + kind: "MOVIE" as const, + total: items.length + skip, + items: items.slice(skip, skip + take), + }; + } + + // shelf by slug or id + const shelf = + (await prisma.libraryShelf.findFirst({ + where: { + OR: [{ slug: id.replace(/^shelf-/, "") }, { id }], + enabled: true, + }, + })) || null; + if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404); + + const items = await this.shelfItems(shelf.id, shelf.kind, skip + take); + return { + id: shelf.slug, + title: shelf.name, + kind: shelf.kind, + total: items.length + skip, + items: items.slice(skip, skip + take), + }; + } + async getShelfPrefs(viewerId: string) { const shelves = await prisma.libraryShelf.findMany({ where: { enabled: true },