Netflix-style TV nav: Films/Series tabs, search, browse-all catalogs.

This commit is contained in:
Jos Vooges | STH 2026-08-31 02:06:24 +02:00
parent 9e7faba240
commit 1b0d5a9f8d
8 changed files with 762 additions and 137 deletions

View file

@ -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\"")
}

View file

@ -77,6 +77,27 @@ class ApiClient(
}
}
suspend fun search(query: String): List<MediaItem> = 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<MediaItem>,
)
data class HomePayload(
val continueWatching: List<MediaItem>,
val shelves: List<ShelfRow>,

View file

@ -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<List<MediaItem>>(emptyList()) }
var total by remember { mutableStateOf(0) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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)
},
)
}
}
}
}

View file

@ -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<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(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,24 +387,19 @@ 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 ->
ShelfBlock(
title = "Verder kijken",
items = continueItems,
onSeeAll = null,
onClick = { item ->
when {
!item.mediaFileId.isNullOrBlank() -> onPlay(
item.mediaFileId!!,
@ -350,18 +408,27 @@ private fun HomeScreen(
null,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
item.title,
)
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<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
private fun ShelfBlock(
title: String,
items: List<MediaItem>,
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 = 18.sp,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
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<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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) {

View file

@ -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<List<MediaItem>>(emptyList()) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
var searchJob by remember { mutableStateOf<Job?>(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)
},
)
}
}
}
}

View file

@ -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) },
)
}
}
}

View file

@ -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) };

View file

@ -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 },