Fix recent catalog sorting by file mtime and polish mobile UI.

Use GREATEST(last_seen, created_at) so Recent/Films/shelves show truly newest media after bulk imports, and ship phone-first layout with touch-friendly players.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-05 00:46:31 +02:00
parent a5e8e18b75
commit 421baae3d7
12 changed files with 1082 additions and 1045 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 4
versionName = "1.0.0" versionName = "1.0.3"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
@ -28,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -38,6 +38,7 @@ import nl.vonas.mediacluster.tv.data.MediaItem
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.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -66,6 +67,7 @@ fun BrowseScreen(
var loadingMore by remember { mutableStateOf(false) } var loadingMore by remember { mutableStateOf(false) }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val minCell = McDimens.gridMinCell()
suspend fun load(reset: Boolean) { suspend fun load(reset: Boolean) {
if (reset) { if (reset) {
@ -128,43 +130,48 @@ fun BrowseScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(McColors.Bg) .background(McColors.Bg)
.padding(horizontal = 36.dp, vertical = 20.dp), .statusBarsPadding()
.padding(horizontal = McDimens.screenPad),
) { ) {
Spacer(Modifier.height(8.dp))
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Column(Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Text(
title,
color = Color.White,
fontSize = McDimens.titleScreen,
fontWeight = FontWeight.Bold,
)
Text( Text(
if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten", if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 14.sp, fontSize = McDimens.meta,
) )
} }
if (onBack != null) { if (onBack != null) {
McButton("Terug", onClick = onBack) McButton("Terug", compact = true, onClick = onBack)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(12.dp))
if (error != null) { if (error != null) {
Text(error!!, color = McColors.Danger) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
} }
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp), columns = GridCells.Adaptive(minSize = minCell),
state = gridState, state = gridState,
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(McDimens.gridGap),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard( PosterCard(
item = item, item = item,
width = 140.dp, fillCell = true,
height = 210.dp,
modifier = Modifier.padding(6.dp),
onClick = { onClick = {
val canResume = onPlayContinue != null && val canResume = onPlayContinue != null &&
!item.mediaFileId.isNullOrBlank() && !item.mediaFileId.isNullOrBlank() &&

View file

@ -1,9 +1,16 @@
package nl.vonas.mediacluster.tv.ui package nl.vonas.mediacluster.tv.ui
import android.content.ActivityNotFoundException import androidx.compose.foundation.layout.padding
import android.content.Intent import androidx.compose.foundation.layout.statusBarsPadding
import android.net.Uri import androidx.compose.foundation.layout.width
import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -17,15 +24,11 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding import android.content.ActivityNotFoundException
import androidx.compose.foundation.layout.width import android.content.Intent
import androidx.compose.foundation.layout.widthIn import android.net.Uri
import androidx.compose.foundation.lazy.LazyColumn import androidx.activity.compose.BackHandler
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -47,11 +50,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -62,6 +61,7 @@ import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@ -164,8 +164,8 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
model = logoUrl, model = logoUrl,
contentDescription = title, contentDescription = title,
modifier = Modifier modifier = Modifier
.widthIn(max = 420.dp) .widthIn(max = 280.dp)
.heightIn(max = 120.dp) .heightIn(max = 72.dp)
.fillMaxWidth(0.85f), .fillMaxWidth(0.85f),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart, alignment = Alignment.CenterStart,
@ -174,11 +174,11 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
Text( Text(
title, title,
color = Color.White, color = Color.White,
fontSize = 40.sp, fontSize = McDimens.titleHero,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 46.sp, lineHeight = 30.sp,
) )
} }
} }
@ -198,7 +198,7 @@ private fun MetaChips(parts: List<String>) {
Text( Text(
part, part,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim, color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
fontSize = 14.sp, fontSize = McDimens.meta,
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
) )
} }
@ -208,28 +208,15 @@ private fun MetaChips(parts: List<String>) {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun DetailBackButton(onClick: () -> Unit) { private fun DetailBackButton(onClick: () -> Unit) {
Surface( Box(
onClick = onClick, Modifier
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), .clip(RoundedCornerShape(4.dp))
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), .clickable(onClick = onClick)
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), .padding(horizontal = 12.dp, vertical = 6.dp),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color(0xFFCFCFCF),
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
),
) { ) {
Text( Text(
"Terug", "Terug",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), color = Color(0xFFCFCFCF),
color = Color.Unspecified,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@ -245,39 +232,18 @@ private fun DetailActionButton(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = if (primary) Color.White else Color(0xE6181818) val bg = if (primary) Color.White else Color(0xE6181818)
val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White
val fg = if (primary) Color.Black else Color.White val fg = if (primary) Color.Black else Color.White
Surface( Box(
onClick = onClick, modifier = Modifier
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), .clip(RoundedCornerShape(4.dp))
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), .background(bg)
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), .clickable(onClick = onClick)
colors = ClickableSurfaceDefaults.colors( .padding(horizontal = 12.dp, vertical = 6.dp),
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = Color.Black,
),
border = if (primary) {
ClickableSurfaceDefaults.border()
} else {
ClickableSurfaceDefaults.border(
border = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
shape = RoundedCornerShape(4.dp),
),
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
)
},
) { ) {
Text( Text(
text = label, text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), color = fg,
color = Color.Unspecified,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@ -368,26 +334,49 @@ fun MovieDetailScreen(
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen" val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
Column( Column(
Modifier Modifier
.fillMaxHeight() .fillMaxSize()
.fillMaxWidth(0.58f) .statusBarsPadding()
.padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp) .verticalScroll(scroll)
.verticalScroll(scroll), .padding(horizontal = McDimens.screenPad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
DetailBackButton(onClick = onBack) DetailBackButton(onClick = onBack)
Spacer(Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
if (!poster.isNullOrBlank()) {
AsyncImage(
model = poster,
contentDescription = null,
modifier = Modifier
.width(McDimens.detailPosterW)
.height(McDimens.detailPosterH)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
)
}
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts) MetaChips(metaParts)
SelectedQualityLine(selected) SelectedQualityLine(selected)
}
}
if (director.isNotEmpty()) { if (director.isNotEmpty()) {
Text( Text(
"Regie: ${director.take(2).joinToString(", ")}", "Regie: ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A), color = Color(0xFF9A9A9A),
fontSize = 13.sp, fontSize = McDimens.meta,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -396,24 +385,27 @@ fun MovieDetailScreen(
Text( Text(
overview, overview,
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 15.sp, fontSize = McDimens.bodySmall,
maxLines = 4, maxLines = 6,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp, lineHeight = 18.sp,
) )
} }
if (cast.isNotEmpty()) { if (cast.isNotEmpty()) {
Text( Text(
"Met: ${cast.take(4).joinToString(", ")}", "Met: ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A), color = Color(0xFF8A8A8A),
fontSize = 13.sp, fontSize = McDimens.meta,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
selected?.let { ver -> selected?.let { ver ->
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) { DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
onPlay(ver.id, title, 0) onPlay(ver.id, title, 0)
@ -432,17 +424,16 @@ fun MovieDetailScreen(
} }
} }
if (versions.size > 1) { if (versions.size > 1) {
Spacer(Modifier.height(2.dp))
Text( Text(
"Kwaliteit", "Kwaliteit",
color = Color(0xFF8F8F8F), color = Color(0xFF8F8F8F),
fontSize = 11.sp, fontSize = McDimens.caption,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp, letterSpacing = 0.6.sp,
) )
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp), contentPadding = PaddingValues(bottom = 16.dp),
) { ) {
items(versions, key = { it.id }) { ver -> items(versions, key = { it.id }) { ver ->
VersionPickChip( VersionPickChip(
@ -452,23 +443,10 @@ fun MovieDetailScreen(
) )
} }
} }
} else {
Spacer(Modifier.height(24.dp))
} }
} }
if (!poster.isNullOrBlank()) {
AsyncImage(
model = poster,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 56.dp)
.width(200.dp)
.height(300.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
)
}
} }
} }
@ -480,30 +458,12 @@ private fun VersionPickChip(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val shape = RoundedCornerShape(5.dp) val shape = RoundedCornerShape(5.dp)
Surface( Box(
onClick = onClick, Modifier
shape = ClickableSurfaceDefaults.shape(shape = shape), .clip(shape)
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), .background(if (selected) Color(0xEE222222) else Color(0x99101010))
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), .clickable(onClick = onClick)
colors = ClickableSurfaceDefaults.colors( .padding(1.dp),
containerColor = if (selected) Color(0xEE222222) else Color(0x99101010),
focusedContainerColor = Color(0xFF2C2C2C),
contentColor = Color.White,
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
border = Border(
border = androidx.compose.foundation.BorderStroke(
if (selected) 1.5.dp else 1.dp,
if (selected) Color.White else Color(0x33FFFFFF),
),
shape = shape,
),
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.5.dp, Color.White),
shape = shape,
),
),
) { ) {
VersionQualityMark(version = version, selected = selected) VersionQualityMark(version = version, selected = selected)
} }
@ -592,30 +552,36 @@ fun SeriesDetailScreen(
} }
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
LazyColumn( LazyColumn(
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp), contentPadding = PaddingValues(
verticalArrangement = Arrangement.spacedBy(12.dp), start = McDimens.screenPad,
end = McDimens.screenPad,
top = 12.dp,
bottom = 28.dp,
),
modifier = Modifier.statusBarsPadding(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
item { item {
DetailBackButton(onClick = onBack) DetailBackButton(onClick = onBack)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(6.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts) MetaChips(metaParts)
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
Text( Text(
overview, overview,
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 15.sp, fontSize = McDimens.bodySmall,
maxLines = 4, maxLines = 5,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp, lineHeight = 18.sp,
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp), modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
) )
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
episodes.firstOrNull()?.let { ep -> episodes.firstOrNull()?.let { ep ->
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) {
@ -627,8 +593,8 @@ fun SeriesDetailScreen(
} }
} }
if (seasons.size > 1) { if (seasons.size > 1) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(6.dp))
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(seasons) { s -> items(seasons) { s ->
@ -641,9 +607,9 @@ fun SeriesDetailScreen(
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) Text("Afleveringen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
} }
itemsIndexed(episodes) { index, ep -> itemsIndexed(episodes) { _, ep ->
EpisodeCard( EpisodeCard(
episode = ep, episode = ep,
focusRequester = null, focusRequester = null,
@ -674,42 +640,32 @@ private fun EpisodeCard(
focusRequester: FocusRequester?, focusRequester: FocusRequester?,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xCC141414),
focusedContainerColor = Color(0xFF222222),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(10.dp), .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
horizontalArrangement = Arrangement.spacedBy(14.dp), .clip(RoundedCornerShape(8.dp))
.background(Color(0xCC141414))
.clickable(onClick = onClick)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImage( AsyncImage(
model = episode.stillUrl, model = episode.stillUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.width(160.dp) .width(McDimens.episodeStillW)
.height(90.dp) .height(McDimens.episodeStillH)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(5.dp))
.background(Color(0xFF1A1A1A)), .background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
"S${episode.season}E${episode.number} · ${episode.title}", "S${episode.season}E${episode.number} · ${episode.title}",
color = Color.White, color = Color.White,
fontSize = 16.sp, fontSize = McDimens.bodySmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -718,15 +674,14 @@ private fun EpisodeCard(
Text( Text(
episode.overview, episode.overview,
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 13.sp, fontSize = McDimens.meta,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp, lineHeight = 15.sp,
) )
} }
formatRuntime(episode.runtime)?.let { formatRuntime(episode.runtime)?.let {
Text(it, color = Color(0xFF888888), fontSize = 12.sp) Text(it, color = Color(0xFF888888), fontSize = McDimens.caption)
}
} }
} }
} }

View file

@ -11,7 +11,9 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -62,9 +64,7 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -397,6 +397,18 @@ fun LiveTvPlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Tik op scherm: info-overlay tonen/verbergen (mobiel)
if (!channelMenuOpen && error == null) {
Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { infoOverlay = !infoOverlay },
)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(200) delay(200)
runCatching { playerFocus.requestFocus() } runCatching { playerFocus.requestFocus() }
@ -426,7 +438,37 @@ fun LiveTvPlayerScreen(
Row( Row(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(32.dp), .padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0x55FFFFFF))
.clickable(onClick = onBack)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text("← Terug", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0x55FFFFFF))
.clickable {
infoOverlay = true
channelMenuOpen = true
}
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text("Zenders", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
}
Row(
Modifier
.align(Alignment.TopStart)
.padding(start = 24.dp, top = 72.dp, end = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -493,7 +535,11 @@ fun LiveTvPlayerScreen(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)), .background(Color.Black.copy(alpha = 0.4f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { channelMenuOpen = false },
) )
LiveChannelSwitcher( LiveChannelSwitcher(
categoryName = menuCategoryName, categoryName = menuCategoryName,
@ -660,24 +706,14 @@ private fun LiveChannelSwitcher(
val ch = channels[index] val ch = channels[index]
val isCurrent = ch.streamId == currentStreamId val isCurrent = ch.streamId == currentStreamId
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
Surface(
onClick = { onSelect(ch.streamId) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
.focusRequester(fr),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (isCurrent) Color(0x33E50914) else Color.Transparent,
focusedContainerColor = Color(0x28FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp)
.focusRequester(fr)
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrent) Color(0x33E50914) else Color.Transparent)
.clickable { onSelect(ch.streamId) }
.height(54.dp) .height(54.dp)
.padding(horizontal = 10.dp), .padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -742,7 +778,6 @@ private fun LiveChannelSwitcher(
} }
} }
} }
}
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -39,7 +40,6 @@ import androidx.compose.ui.focus.FocusRequester
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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -70,6 +70,7 @@ import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.SubNavItem import nl.vonas.mediacluster.tv.ui.components.SubNavItem
import nl.vonas.mediacluster.tv.ui.components.TopNavBar import nl.vonas.mediacluster.tv.ui.components.TopNavBar
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@Composable @Composable
fun McNav() { fun McNav() {
@ -646,8 +647,8 @@ private fun HomeScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp), contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(28.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
) { ) {
item { item {
SpotlightBillboard( SpotlightBillboard(
@ -674,7 +675,14 @@ private fun HomeScreen(
) )
} }
if (error != null) { if (error != null) {
item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } item {
Text(
error!!,
color = McColors.Danger,
fontSize = McDimens.bodySmall,
modifier = Modifier.padding(horizontal = McDimens.screenPad),
)
}
} }
val continueItems = home?.continueWatching.orEmpty() val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) { if (continueItems.isNotEmpty()) {
@ -736,15 +744,15 @@ private fun HomeScreen(
) { ) {
Column( Column(
Modifier Modifier
.width(440.dp) .fillMaxWidth(0.9f)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A)) .background(Color(0xFF1A1A1A))
.padding(28.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2) Text(menuItem.title, color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold, maxLines = 2)
if (!menuItem.subtitle.isNullOrBlank()) { if (!menuItem.subtitle.isNullOrBlank()) {
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
McButton("Markeren als gezien") { McButton("Markeren als gezien") {
@ -774,7 +782,7 @@ private fun HomeScreen(
continueMenu = null continueMenu = null
} }
} }
McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null } McButton("Annuleren", compact = true, focusRequester = menuFocus) { continueMenu = null }
} }
} }
} }
@ -791,6 +799,7 @@ private fun SpotlightBillboard(
var index by remember { mutableStateOf(0) } var index by remember { mutableStateOf(0) }
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) } val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
val current = safeItems[index.coerceIn(0, safeItems.lastIndex)] val current = safeItems[index.coerceIn(0, safeItems.lastIndex)]
val billboardH = McDimens.billboardHeight()
LaunchedEffect(safeItems.size) { LaunchedEffect(safeItems.size) {
if (safeItems.size <= 1) return@LaunchedEffect if (safeItems.size <= 1) return@LaunchedEffect
@ -800,7 +809,7 @@ private fun SpotlightBillboard(
} }
} }
Box(Modifier.fillMaxWidth().height(480.dp)) { Box(Modifier.fillMaxWidth().height(billboardH)) {
AsyncImage( AsyncImage(
model = current.backdropUrl ?: current.posterUrl, model = current.backdropUrl ?: current.posterUrl,
contentDescription = current.title, contentDescription = current.title,
@ -810,81 +819,47 @@ private fun SpotlightBillboard(
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)), listOf(Color(0x22000000), Color(0x66000000), Color(0xF20B0B0B)),
), ),
), ),
) )
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
listOf(Color(0xE60B0B0B), Color(0x880B0B0B), Color.Transparent),
),
),
)
// Peek van volgende posters rechts
if (safeItems.size > 1) {
Row(
Modifier
.align(Alignment.CenterEnd)
.padding(end = 36.dp),
horizontalArrangement = Arrangement.spacedBy((-28).dp),
) {
val peek = listOf(1, 2).mapNotNull { off ->
safeItems.getOrNull((index + off) % safeItems.size)
}
peek.forEachIndexed { i, item ->
AsyncImage(
model = item.posterUrl ?: item.backdropUrl,
contentDescription = null,
modifier = Modifier
.width(110.dp)
.height(165.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFF1A1A1A))
.graphicsLayer { alpha = if (i == 0) 0.9f else 0.5f },
contentScale = ContentScale.Crop,
)
}
}
}
Column( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth(0.55f) .fillMaxWidth()
.padding(start = 48.dp, end = 24.dp, bottom = 36.dp), .padding(horizontal = McDimens.screenPad, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"VMC", "VMC",
color = McColors.Brand, color = McColors.Brand,
fontSize = 13.sp, fontSize = 11.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
letterSpacing = 2.sp, letterSpacing = 1.5.sp,
) )
if (!current.badge.isNullOrBlank()) { if (!current.badge.isNullOrBlank()) {
Text( Text(
current.badge!!.uppercase(), current.badge!!.uppercase(),
color = Color.White, color = Color.White,
fontSize = 12.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(McColors.Brand) .background(McColors.Brand)
.padding(horizontal = 8.dp, vertical = 3.dp), .padding(horizontal = 6.dp, vertical = 2.dp),
) )
} }
} }
Text( Text(
current.title, current.title,
color = Color.White, color = Color.White,
fontSize = 46.sp, fontSize = McDimens.titleHero,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 50.sp, lineHeight = 30.sp,
) )
val meta = listOfNotNull( val meta = listOfNotNull(
current.year?.toString(), current.year?.toString(),
@ -892,21 +867,21 @@ private fun SpotlightBillboard(
current.subtitle?.takeIf { it.isNotBlank() }, current.subtitle?.takeIf { it.isNotBlank() },
).joinToString(" · ") ).joinToString(" · ")
if (meta.isNotBlank()) { if (meta.isNotBlank()) {
Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp) Text(meta, color = Color(0xFFCFCFCF), fontSize = McDimens.meta)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (current.id.isNotBlank()) { if (current.id.isNotBlank()) {
McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) }) McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) })
McButton("Meer info", onClick = { onOpen(current) }) McButton("Meer info", compact = true, onClick = { onOpen(current) })
} }
} }
if (safeItems.size > 1) { if (safeItems.size > 1) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.padding(top = 2.dp)) {
safeItems.indices.forEach { i -> safeItems.indices.forEach { i ->
Box( Box(
Modifier Modifier
.height(4.dp) .height(3.dp)
.width(if (i == index) 22.dp else 8.dp) .width(if (i == index) 16.dp else 6.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.background(if (i == index) Color.White else Color(0x55FFFFFF)), .background(if (i == index) Color.White else Color(0x55FFFFFF)),
) )
@ -926,30 +901,36 @@ private fun ShelfBlock(
onClick: (MediaItem) -> Unit, onClick: (MediaItem) -> Unit,
onLongClick: ((MediaItem) -> Unit)? = null, onLongClick: ((MediaItem) -> Unit)? = null,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { val posterW = McDimens.shelfPosterWidth()
val posterH = McDimens.posterHeight(posterW)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 48.dp), Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
title, title,
color = Color(0xFFE5E5E5), color = Color(0xFFE5E5E5),
fontSize = 20.sp, fontSize = McDimens.titleShelf,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
if (onSeeAll != null) { if (onSeeAll != null) {
McButton("Alles") { onSeeAll() } McButton("Alles", compact = true) { onSeeAll() }
} }
} }
// contentPadding i.p.v. Column-startpadding: focus-scale mag in de marge tekenen
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(McDimens.shelfGap),
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp), contentPadding = PaddingValues(horizontal = McDimens.screenPad, vertical = 2.dp),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard( PosterCard(
item = item, item = item,
width = posterW,
height = posterH,
onClick = { onClick(item) }, onClick = { onClick(item) },
onLongClick = onLongClick?.let { handler -> { handler(item) } }, onLongClick = onLongClick?.let { handler -> { handler(item) } },
) )
@ -989,30 +970,34 @@ private fun SettingsScreen(
} }
LazyColumn( LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp), Modifier
verticalArrangement = Arrangement.spacedBy(12.dp), .fillMaxSize()
.background(McColors.Bg)
.statusBarsPadding()
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
item { item {
Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold)
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp) Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall)
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(8.dp))
McButton("Uitloggen", onClick = onLogout)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
McButton("Uitloggen", compact = true, onClick = onLogout)
Spacer(Modifier.height(4.dp))
} }
itemsIndexed(prefs) { index, p -> itemsIndexed(prefs) { index, p ->
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(10.dp))
.background(McColors.BgElevated) .background(McColors.BgElevated)
.padding(16.dp), .padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑") { McButton("↑", compact = true) {
if (index > 0) { if (index > 0) {
val next = prefs.toMutableList() val next = prefs.toMutableList()
val tmp = next[index - 1] val tmp = next[index - 1]
@ -1021,7 +1006,7 @@ private fun SettingsScreen(
persist(next) persist(next)
} }
} }
McButton("↓") { McButton("↓", compact = true) {
if (index < prefs.lastIndex) { if (index < prefs.lastIndex) {
val next = prefs.toMutableList() val next = prefs.toMutableList()
val tmp = next[index + 1] val tmp = next[index + 1]

View file

@ -6,18 +6,21 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import android.util.Base64
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -31,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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
@ -56,10 +60,9 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import java.io.File
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -74,9 +77,6 @@ import nl.vonas.mediacluster.tv.playback.listTracks
import nl.vonas.mediacluster.tv.playback.selectTrack import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64
import androidx.compose.runtime.rememberCoroutineScope
import java.io.File
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -372,13 +372,17 @@ fun PlayerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet // Touch + D-pad catcher wanneer controls weg zijn
if (!controls && menu == null && error == null) { if (!controls && menu == null && error == null) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(catcherFocus) .focusRequester(catcherFocus)
.focusable(), .focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { showControls() },
) )
} }
@ -401,7 +405,15 @@ fun PlayerScreen(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
Box(Modifier.fillMaxSize()) { Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = menu == null,
) { hideControls() },
) {
// Top / bottom scrim zoals Netflix // Top / bottom scrim zoals Netflix
Box( Box(
Modifier Modifier
@ -430,21 +442,16 @@ fun PlayerScreen(
// Grote play/pause in het midden als gepauzeerd // Grote play/pause in het midden als gepauzeerd
if (!playing) { if (!playing) {
Surface( Box(
onClick = { togglePlay() }, Modifier
modifier = Modifier.align(Alignment.Center), .align(Alignment.Center)
shape = ClickableSurfaceDefaults.shape(shape = CircleShape), .size(88.dp)
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), .clip(CircleShape)
colors = ClickableSurfaceDefaults.colors( .background(Color(0x55FFFFFF))
containerColor = Color(0x55FFFFFF), .clickable { togglePlay() },
focusedContainerColor = Color.White, contentAlignment = Alignment.Center,
contentColor = Color.White,
focusedContentColor = Color.Black,
),
) { ) {
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) { Text("▶", color = Color.White, fontSize = 36.sp)
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
}
} }
} }
@ -621,44 +628,35 @@ private fun NetflixIconButton(
primary: Boolean = false, primary: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.15f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) Color.White else Color(0x22FFFFFF),
focusedContainerColor = Color.White,
contentColor = if (primary) Color.Black else Color.White,
focusedContentColor = Color.Black,
),
) {
Box( Box(
Modifier.size(52.dp), Modifier
.size(52.dp)
.clip(CircleShape)
.background(if (primary) Color.White else Color(0x22FFFFFF))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(
} label,
color = if (primary) Color.Black else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun NetflixTextButton(label: String, onClick: () -> Unit) { private fun NetflixTextButton(label: String, onClick: () -> Unit) {
Surface( Box(
onClick = onClick, Modifier
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), .clip(RoundedCornerShape(4.dp))
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), .clickable(onClick = onClick)
colors = ClickableSurfaceDefaults.colors( .padding(horizontal = 14.dp, vertical = 10.dp),
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color(0xFFE5E5E5),
focusedContentColor = Color.White,
),
) { ) {
Text( Text(
label, label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), color = Color(0xFFE5E5E5),
color = Color.Unspecified,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@ -668,21 +666,12 @@ private fun NetflixTextButton(label: String, onClick: () -> Unit) {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) { private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) Color(0x22FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
),
modifier = Modifier.fillMaxWidth(),
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(if (selected) Color(0x22FFFFFF) else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -691,8 +680,7 @@ private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Uni
color = McColors.Brand, color = McColors.Brand,
fontSize = 15.sp, fontSize = 15.sp,
) )
Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Medium) Text(label, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
} }
} }

View file

@ -3,7 +3,6 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler 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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -11,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
@ -26,14 +26,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -43,6 +41,7 @@ import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.components.PosterCard import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -60,6 +59,7 @@ fun SearchScreen(
var searchJob by remember { mutableStateOf<Job?>(null) } var searchJob by remember { mutableStateOf<Job?>(null) }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val minCell = McDimens.gridMinCell()
if (onAtTopBack != null) { if (onAtTopBack != null) {
BackHandler { BackHandler {
@ -98,12 +98,14 @@ fun SearchScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(McColors.Bg) .background(McColors.Bg)
.padding(horizontal = 16.dp, vertical = 8.dp), .statusBarsPadding()
.padding(horizontal = McDimens.screenPad),
) { ) {
Spacer(Modifier.height(8.dp))
Text( Text(
"Zoeken", "Zoeken",
color = Color.White, color = Color.White,
fontSize = 22.sp, fontSize = McDimens.titleScreen,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
@ -112,8 +114,10 @@ fun SearchScreen(
onValueChange = { scheduleSearch(it) }, onValueChange = { scheduleSearch(it) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("Typ een titel…", color = McColors.TextDim) }, placeholder = {
shape = RoundedCornerShape(12.dp), androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
@ -128,24 +132,23 @@ fun SearchScreen(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
when { when {
error != null -> Text(error!!, color = McColors.Danger, fontSize = 14.sp) error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = 14.sp) busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
query.isNotBlank() && results.isEmpty() -> query.isNotBlank() && results.isEmpty() ->
Text("Geen resultaten", color = McColors.TextDim, fontSize = 14.sp) Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall)
} }
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp), columns = GridCells.Adaptive(minSize = minCell),
state = gridState, state = gridState,
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(McDimens.gridGap),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(results, key = { "${it.type}-${it.id}" }) { item -> items(results, key = { "${it.type}-${it.id}" }) { item ->
PosterCard( PosterCard(
item = item, item = item,
width = 110.dp, fillCell = true,
height = 165.dp,
onClick = { onClick = {
when (item.type) { when (item.type) {
"movie" -> onOpenMovie(item.id) "movie" -> onOpenMovie(item.id)

View file

@ -3,18 +3,20 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -34,16 +36,13 @@ 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -54,16 +53,18 @@ import nl.vonas.mediacluster.tv.data.EpgProgram
import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvChannel
import nl.vonas.mediacluster.tv.data.IptvEpg import nl.vonas.mediacluster.tv.data.IptvEpg
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
private val TvRowBg = Color(0xFF141414) private val TvRowBg = Color(0xFF141414)
private val TvRowFocus = Color(0xFF242424) private val TvRowSelected = Color(0xFF242424)
private val TvRowActive = Color(0xFF1C1C1C) private val TvRowActive = Color(0xFF1C1C1C)
private val TvDivider = Color(0xFF2A2A2A) private val TvDivider = Color(0xFF2A2A2A)
private val TvPanelBg = Color(0xFF101010) private val TvPanelBg = Color(0xFF121212)
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -83,15 +84,13 @@ fun TvScreen(
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var loadingChannels by remember { mutableStateOf(false) } var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) } var selectedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) } var selectedStreamId by remember { mutableStateOf<Int?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) } var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInList by remember { mutableStateOf(false) }
val channelListState = rememberLazyListState() val channelListState = rememberLazyListState()
val categoryRowState = rememberLazyListState() val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() } val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() } val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -113,11 +112,25 @@ fun TvScreen(
} }
} }
fun loadEpg(streamId: Int) {
selectedStreamId = streamId
onLastStreamChange(streamId)
epgJob?.cancel()
epgJob = scope.launch {
delay(120)
runCatching {
val epg = api.iptvEpg(streamId)
selectedEpg = epg
epg.now?.title?.let { epgNowTitles[streamId] = it }
}
}
}
LaunchedEffect(selectedCategoryId) { LaunchedEffect(selectedCategoryId) {
val catId = selectedCategoryId ?: return@LaunchedEffect val catId = selectedCategoryId ?: return@LaunchedEffect
loadingChannels = true loadingChannels = true
focusedEpg = null selectedEpg = null
focusedStreamId = null selectedStreamId = null
epgNowTitles.clear() epgNowTitles.clear()
try { try {
val loaded = api.iptvChannels(catId, includeEpg = true) val loaded = api.iptvChannels(catId, includeEpg = true)
@ -126,10 +139,7 @@ fun TvScreen(
titles.forEach { (id, title) -> epgNowTitles[id] = title } titles.forEach { (id, title) -> epgNowTitles[id] = title }
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } } val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
?: loaded.firstOrNull()?.streamId ?: loaded.firstOrNull()?.streamId
focusId?.let { id -> focusId?.let { loadEpg(it) }
focusedStreamId = id
runCatching { focusedEpg = api.iptvEpg(id) }
}
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
channels = emptyList() channels = emptyList()
@ -138,127 +148,98 @@ fun TvScreen(
} }
} }
LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) {
if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect
delay(100)
val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
?: channels.first().streamId
val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
channelListState.scrollToItem(idx)
delay(80)
channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
focusedStreamId = targetId
}
LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) { LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
if (loading || categories.isEmpty()) return@LaunchedEffect if (loading || categories.isEmpty()) return@LaunchedEffect
delay(120) delay(80)
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } } val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
if (channelTarget != null) { if (channelTarget != null) {
val idx = channels.indexOfFirst { it.streamId == channelTarget } val idx = channels.indexOfFirst { it.streamId == channelTarget }
if (idx >= 0) channelListState.scrollToItem(idx) if (idx >= 0) channelListState.scrollToItem(idx)
channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } } loadEpg(channelTarget)
focusInList = true
focusedStreamId = channelTarget
runCatching {
val epg = api.iptvEpg(channelTarget)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[channelTarget] = it }
}
} else { } else {
val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0) val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
categoryRowState.scrollToItem(catIdx) categoryRowState.scrollToItem(catIdx)
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInList = false
} }
} }
BackHandler(enabled = focusInList) { BackHandler { onAtTopBack() }
val catId = selectedCategoryId ?: categories.firstOrNull()?.id
if (catId != null) {
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInList = false
}
}
BackHandler(enabled = !focusInList) {
onAtTopBack()
}
fun loadEpg(streamId: Int) { Box(
focusedStreamId = streamId Modifier
epgJob?.cancel() .fillMaxSize()
epgJob = scope.launch { .background(McColors.Bg)
delay(200) .statusBarsPadding(),
runCatching { ) {
val epg = api.iptvEpg(streamId)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[streamId] = it }
}
}
}
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
when { when {
loading -> { loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("TV laden…", color = McColors.TextDim, fontSize = 15.sp) Text("TV laden…", color = McColors.TextDim, fontSize = McDimens.body)
} }
} }
error != null && categories.isEmpty() -> { error != null && categories.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(
Text("TV niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) horizontalAlignment = Alignment.CenterHorizontally,
Text(error!!, color = McColors.Danger, fontSize = 14.sp) verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("TV niet beschikbaar", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.SemiBold)
Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
} }
} }
} }
else -> { else -> {
Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) { Column(
Modifier
.fillMaxSize()
.padding(horizontal = McDimens.screenPad),
) {
Spacer(Modifier.height(8.dp))
Text(
"Live TV",
color = Color.White,
fontSize = McDimens.titleScreen,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
CategoryTabRow( CategoryTabRow(
categories = categories, categories = categories,
selectedId = selectedCategoryId, selectedId = selectedCategoryId,
listState = categoryRowState, listState = categoryRowState,
categoryFocus = categoryFocus, categoryFocus = categoryFocus,
onSelect = { onSelect = { onCategoryChange(it) },
onCategoryChange(it) onFocused = { onContentFocused() },
focusInList = true
},
onFocused = {
focusInList = false
onContentFocused()
},
) )
Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(8.dp)) // Gids bovenaan — leesbaar op portrait
Box( MobileEpgCard(
Modifier epg = selectedEpg,
.fillMaxWidth() onPlay = {
.height(1.dp) selectedStreamId?.let { id ->
.background(TvDivider), onLastStreamChange(id)
) onPlayChannel(id)
Spacer(Modifier.height(4.dp))
Row(Modifier.weight(1f).fillMaxWidth()) {
Column(Modifier.weight(0.58f).fillMaxHeight()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
} }
},
)
Spacer(Modifier.height(10.dp))
Text(
"Tik om te selecteren · nogmaals om te kijken",
color = Color(0xFF777777),
fontSize = McDimens.caption,
)
Spacer(Modifier.height(6.dp))
when { when {
loadingChannels -> { loadingChannels -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp) Text("Zenders laden…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
} }
} }
channels.isEmpty() -> { channels.isEmpty() -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp) Text("Geen zenders", color = McColors.TextDim, fontSize = McDimens.bodySmall)
} }
} }
else -> { else -> {
@ -266,25 +247,22 @@ fun TvScreen(
state = channelListState, state = channelListState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = 12.dp), contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
items(channels, key = { it.streamId }) { ch -> items(channels, key = { it.streamId }) { ch ->
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelRow( ChannelRow(
channel = ch, channel = ch,
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle, nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
isFocused = focusedStreamId == ch.streamId, isSelected = selectedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId, isLastWatched = ch.streamId == lastStreamId,
modifier = Modifier.focusRequester(fr),
onClick = { onClick = {
onContentFocused()
if (selectedStreamId == ch.streamId) {
onLastStreamChange(ch.streamId) onLastStreamChange(ch.streamId)
onPlayChannel(ch.streamId) onPlayChannel(ch.streamId)
}, } else {
onFocused = {
focusInList = true
onContentFocused()
onLastStreamChange(ch.streamId)
loadEpg(ch.streamId) loadEpg(ch.streamId)
}
}, },
) )
} }
@ -292,24 +270,6 @@ fun TvScreen(
} }
} }
} }
Box(
Modifier
.width(1.dp)
.fillMaxHeight()
.padding(vertical = 4.dp)
.background(TvDivider),
)
ChannelDetailPanel(
epg = focusedEpg,
modifier = Modifier
.weight(0.42f)
.fillMaxHeight()
.padding(start = 16.dp, end = 8.dp, top = 4.dp),
)
}
}
} }
} }
} }
@ -328,53 +288,41 @@ private fun CategoryTabRow(
LazyRow( LazyRow(
state = listState, state = listState,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 4.dp), contentPadding = PaddingValues(vertical = 2.dp),
) { ) {
items(categories, key = { it.id }) { cat -> items(categories, key = { it.id }) { cat ->
val active = cat.id == selectedId val active = cat.id == selectedId
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() } val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
val textColor by animateColorAsState(
if (active) Color.White else Color(0xFF888888),
label = "tabText",
)
Surface(
onClick = { onSelect(cat.id) },
modifier = Modifier
.focusRequester(fr)
.onFocusChanged { if (it.isFocused) onFocused() },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x18FFFFFF),
contentColor = textColor,
focusedContentColor = Color.White,
),
) {
Column( Column(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp), Modifier
.focusRequester(fr)
.clip(RoundedCornerShape(4.dp))
.clickable {
onFocused()
onSelect(cat.id)
}
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
cat.name, cat.name,
color = Color.Unspecified, color = if (active) Color.White else Color(0xFF888888),
fontSize = 14.sp, fontSize = 13.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(4.dp))
Box( Box(
Modifier Modifier
.height(2.dp) .height(2.dp)
.width(if (active) 24.dp else 0.dp) .width(if (active) 20.dp else 0.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.dp))
.background(if (active) McColors.Brand else Color.Transparent), .background(if (active) McColors.Brand else Color.Transparent),
) )
} }
} }
} }
}
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -382,39 +330,25 @@ private fun CategoryTabRow(
private fun ChannelRow( private fun ChannelRow(
channel: IptvChannel, channel: IptvChannel,
nowTitle: String?, nowTitle: String?,
isFocused: Boolean, isSelected: Boolean,
isLastWatched: Boolean, isLastWatched: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onFocused: () -> Unit,
) { ) {
val bg by animateColorAsState( val bg by animateColorAsState(
when { when {
isFocused -> TvRowFocus isSelected -> TvRowSelected
isLastWatched -> TvRowActive isLastWatched -> TvRowActive
else -> TvRowBg else -> TvRowBg
}, },
label = "rowBg", label = "rowBg",
) )
Surface(
onClick = onClick,
modifier = modifier.onFocusChanged { state ->
if (state.isFocused) onFocused()
},
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = bg,
focusedContainerColor = TvRowFocus,
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(52.dp) .clip(RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp), .background(bg)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
@ -424,16 +358,16 @@ private fun ChannelRow(
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.background( .background(
when { when {
isFocused -> McColors.Brand isSelected -> McColors.Brand
isLastWatched -> McColors.Brand.copy(alpha = 0.45f) isLastWatched -> McColors.Brand.copy(alpha = 0.45f)
else -> Color.Transparent else -> Color.Transparent
}, },
), ),
) )
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
.size(36.dp) .size(32.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)), .background(Color(0xFF0A0A0A)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -442,100 +376,100 @@ private fun ChannelRow(
AsyncImage( AsyncImage(
model = channel.logoUrl, model = channel.logoUrl,
contentDescription = channel.name, contentDescription = channel.name,
modifier = Modifier modifier = Modifier.fillMaxSize().padding(3.dp),
.fillMaxSize()
.padding(4.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} else { } else {
Text( Text(
channel.name.take(2).uppercase(), channel.name.take(2).uppercase(),
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 11.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
channel.number?.let { num -> channel.number?.let { num ->
Text( Text(
"$num", "$num",
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 13.sp, fontSize = 11.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.width(36.dp), modifier = Modifier.padding(end = 6.dp),
) )
} }
Text( Text(
channel.name, channel.name,
color = Color.White, color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(140.dp),
)
Text(
nowTitle ?: "",
color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
if (!nowTitle.isNullOrBlank()) {
Text(
nowTitle,
color = if (isSelected) Color(0xFFCCCCCC) else McColors.TextDim,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun ChannelDetailPanel( private fun MobileEpgCard(
epg: IptvEpg?, epg: IptvEpg?,
modifier: Modifier = Modifier, onPlay: () -> Unit,
) { ) {
Column( Column(
modifier Modifier
.clip(RoundedCornerShape(6.dp)) .fillMaxWidth()
.heightIn(min = 120.dp)
.clip(RoundedCornerShape(10.dp))
.background(TvPanelBg) .background(TvPanelBg)
.padding(16.dp), .padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (epg == null) { if (epg == null) {
Spacer(Modifier.weight(1f)) Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text( Text("Selecteer een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall)
"Selecteer een zender", }
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.weight(1f))
return return
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
if (!epg.logoUrl.isNullOrBlank()) { if (!epg.logoUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = epg.logoUrl, model = epg.logoUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(40.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF0A0A0A)), .background(Color(0xFF0A0A0A)),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
Column {
Text( Text(
epg.channelName, epg.channelName,
color = Color.White, color = Color.White,
fontSize = 16.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
} McButton("▶ Kijk", primary = true, compact = true, onClick = onPlay)
} }
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
@ -566,37 +500,34 @@ private fun ChannelDetailPanel(
} }
if (epg.now == null && epg.next == null) { if (epg.now == null && epg.next == null) {
Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp) Text("Geen programma-info", color = McColors.TextDim, fontSize = McDimens.meta)
} }
Spacer(Modifier.weight(1f))
Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp)
} }
} }
@Composable @Composable
private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) { private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
label.uppercase(), label.uppercase(),
color = if (highlight) McColors.Brand else McColors.TextDim, color = if (highlight) McColors.Brand else McColors.TextDim,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.sp, letterSpacing = 0.8.sp,
) )
formatEpgTime(program.start, program.end)?.let { time -> formatEpgTime(program.start, program.end)?.let { time ->
Text(time, color = McColors.TextDim, fontSize = 12.sp) Text(time, color = McColors.TextDim, fontSize = 11.sp)
} }
} }
Text( Text(
program.title, program.title,
color = if (highlight) Color.White else Color(0xFFBBBBBB), color = if (highlight) Color.White else Color(0xFFBBBBBB),
fontSize = if (highlight) 15.sp else 14.sp, fontSize = if (highlight) 13.sp else 12.sp,
fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal, fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal,
maxLines = 3, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp, lineHeight = 17.sp,
) )
} }
} }

View file

@ -1,7 +1,11 @@
package nl.vonas.mediacluster.tv.ui.components package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -9,36 +13,23 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
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.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -46,35 +37,26 @@ fun McButton(
label: String, label: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
primary: Boolean = false, primary: Boolean = false,
compact: Boolean = false,
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = if (primary) Color.White else Color(0x33FFFFFF) val bg = if (primary) Color.White else Color(0x33FFFFFF)
val bgFocus = if (primary) Color.White else Color.White
val fg = if (primary) Color.Black else Color.White val fg = if (primary) Color.Black else Color.White
val fgFocus = Color.Black val hPad = if (compact) 12.dp else 16.dp
Surface( val vPad = if (compact) 8.dp else 10.dp
onClick = onClick, Box(
modifier = modifier.then( modifier = modifier
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, .clip(RoundedCornerShape(4.dp))
), .background(bg)
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), .clickable(onClick = onClick)
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), .padding(horizontal = hPad, vertical = vPad),
glow = ClickableSurfaceDefaults.glow( contentAlignment = Alignment.Center,
focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = fgFocus,
),
) { ) {
Text( Text(
text = label.ifBlank { "—" }, text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp), color = fg,
color = Color.Unspecified, fontSize = if (compact) 13.sp else 14.sp,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -82,39 +64,16 @@ fun McButton(
} }
} }
private fun Key.isTvSelectKey(): Boolean = @OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class)
this == Key.DirectionCenter || this == Key.Enter || this == Key.NumPadEnter
@Composable
private fun Modifier.tvShortOrLongPress(
onClick: () -> Unit,
onLongPress: () -> Unit,
): Modifier {
var downAt by remember { mutableLongStateOf(0L) }
return this.onPreviewKeyEvent { ev ->
when {
ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
downAt = System.currentTimeMillis()
false
}
ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
val held = System.currentTimeMillis() - downAt
if (held >= 450) onLongPress() else onClick()
true
}
else -> false
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun PosterCard( fun PosterCard(
item: MediaItem, item: MediaItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
width: Dp = 110.dp, width: Dp? = null,
height: Dp = 165.dp, height: Dp? = null,
/** Vul de grid-cel; behoud 2:3 poster-aspect. */
fillCell: Boolean = false,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
) { ) {
val progress = if (item.durationMs > 0) { val progress = if (item.durationMs > 0) {
@ -123,39 +82,31 @@ fun PosterCard(
0f 0f
} }
val useKeyHandler = onLongClick != null val shelfW = if (!fillCell && width == null) McDimens.shelfPosterWidth() else null
Surface( val resolvedW = width ?: shelfW
onClick = if (useKeyHandler) ({}) else onClick, val resolvedH = height ?: resolvedW?.let { McDimens.posterHeight(it) }
onLongClick = null,
modifier = modifier val sizeModifier = when {
.width(width) fillCell -> Modifier
.then(
if (useKeyHandler) {
Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!)
} else {
Modifier
},
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF1A1A1A),
),
) {
Box(
Modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .aspectRatio(2f / 3f)
resolvedW != null && resolvedH != null -> Modifier
.width(resolvedW)
.height(resolvedH)
else -> Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
}
Box(
modifier = modifier
.then(sizeModifier)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A1A1A)), .background(Color(0xFF1A1A1A))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
) { ) {
AsyncImage( AsyncImage(
model = item.posterUrl ?: item.backdropUrl, model = item.posterUrl ?: item.backdropUrl,
@ -168,10 +119,9 @@ fun PosterCard(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth(progress) .fillMaxWidth(progress)
.height(4.dp) .height(3.dp)
.background(McColors.Brand), .background(McColors.Brand),
) )
} }
} }
}
} }

