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"
|
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\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,24 +387,19 @@ 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(
|
||||||
|
title = "Verder kijken",
|
||||||
|
items = continueItems,
|
||||||
|
onSeeAll = null,
|
||||||
|
onClick = { item ->
|
||||||
when {
|
when {
|
||||||
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
item.mediaFileId!!,
|
item.mediaFileId!!,
|
||||||
|
|
@ -350,18 +408,27 @@ private fun HomeScreen(
|
||||||
null,
|
null,
|
||||||
if (item.type == "episode") "EPISODE" else "MOVIE",
|
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||||
item.positionMs,
|
item.positionMs,
|
||||||
item.title,
|
|
||||||
)
|
)
|
||||||
item.type == "movie" -> onOpenMovie(item.id)
|
item.type == "movie" -> onOpenMovie(item.id)
|
||||||
else -> onOpenSeries(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(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(420.dp),
|
|
||||||
) {
|
) {
|
||||||
|
Box(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,
|
||||||
|
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(
|
Text(
|
||||||
title.uppercase(),
|
title.uppercase(),
|
||||||
color = Color(0xFFE8EEF8),
|
color = Color(0xFFE8EEF8),
|
||||||
fontSize = 18.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.2.sp,
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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) };
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue