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" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "0.4.0" versionName = "0.5.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") 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) { suspend fun movie(id: String): JSONObject = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/movies/$id").get().build() val req = authed("$baseUrl/api/v1/client/movies/$id").get().build()
JSONObject(execute(req)) JSONObject(execute(req))
@ -217,6 +238,14 @@ sealed class PollResult {
data class Linked(val refreshToken: String, val viewerName: String) : 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( data class HomePayload(
val continueWatching: List<MediaItem>, val continueWatching: List<MediaItem>,
val shelves: List<ShelfRow>, 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 package nl.vonas.mediacluster.tv.ui
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -29,6 +30,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.PollResult
import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ShelfPref 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.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
@Composable @Composable
@ -82,25 +87,27 @@ fun McNav() {
return return
} }
NavHost(navController = nav, startDestination = if (authed) "home" else "link") { NavHost(navController = nav, startDestination = if (authed) "main" else "link") {
composable("link") { composable("link") {
LinkScreen(api, store) { LinkScreen(api, store) {
authed = true authed = true
nav.navigate("home") { popUpTo("link") { inclusive = true } } nav.navigate("main") { popUpTo("link") { inclusive = true } }
} }
} }
composable("home") { composable("main") {
HomeScreen( MainHub(
api = api, api = api,
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$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 m = movieId?.ifBlank { null } ?: "-"
val e = episodeId?.ifBlank { null } ?: "-" val e = episodeId?.ifBlank { null } ?: "-"
val s = seriesId?.ifBlank { null } ?: "-" val s = seriesId?.ifBlank { null } ?: "-"
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs") nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
}, },
onSettings = { nav.navigate("settings") },
onLogout = { onLogout = {
scope.launch { scope.launch {
store.setToken(null) 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 -> composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable val id = entry.arguments?.getString("id") ?: return@composable
MovieDetailScreen( MovieDetailScreen(
api = api, api = api,
movieId = id, movieId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, title, startMs -> onPlay = { mediaFileId, _, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
}, },
) )
@ -128,7 +148,7 @@ fun McNav() {
api = api, api = api,
seriesId = id, seriesId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _title -> onPlay = { mediaFileId, episodeId, _ ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
}, },
) )
@ -156,8 +176,81 @@ fun McNav() {
onBack = { nav.popBackStack() }, 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun Splash() { private fun Splash() {
Box( Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize().background(McColors.Bg),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp) Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -224,12 +314,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
Brush.radialGradient(
colors = listOf(Color(0xFF12203A), McColors.Bg),
radius = 900f,
),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
@ -242,23 +327,10 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
.padding(40.dp), .padding(40.dp),
) { ) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) 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("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text( Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
"Open de link hieronder op je telefoon of computer, log in en voer de code in.", Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
color = McColors.TextDim, if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
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(status, color = McColors.TextDim, fontSize = 14.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
} }
@ -271,35 +343,27 @@ private fun HomeScreen(
api: ApiClient, api: ApiClient,
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit, onOpenSeries: (String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long, title: String) -> Unit, onSeeAll: (catalogId: String, title: String) -> Unit,
onSettings: () -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onLogout: () -> Unit,
) { ) {
var home by remember { mutableStateOf<HomePayload?>(null) } var home by remember { mutableStateOf<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
suspend fun reload() { LaunchedEffect(Unit) {
loading = true
try { try {
home = api.home() home = api.home()
error = null
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} finally {
loading = false
} }
} }
LaunchedEffect(Unit) { reload() }
val hero = home?.continueWatching?.firstOrNull() val hero = home?.continueWatching?.firstOrNull()
?: home?.shelves?.firstOrNull()?.items?.firstOrNull() ?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
LazyColumn( LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg), Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), contentPadding = PaddingValues(bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(28.dp), verticalArrangement = Arrangement.spacedBy(26.dp),
) { ) {
item { item {
HeroHeader( HeroHeader(
@ -314,7 +378,6 @@ private fun HomeScreen(
null, null,
if (h.type == "episode") "EPISODE" else "MOVIE", if (h.type == "episode") "EPISODE" else "MOVIE",
h.positionMs, h.positionMs,
h.title,
) )
h.type == "movie" -> onOpenMovie(h.id) h.type == "movie" -> onOpenMovie(h.id)
else -> onOpenSeries(h.id) else -> onOpenSeries(h.id)
@ -324,44 +387,48 @@ private fun HomeScreen(
val h = hero ?: return@HeroHeader val h = hero ?: return@HeroHeader
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id) if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
}, },
onSettings = onSettings,
onLogout = onLogout,
) )
} }
if (error != null) { if (error != null) {
item { item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) }
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))
}
} }
val continueItems = home?.continueWatching.orEmpty() val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) { if (continueItems.isNotEmpty()) {
item { item {
ShelfBlock("Verder kijken", continueItems) { item -> ShelfBlock(
when { title = "Verder kijken",
!item.mediaFileId.isNullOrBlank() -> onPlay( items = continueItems,
item.mediaFileId!!, onSeeAll = null,
item.id.takeIf { item.type == "movie" }, onClick = { item ->
item.id.takeIf { item.type == "episode" }, when {
null, !item.mediaFileId.isNullOrBlank() -> onPlay(
if (item.type == "episode") "EPISODE" else "MOVIE", item.mediaFileId!!,
item.positionMs, item.id.takeIf { item.type == "movie" },
item.title, item.id.takeIf { item.type == "episode" },
) null,
item.type == "movie" -> onOpenMovie(item.id) if (item.type == "episode") "EPISODE" else "MOVIE",
else -> onOpenSeries(item.id) item.positionMs,
} )
} item.type == "movie" -> onOpenMovie(item.id)
else -> onOpenSeries(item.id)
}
},
)
} }
} }
items(home?.shelves.orEmpty()) { shelf -> items(home?.shelves.orEmpty()) { shelf ->
ShelfBlock(shelf.title, shelf.items) { item -> val catalogId = when (shelf.slug) {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) "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?, hero: MediaItem?,
onPlayHero: () -> Unit, onPlayHero: () -> Unit,
onOpenHero: () -> Unit, onOpenHero: () -> Unit,
onSettings: () -> Unit,
onLogout: () -> Unit,
) { ) {
Box( Box(Modifier.fillMaxWidth().height(420.dp)) {
Modifier
.fillMaxWidth()
.height(420.dp),
) {
AsyncImage( AsyncImage(
model = hero?.backdropUrl ?: hero?.posterUrl, model = hero?.backdropUrl ?: hero?.posterUrl,
contentDescription = null, contentDescription = null,
@ -388,41 +449,35 @@ private fun HeroHeader(
) )
Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
Box( Box(
Modifier Modifier.fillMaxSize().background(
.fillMaxSize() Brush.horizontalGradient(listOf(McColors.Bg, Color.Transparent, Color.Transparent)),
.background( ),
Brush.horizontalGradient(
listOf(McColors.Bg, Color.Transparent, Color.Transparent),
),
),
) )
Column( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth(0.55f) .fillMaxWidth(0.58f)
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp), .padding(start = 40.dp, end = 24.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
Text( Text(
hero?.title ?: "Welkom terug", hero?.title ?: "Welkom terug",
color = McColors.Text, color = Color.White,
fontSize = 40.sp, fontSize = 42.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 44.sp, lineHeight = 46.sp,
) )
if (!hero?.subtitle.isNullOrBlank()) { if (!hero?.subtitle.isNullOrBlank()) {
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp) Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (hero != null) { 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("Details", onClick = onOpenHero)
} }
McButton("Planken", onClick = onSettings)
McButton("Uitloggen", onClick = onLogout)
} }
} }
} }
@ -430,15 +485,29 @@ private fun HeroHeader(
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) { private fun ShelfBlock(
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { title: String,
Text( items: List<MediaItem>,
title.uppercase(), onSeeAll: (() -> Unit)?,
color = Color(0xFFE8EEF8), onClick: (MediaItem) -> Unit,
fontSize = 18.sp, ) {
fontWeight = FontWeight.Bold, Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
letterSpacing = 1.2.sp, 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( LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(end = 40.dp), contentPadding = PaddingValues(end = 40.dp),
@ -483,9 +552,7 @@ private fun MovieDetailScreen(
f.optString("hdrType").ifBlank { null }?.let(::add) f.optString("hdrType").ifBlank { null }?.let(::add)
f.optString("videoCodec").ifBlank { null }?.let(::add) f.optString("videoCodec").ifBlank { null }?.let(::add)
f.optString("audioCodec").ifBlank { null }?.let(::add) f.optString("audioCodec").ifBlank { null }?.let(::add)
f.optString("releaseName").ifBlank { null }?.let { rn -> f.optString("releaseName").ifBlank { null }?.let { if (isEmpty()) add(it.take(42)) }
if (isEmpty()) add(rn.take(42))
}
f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) } f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) }
}.joinToString(" · ").ifBlank { "Versie ${i + 1}" } }.joinToString(" · ").ifBlank { "Versie ${i + 1}" }
list += f.getString("id") to label list += f.getString("id") to label
@ -497,24 +564,20 @@ private fun MovieDetailScreen(
} }
} }
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize().background(McColors.Bg)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) 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.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent))))
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg)))) Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg))))
Column( Column(
Modifier Modifier.fillMaxHeight().fillMaxWidth(0.58f).padding(40.dp),
.fillMaxHeight()
.fillMaxWidth(0.55f)
.padding(40.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
McButton("Terug", onClick = onBack) McButton("Terug", onClick = onBack)
Spacer(Modifier.height(8.dp)) Text(title, color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3)
Text(title, color = McColors.Text, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3)
if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp) 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) Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp)
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
files.firstOrNull()?.let { (id, _) -> files.firstOrNull()?.let { (id, _) ->
McButton("Afspelen", primary = true) { onPlay(id, title, 0) } McButton("Afspelen", primary = true) { onPlay(id, title, 0) }
@ -527,12 +590,10 @@ private fun MovieDetailScreen(
} }
} }
if (files.isNotEmpty()) { 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)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(files) { (id, label) -> items(files) { (id, label) ->
McButton(label.take(48), primary = id == files.first().first) { McButton(label.take(48), primary = id == files.first().first) { onPlay(id, title, 0) }
onPlay(id, title, 0)
}
} }
} }
} }
@ -581,22 +642,19 @@ private fun SeriesDetailScreen(
} }
} }
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize().background(McColors.Bg)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f) AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
LazyColumn( LazyColumn(contentPadding = PaddingValues(40.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
contentPadding = PaddingValues(40.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { item {
McButton("Terug", onClick = onBack) McButton("Terug", onClick = onBack)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text(title, color = McColors.Text, fontSize = 34.sp, fontWeight = FontWeight.Bold) Text(title, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis) Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(16.dp)) 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) -> items(episodes) { (id, label, fileId) ->
McButton(label) { onPlay(fileId, id, "$title · $label") } McButton(label) { onPlay(fileId, id, "$title · $label") }
@ -607,7 +665,11 @@ private fun SeriesDetailScreen(
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @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 prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -636,12 +698,12 @@ private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { item {
McButton("Terug", onClick = onBack) Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp)) Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
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)
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
McButton("Uitloggen", onClick = onLogout)
Spacer(Modifier.height(8.dp))
} }
itemsIndexed(prefs) { index, p -> itemsIndexed(prefs) { index, p ->
Row( Row(
@ -653,7 +715,7 @@ private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, 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) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑") { McButton("↑") {
if (index > 0) { 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); 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) => { app.get("/api/v1/client/shelves/prefs", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
return { shelves: await viewers.getShelfPrefs(auth.viewerId) }; return { shelves: await viewers.getShelfPrefs(auth.viewerId) };

View file

@ -687,6 +687,170 @@ export class ViewerService {
return { ok: true }; 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) { async getShelfPrefs(viewerId: string) {
const shelves = await prisma.libraryShelf.findMany({ const shelves = await prisma.libraryShelf.findMany({
where: { enabled: true }, where: { enabled: true },