View file

@ -0,0 +1,77 @@
package nl.vonas.mediacluster.tv.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Phone-first maatvoering. Posterbreedtes schalen met schermbreedte:
* groter scherm / hogere dichtheid → meer posters zichtbaar.
*/
object McDimens {
val screenPad: Dp = 16.dp
val screenPadTight: Dp = 12.dp
val shelfGap: Dp = 8.dp
val gridGap: Dp = 8.dp
val titleHero: TextUnit = 26.sp
val titleScreen: TextUnit = 20.sp
val titleShelf: TextUnit = 15.sp
val body: TextUnit = 14.sp
val bodySmall: TextUnit = 13.sp
val meta: TextUnit = 12.sp
val caption: TextUnit = 11.sp
val detailPosterW: Dp = 110.dp
val detailPosterH: Dp = 165.dp
val episodeStillW: Dp = 112.dp
val episodeStillH: Dp = 63.dp
@Composable
fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
@Composable
fun screenHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp
/** ~3.2–3.5 posters + peek in een shelf-rij. */
@Composable
fun shelfPosterWidth(): Dp {
val w = screenWidth()
return remember(w) {
((w - screenPad * 2) / 3.4f).coerceIn(78.dp, 118.dp)
}
}
fun posterHeight(width: Dp): Dp = width * 1.5f
/**
* Adaptive grid-cel: mikt op ~4 kolommen op typische phones,
* 5+ op grotere / tablet-achtige breedtes.
*/
@Composable
fun gridMinCell(): Dp {
val w = screenWidth()
return remember(w) {
val targetCols = when {
w.value >= 720f -> 6f
w.value >= 600f -> 5f
w.value >= 400f -> 4f
else -> 3.5f
}
((w - screenPad * 2 - gridGap * (targetCols - 1)) / targetCols)
.coerceIn(72.dp, 130.dp)
}
}
@Composable
fun billboardHeight(): Dp {
val h = screenHeight()
return remember(h) {
(h * 0.36f).coerceIn(200.dp, 280.dp)
}
}
}

View file

@ -412,17 +412,56 @@ async function getMovieCatalog(
}); });
if (catalogId === "recent-movies" && !search) { if (catalogId === "recent-movies" && !search) {
movies = await prisma.movie.findMany({ const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
where: { mediaFiles: { some: { available: true } } }, SELECT mf.movie_id
orderBy: { createdAt: "desc" }, FROM media_files mf
skip, WHERE mf.available = true AND mf.movie_id IS NOT NULL
take: CATALOG_PAGE, GROUP BY mf.movie_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${CATALOG_PAGE}
`;
const ids = idRows.map((r) => r.movie_id);
movies = ids.length
? await prisma.movie.findMany({
where: { id: { in: ids } },
include: { mediaFiles: { where: { available: true } } }, include: { mediaFiles: { where: { available: true } } },
}); }).then((rows) => {
const byId = new Map(rows.map((m) => [m.id, m]));
return ids.map((id) => byId.get(id)).filter((m): m is NonNullable<typeof m> => !!m);
})
: [];
} else if (catalogId === "4k-movies") { } else if (catalogId === "4k-movies") {
if (!search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
AND (
mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)
GROUP BY mf.movie_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${CATALOG_PAGE}
`;
const ids = idRows.map((r) => r.movie_id);
movies = ids.length
? await prisma.movie.findMany({
where: { id: { in: ids } },
include: { mediaFiles: { where: { available: true } } },
}).then((rows) => {
const byId = new Map(rows.map((m) => [m.id, m]));
return ids.map((id) => byId.get(id)).filter((m): m is NonNullable<typeof m> => !!m);
})
: [];
} else {
movies = await prisma.movie.findMany({ movies = await prisma.movie.findMany({
where: { where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}), title: { contains: search, mode: "insensitive" },
mediaFiles: { mediaFiles: {
some: { some: {
available: true, available: true,
@ -440,6 +479,27 @@ async function getMovieCatalog(
include: { mediaFiles: { where: { available: true } } }, include: { mediaFiles: { where: { available: true } } },
}); });
} }
} else if (catalogId === "movies" && !search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${CATALOG_PAGE}
`;
const ids = idRows.map((r) => r.movie_id);
movies = ids.length
? await prisma.movie.findMany({
where: { id: { in: ids } },
include: { mediaFiles: { where: { available: true } } },
}).then((rows) => {
const byId = new Map(rows.map((m) => [m.id, m]));
return ids.map((id) => byId.get(id)).filter((m): m is NonNullable<typeof m> => !!m);
})
: [];
}
return movies return movies
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES) .filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
@ -489,12 +549,41 @@ async function getSeriesCatalog(
}); });
if (catalogId === "recent-series" && !search) { if (catalogId === "recent-series" && !search) {
series = await prisma.series.findMany({ const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
where: availableFilter, SELECT e.series_id
orderBy: { createdAt: "desc" }, FROM media_files mf
skip, INNER JOIN episodes e ON e.id = mf.episode_id
take: CATALOG_PAGE, WHERE mf.available = true AND mf.episode_id IS NOT NULL
}); GROUP BY e.series_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${CATALOG_PAGE}
`;
const ids = idRows.map((r) => r.series_id);
series = ids.length
? await prisma.series.findMany({ where: { id: { in: ids } } }).then((rows) => {
const byId = new Map(rows.map((s) => [s.id, s]));
return ids.map((id) => byId.get(id)).filter((s): s is NonNullable<typeof s> => !!s);
})
: [];
} else if (catalogId === "series" && !search) {
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${CATALOG_PAGE}
`;
const ids = idRows.map((r) => r.series_id);
series = ids.length
? await prisma.series.findMany({ where: { id: { in: ids } } }).then((rows) => {
const byId = new Map(rows.map((s) => [s.id, s]));
return ids.map((id) => byId.get(id)).filter((s): s is NonNullable<typeof s> => !!s);
})
: [];
} }
return series.map((s) => ({ return series.map((s) => ({

View file

@ -1,4 +1,5 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { Prisma } from "@prisma/client";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { import {
generateSecureToken, generateSecureToken,
@ -421,22 +422,23 @@ export class ViewerService {
/** Nieuwste titels (op mediabestand-datum) voor de home billboard. */ /** Nieuwste titels (op mediabestand-datum) voor de home billboard. */
async spotlight(take: number) { async spotlight(take: number) {
const [movieFiles, episodeFiles] = await Promise.all([ const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
prisma.mediaFile.findMany({ SELECT mf.movie_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at
where: { available: true, movieId: { not: null } }, FROM media_files mf
orderBy: { createdAt: "desc" }, WHERE mf.available = true AND mf.movie_id IS NOT NULL
take: take * 3, GROUP BY mf.movie_id
include: { movie: true }, ORDER BY added_at DESC
}), LIMIT ${take * 2}
prisma.mediaFile.findMany({ `;
where: { available: true, episodeId: { not: null } }, const seriesIds = await prisma.$queryRaw<Array<{ series_id: string; added_at: Date }>>`
orderBy: { createdAt: "desc" }, SELECT e.series_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at
take: take * 4, FROM media_files mf
include: { INNER JOIN episodes e ON e.id = mf.episode_id
episode: { include: { series: true } }, WHERE mf.available = true AND mf.episode_id IS NOT NULL
}, GROUP BY e.series_id
}), ORDER BY added_at DESC
]); LIMIT ${take * 2}
`;
type Spot = { type Spot = {
type: "movie" | "series"; type: "movie" | "series";
@ -451,11 +453,22 @@ export class ViewerService {
}; };
const spots: Spot[] = []; const spots: Spot[] = [];
const seenMovies = new Set<string>(); if (movieIds.length) {
for (const f of movieFiles) { const movies = await prisma.movie.findMany({
const m = f.movie; where: { id: { in: movieIds.map((r) => r.movie_id) } },
if (!m || !f.movieId || seenMovies.has(f.movieId)) continue; include: {
seenMovies.add(f.movieId); mediaFiles: {
where: { available: true },
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1,
},
},
});
const byId = new Map(movies.map((m) => [m.id, m]));
const added = new Map(movieIds.map((r) => [r.movie_id, r.added_at.getTime()]));
for (const row of movieIds) {
const m = byId.get(row.movie_id);
if (!m) continue;
spots.push({ spots.push({
type: "movie", type: "movie",
id: m.id, id: m.id,
@ -463,18 +476,22 @@ export class ViewerService {
year: m.year, year: m.year,
posterUrl: m.posterUrl, posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl, backdropUrl: m.backdropUrl,
mediaFileId: f.id, mediaFileId: m.mediaFiles[0]?.id ?? null,
addedAt: f.createdAt.getTime(), addedAt: added.get(m.id) ?? 0,
badge: "Nieuw", badge: "Nieuw",
}); });
} }
}
const seenSeries = new Set<string>(); if (seriesIds.length) {
for (const f of episodeFiles) { const series = await prisma.series.findMany({
const s = f.episode?.series; where: { id: { in: seriesIds.map((r) => r.series_id) } },
const seriesId = f.episode?.seriesId; });
if (!s || !seriesId || seenSeries.has(seriesId)) continue; const byId = new Map(series.map((s) => [s.id, s]));
seenSeries.add(seriesId); const added = new Map(seriesIds.map((r) => [r.series_id, r.added_at.getTime()]));
for (const row of seriesIds) {
const s = byId.get(row.series_id);
if (!s) continue;
spots.push({ spots.push({
type: "series", type: "series",
id: s.id, id: s.id,
@ -483,10 +500,11 @@ export class ViewerService {
posterUrl: s.posterUrl, posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl, backdropUrl: s.backdropUrl,
mediaFileId: null, mediaFileId: null,
addedAt: f.createdAt.getTime(), addedAt: added.get(s.id) ?? 0,
badge: "Nieuwe aflevering", badge: "Nieuwe aflevering",
}); });
} }
}
return spots return spots
.sort((a, b) => b.addedAt - a.addedAt) .sort((a, b) => b.addedAt - a.addedAt)
@ -494,49 +512,103 @@ export class ViewerService {
.map(({ addedAt: _a, ...rest }) => rest); .map(({ addedAt: _a, ...rest }) => rest);
} }
async recentMovies(take: number) { async recentMovies(take: number, skip = 0) {
const files = await prisma.mediaFile.findMany({ const page = await this.moviesByRecency(skip, take);
where: { available: true, movieId: { not: null } }, return page.items;
orderBy: { createdAt: "desc" }, }
take: Math.max(take * 3, take),
include: { movie: true }, async recentSeries(take: number, skip = 0) {
const page = await this.seriesByRecency(skip, take);
return page.items;
}
/** Films gesorteerd op echte mediadatum (bestand-mtime of eerste indexering). */
private async moviesByRecency(
skip: number,
take: number,
extraWhereSql: Prisma.Sql = Prisma.empty
) {
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
GROUP BY mf.movie_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return { total, items: [] };
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
include: {
mediaFiles: {
where: { available: true },
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1,
},
},
}); });
const seen = new Set<string>(); return { total, items: this.mapMovies(ids, movies) };
const out: Array<{ }
type: "movie";
private mapMovies(
ids: string[],
movies: Array<{
id: string; id: string;
title: string; title: string;
year: number | null; year: number | null;
posterUrl: string | null; posterUrl: string | null;
backdropUrl: string | null; backdropUrl: string | null;
mediaFileId: string | null; mediaFiles: Array<{ id: string }>;
}> = []; }>
for (const f of files) { ) {
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue; const byId = new Map(movies.map((m) => [m.id, m]));
seen.add(f.movieId); return ids
out.push({ .map((id) => byId.get(id))
type: "movie", .filter((m): m is NonNullable<typeof m> => !!m)
id: f.movie.id, .map((m) => ({
title: f.movie.title, type: "movie" as const,
year: f.movie.year, id: m.id,
posterUrl: f.movie.posterUrl, title: m.title,
backdropUrl: f.movie.backdropUrl, year: m.year,
mediaFileId: f.id, posterUrl: m.posterUrl,
}); backdropUrl: m.backdropUrl,
if (out.length >= take) break; mediaFileId: m.mediaFiles[0]?.id ?? null,
} }));
return out;
} }
async recentSeries(take: number) { private async seriesByRecency(skip: number, take: number) {
const files = await prisma.mediaFile.findMany({ const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
where: { available: true, episodeId: { not: null } }, SELECT COUNT(DISTINCT e.series_id)::bigint AS count
orderBy: { createdAt: "desc" }, FROM media_files mf
take: Math.max(take * 5, take), INNER JOIN episodes e ON e.id = mf.episode_id
include: { episode: { include: { series: true } } }, WHERE mf.available = true AND mf.episode_id IS NOT NULL
}); `;
const seen = new Set<string>(); const total = Number(countRow?.count ?? 0);
const out: Array<{ const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id
ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (!ids.length) return { total, items: [] as Array<{
type: "series"; type: "series";
id: string; id: string;
title: string; title: string;
@ -544,66 +616,38 @@ export class ViewerService {
posterUrl: string | null; posterUrl: string | null;
backdropUrl: string | null; backdropUrl: string | null;
mediaFileId: string | null; mediaFileId: string | null;
}> = []; }> };
for (const f of files) {
const series = f.episode?.series; const series = await prisma.series.findMany({ where: { id: { in: ids } } });
const seriesId = f.episode?.seriesId; const byId = new Map(series.map((s) => [s.id, s]));
if (!series || !seriesId || seen.has(seriesId)) continue; return {
seen.add(seriesId); total,
out.push({ items: ids
type: "series", .map((id) => byId.get(id))
id: series.id, .filter((s): s is NonNullable<typeof s> => !!s)
title: series.title, .map((s) => ({
year: series.year, type: "series" as const,
posterUrl: series.posterUrl, id: s.id,
backdropUrl: series.backdropUrl, title: s.title,
mediaFileId: null, year: s.year,
}); posterUrl: s.posterUrl,
if (out.length >= take) break; backdropUrl: s.backdropUrl,
} mediaFileId: null as string | null,
return out; })),
};
} }
async fourKMovies(take: number) { async fourKMovies(take: number, skip = 0) {
const files = await prisma.mediaFile.findMany({ const page = await this.moviesByRecency(
where: { skip,
available: true, take,
movieId: { not: null }, Prisma.sql`AND (
OR: [ mf.resolution ILIKE '%2160%'
{ resolution: { contains: "2160" } }, OR mf.resolution ILIKE '%4K%'
{ resolution: { contains: "4K", mode: "insensitive" } }, OR mf.resolution ILIKE '%UHD%'
{ resolution: { contains: "UHD", mode: "insensitive" } }, )`
], );
}, return page.items;
orderBy: { createdAt: "desc" },
take: Math.max(take * 3, take),
include: { movie: true },
});
const seen = new Set<string>();
const out: Array<{
type: "movie";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
mediaFileId: string | null;
}> = [];
for (const f of files) {
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
seen.add(f.movieId);
out.push({
type: "movie",
id: f.movie.id,
title: f.movie.title,
year: f.movie.year,
posterUrl: f.movie.posterUrl,
backdropUrl: f.movie.backdropUrl,
mediaFileId: f.id,
});
if (out.length >= take) break;
}
return out;
} }
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
@ -634,7 +678,7 @@ export class ViewerService {
AND mf.available = true AND mf.available = true
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id GROUP BY mf.movie_id
ORDER BY MAX(mf.created_at) DESC ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${take} LIMIT ${take}
`; `;
@ -646,7 +690,7 @@ export class ViewerService {
include: { include: {
mediaFiles: { mediaFiles: {
where: { shelfId, available: true }, where: { shelfId, available: true },
orderBy: { createdAt: "desc" }, orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1, take: 1,
}, },
}, },
@ -686,7 +730,7 @@ export class ViewerService {
AND mf.available = true AND mf.available = true
AND mf.episode_id IS NOT NULL AND mf.episode_id IS NOT NULL
GROUP BY e.series_id GROUP BY e.series_id
ORDER BY MAX(mf.created_at) DESC ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${take} LIMIT ${take}
`; `;
@ -964,92 +1008,65 @@ export class ViewerService {
} }
if (id === "movies" || id === "films") { if (id === "movies" || id === "films") {
const total = await prisma.movie.count({ const page = await this.moviesByRecency(skip, take);
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 { return {
id, id,
title: "Films", title: "Films",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total, total: page.total,
items: movies.map((m) => ({ items: page.items,
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") { if (id === "series" || id === "shows") {
const total = await prisma.series.count({ const page = await this.seriesByRecency(skip, take);
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 { return {
id, id,
title: "Series", title: "Series",
kind: "SERIES" as const, kind: "SERIES" as const,
total, total: page.total,
items: series.map((s) => ({ items: page.items,
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") { if (id === "recent-movies") {
const items = await this.recentMovies(skip + take); const page = await this.moviesByRecency(skip, take);
return { return {
id, id,
title: "Recent toegevoegd", title: "Recent toegevoegd",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total: items.length + skip, total: page.total,
items: items.slice(skip, skip + take), items: page.items,
}; };
} }
if (id === "recent-series") { if (id === "recent-series") {
const items = await this.recentSeries(skip + take); const page = await this.seriesByRecency(skip, take);
return { return {
id, id,
title: "Recent toegevoegde series", title: "Recent toegevoegde series",
kind: "SERIES" as const, kind: "SERIES" as const,
total: items.length + skip, total: page.total,
items: items.slice(skip, skip + take), items: page.items,
}; };
} }
if (id === "4k-movies" || id === "4k") { if (id === "4k-movies" || id === "4k") {
const items = await this.fourKMovies(skip + take); const page = await this.moviesByRecency(
skip,
take,
Prisma.sql`AND (
mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)`
);
return { return {
id, id,
title: "4K films", title: "4K films",
kind: "MOVIE" as const, kind: "MOVIE" as const,
total: items.length + skip, total: page.total,
items: items.slice(skip, skip + take), items: page.items,
}; };
} }