Netflix-style TV nav: Films/Series tabs, search, browse-all catalogs.
This commit is contained in:
parent
9e7faba240
commit
1b0d5a9f8d
8 changed files with 762 additions and 137 deletions
|
|
@ -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\"")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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<MediaItem>, 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<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 = 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<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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue