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

View file

@ -1,9 +1,16 @@
package nl.vonas.mediacluster.tv.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.verticalScroll
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.layout.size
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.unit.dp
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.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
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.parseMediaVersion
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
import org.json.JSONArray
import org.json.JSONObject
@ -164,8 +164,8 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
model = logoUrl,
contentDescription = title,
modifier = Modifier
.widthIn(max = 420.dp)
.heightIn(max = 120.dp)
.widthIn(max = 280.dp)
.heightIn(max = 72.dp)
.fillMaxWidth(0.85f),
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
@ -174,11 +174,11 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
Text(
title,
color = Color.White,
fontSize = 40.sp,
fontSize = McDimens.titleHero,
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 46.sp,
lineHeight = 30.sp,
)
}
}
@ -198,7 +198,7 @@ private fun MetaChips(parts: List<String>) {
Text(
part,
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
fontSize = 14.sp,
fontSize = McDimens.meta,
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
)
}
@ -208,28 +208,15 @@ private fun MetaChips(parts: List<String>) {
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun DetailBackButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.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),
),
),
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
"Terug",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
color = Color.Unspecified,
color = Color(0xFFCFCFCF),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
@ -245,39 +232,18 @@ private fun DetailActionButton(
onClick: () -> Unit,
) {
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
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
colors = ClickableSurfaceDefaults.colors(
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),
),
)
},
Box(
modifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.clip(RoundedCornerShape(4.dp))
.background(bg)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
color = Color.Unspecified,
color = fg,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -368,26 +334,49 @@ fun MovieDetailScreen(
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop)
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth(0.58f)
.padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
.verticalScroll(scroll),
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(scroll)
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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)
MetaChips(metaParts)
SelectedQualityLine(selected)
}
}
if (director.isNotEmpty()) {
Text(
"Regie: ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = 13.sp,
fontSize = McDimens.meta,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -396,24 +385,27 @@ fun MovieDetailScreen(
Text(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
fontSize = McDimens.bodySmall,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
lineHeight = 18.sp,
)
}
if (cast.isNotEmpty()) {
Text(
"Met: ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
fontSize = McDimens.meta,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
selected?.let { ver ->
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
onPlay(ver.id, title, 0)
@ -432,17 +424,16 @@ fun MovieDetailScreen(
}
}
if (versions.size > 1) {
Spacer(Modifier.height(2.dp))
Text(
"Kwaliteit",
color = Color(0xFF8F8F8F),
fontSize = 11.sp,
fontSize = McDimens.caption,
fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(versions, key = { it.id }) { ver ->
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,
) {
val shape = RoundedCornerShape(5.dp)
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = shape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
colors = ClickableSurfaceDefaults.colors(
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,
),
),
Box(
Modifier
.clip(shape)
.background(if (selected) Color(0xEE222222) else Color(0x99101010))
.clickable(onClick = onClick)
.padding(1.dp),
) {
VersionQualityMark(version = version, selected = selected)
}
@ -592,30 +552,36 @@ fun SeriesDetailScreen(
}
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
DetailBackdrop(backdrop)
LazyColumn(
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
start = McDimens.screenPad,
end = McDimens.screenPad,
top = 12.dp,
bottom = 28.dp,
),
modifier = Modifier.statusBarsPadding(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
DetailBackButton(onClick = onBack)
Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(6.dp))
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
fontSize = McDimens.bodySmall,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
lineHeight = 18.sp,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
)
}
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp))
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
episodes.firstOrNull()?.let { ep ->
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) {
@ -627,8 +593,8 @@ fun SeriesDetailScreen(
}
}
if (seasons.size > 1) {
Spacer(Modifier.height(8.dp))
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(seasons) { s ->
@ -641,9 +607,9 @@ fun SeriesDetailScreen(
}
}
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(
episode = ep,
focusRequester = null,
@ -673,43 +639,33 @@ private fun EpisodeCard(
episode: EpisodeRow,
focusRequester: FocusRequester?,
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(
Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xCC141414))
.clickable(onClick = onClick)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = episode.stillUrl,
contentDescription = null,
modifier = Modifier
.width(160.dp)
.height(90.dp)
.clip(RoundedCornerShape(6.dp))
.width(McDimens.episodeStillW)
.height(McDimens.episodeStillH)
.clip(RoundedCornerShape(5.dp))
.background(Color(0xFF1A1A1A)),
contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"S${episode.season}E${episode.number} · ${episode.title}",
color = Color.White,
fontSize = 16.sp,
fontSize = McDimens.bodySmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -718,15 +674,14 @@ private fun EpisodeCard(
Text(
episode.overview,
color = McColors.TextDim,
fontSize = 13.sp,
fontSize = McDimens.meta,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
lineHeight = 15.sp,
)
}
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.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -62,9 +64,7 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
@ -397,6 +397,18 @@ fun LiveTvPlayerScreen(
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) {
delay(200)
runCatching { playerFocus.requestFocus() }
@ -426,7 +438,37 @@ fun LiveTvPlayerScreen(
Row(
Modifier
.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),
verticalAlignment = Alignment.CenterVertically,
) {
@ -493,7 +535,11 @@ fun LiveTvPlayerScreen(
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)),
.background(Color.Black.copy(alpha = 0.4f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { channelMenuOpen = false },
)
LiveChannelSwitcher(
categoryName = menuCategoryName,
@ -660,24 +706,14 @@ private fun LiveChannelSwitcher(
val ch = channels[index]
val isCurrent = ch.streamId == currentStreamId
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(
Modifier
.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)
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
@ -742,7 +778,6 @@ private fun LiveChannelSwitcher(
}
}
}
}
Box(
Modifier
.fillMaxWidth()

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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.TopNavBar
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@Composable
fun McNav() {
@ -646,8 +647,8 @@ private fun HomeScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 56.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
SpotlightBillboard(
@ -674,7 +675,14 @@ private fun HomeScreen(
)
}
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()
if (continueItems.isNotEmpty()) {
@ -736,15 +744,15 @@ private fun HomeScreen(
) {
Column(
Modifier
.width(440.dp)
.fillMaxWidth(0.9f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(20.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()) {
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall)
}
Spacer(Modifier.height(4.dp))
McButton("Markeren als gezien") {
@ -774,7 +782,7 @@ private fun HomeScreen(
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) }
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
val current = safeItems[index.coerceIn(0, safeItems.lastIndex)]
val billboardH = McDimens.billboardHeight()
LaunchedEffect(safeItems.size) {
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(
model = current.backdropUrl ?: current.posterUrl,
contentDescription = current.title,
@ -810,81 +819,47 @@ private fun SpotlightBillboard(
Box(
Modifier.fillMaxSize().background(
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(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(0.55f)
.padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxWidth()
.padding(horizontal = McDimens.screenPad, vertical = 16.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(
"VMC",
color = McColors.Brand,
fontSize = 13.sp,
fontSize = 11.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp,
letterSpacing = 1.5.sp,
)
if (!current.badge.isNullOrBlank()) {
Text(
current.badge!!.uppercase(),
color = Color.White,
fontSize = 12.sp,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(3.dp))
.background(McColors.Brand)
.padding(horizontal = 8.dp, vertical = 3.dp),
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Text(
current.title,
color = Color.White,
fontSize = 46.sp,
fontSize = McDimens.titleHero,
fontWeight = FontWeight.Black,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 50.sp,
lineHeight = 30.sp,
)
val meta = listOfNotNull(
current.year?.toString(),
@ -892,21 +867,21 @@ private fun SpotlightBillboard(
current.subtitle?.takeIf { it.isNotBlank() },
).joinToString(" · ")
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()) {
McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
McButton("Meer info", onClick = { onOpen(current) })
McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) })
McButton("Meer info", compact = true, onClick = { onOpen(current) })
}
}
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 ->
Box(
Modifier
.height(4.dp)
.width(if (i == index) 22.dp else 8.dp)
.height(3.dp)
.width(if (i == index) 16.dp else 6.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (i == index) Color.White else Color(0x55FFFFFF)),
)
@ -926,30 +901,36 @@ private fun ShelfBlock(
onClick: (MediaItem) -> Unit,
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(
Modifier.fillMaxWidth().padding(horizontal = 48.dp),
Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
color = Color(0xFFE5E5E5),
fontSize = 20.sp,
fontSize = McDimens.titleShelf,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
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(
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp),
horizontalArrangement = Arrangement.spacedBy(McDimens.shelfGap),
contentPadding = PaddingValues(horizontal = McDimens.screenPad, vertical = 2.dp),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
width = posterW,
height = posterH,
onClick = { onClick(item) },
onLongClick = onLongClick?.let { handler -> { handler(item) } },
)
@ -989,30 +970,34 @@ private fun SettingsScreen(
}
LazyColumn(
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
Modifier
.fillMaxSize()
.background(McColors.Bg)
.statusBarsPadding()
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp))
McButton("Uitloggen", onClick = onLogout)
Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold)
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(8.dp))
McButton("Uitloggen", compact = true, onClick = onLogout)
Spacer(Modifier.height(4.dp))
}
itemsIndexed(prefs) { index, p ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(10.dp))
.background(McColors.BgElevated)
.padding(16.dp),
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑") {
Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑", compact = true) {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
@ -1021,7 +1006,7 @@ private fun SettingsScreen(
persist(next)
}
}
McButton("↓") {
McButton("↓", compact = true) {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
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.fadeIn
import androidx.compose.animation.fadeOut
import android.util.Base64
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -31,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -56,10 +60,9 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64
import androidx.compose.runtime.rememberCoroutineScope
import java.io.File
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -372,13 +372,17 @@ fun PlayerScreen(
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) {
Box(
Modifier
.fillMaxSize()
.focusRequester(catcherFocus)
.focusable(),
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { showControls() },
)
}
@ -401,7 +405,15 @@ fun PlayerScreen(
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = menu == null,
) { hideControls() },
) {
// Top / bottom scrim zoals Netflix
Box(
Modifier
@ -430,21 +442,16 @@ fun PlayerScreen(
// Grote play/pause in het midden als gepauzeerd
if (!playing) {
Surface(
onClick = { togglePlay() },
modifier = Modifier.align(Alignment.Center),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x55FFFFFF),
focusedContainerColor = Color.White,
contentColor = Color.White,
focusedContentColor = Color.Black,
),
Box(
Modifier
.align(Alignment.Center)
.size(88.dp)
.clip(CircleShape)
.background(Color(0x55FFFFFF))
.clickable { togglePlay() },
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) {
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
}
Text("▶", color = Color.White, fontSize = 36.sp)
}
}
@ -620,45 +627,36 @@ private fun NetflixIconButton(
label: String,
primary: Boolean = false,
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(
Modifier.size(52.dp),
Modifier
.size(52.dp)
.clip(CircleShape)
.background(if (primary) Color.White else Color(0x22FFFFFF))
.clickable(onClick = onClick),
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)
@Composable
private fun NetflixTextButton(label: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color(0xFFE5E5E5),
focusedContentColor = Color.White,
),
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
color = Color.Unspecified,
color = Color(0xFFE5E5E5),
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
@ -668,21 +666,12 @@ private fun NetflixTextButton(label: String, onClick: () -> Unit) {
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
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(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(if (selected) Color(0x22FFFFFF) else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@ -691,8 +680,7 @@ private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Uni
color = McColors.Brand,
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.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@ -11,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.Job
@ -43,6 +41,7 @@ import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -60,6 +59,7 @@ fun SearchScreen(
var searchJob by remember { mutableStateOf<Job?>(null) }
val gridState = rememberLazyGridState()
val focusManager = LocalFocusManager.current
val minCell = McDimens.gridMinCell()
if (onAtTopBack != null) {
BackHandler {
@ -98,12 +98,14 @@ fun SearchScreen(
Modifier
.fillMaxSize()
.background(McColors.Bg)
.padding(horizontal = 16.dp, vertical = 8.dp),
.statusBarsPadding()
.padding(horizontal = McDimens.screenPad),
) {
Spacer(Modifier.height(8.dp))
Text(
"Zoeken",
color = Color.White,
fontSize = 22.sp,
fontSize = McDimens.titleScreen,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(10.dp))
@ -112,8 +114,10 @@ fun SearchScreen(
onValueChange = { scheduleSearch(it) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("Typ een titel…", color = McColors.TextDim) },
shape = RoundedCornerShape(12.dp),
placeholder = {
androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
colors = OutlinedTextFieldDefaults.colors(
@ -128,24 +132,23 @@ fun SearchScreen(
)
Spacer(Modifier.height(12.dp))
when {
error != null -> Text(error!!, color = McColors.Danger, fontSize = 14.sp)
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = 14.sp)
error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
query.isNotBlank() && results.isEmpty() ->
Text("Geen resultaten", color = McColors.TextDim, fontSize = 14.sp)
Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
columns = GridCells.Adaptive(minSize = minCell),
state = gridState,
contentPadding = PaddingValues(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap),
verticalArrangement = Arrangement.spacedBy(McDimens.gridGap),
modifier = Modifier.fillMaxSize(),
) {
items(results, key = { "${it.type}-${it.id}" }) { item ->
PosterCard(
item = item,
width = 110.dp,
height = 165.dp,
fillCell = true,
onClick = {
when (item.type) {
"movie" -> onOpenMovie(item.id)

View file

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

View file

@ -1,7 +1,11 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.shape.RoundedCornerShape
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.text.font.FontWeight
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.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -46,35 +37,26 @@ fun McButton(
label: String,
modifier: Modifier = Modifier,
primary: Boolean = false,
compact: Boolean = false,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
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 fgFocus = Color.Black
Surface(
onClick = onClick,
modifier = modifier.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = fgFocus,
),
val hPad = if (compact) 12.dp else 16.dp
val vPad = if (compact) 8.dp else 10.dp
Box(
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(bg)
.clickable(onClick = onClick)
.padding(horizontal = hPad, vertical = vPad),
contentAlignment = Alignment.Center,
) {
Text(
text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
color = Color.Unspecified,
fontSize = 15.sp,
color = fg,
fontSize = if (compact) 13.sp else 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -82,39 +64,16 @@ fun McButton(
}
}
private fun Key.isTvSelectKey(): Boolean =
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)
@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class)
@Composable
fun PosterCard(
item: MediaItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 110.dp,
height: Dp = 165.dp,
width: Dp? = null,
height: Dp? = null,
/** Vul de grid-cel; behoud 2:3 poster-aspect. */
fillCell: Boolean = false,
onLongClick: (() -> Unit)? = null,
) {
val progress = if (item.durationMs > 0) {
@ -123,39 +82,31 @@ fun PosterCard(
0f
}
val useKeyHandler = onLongClick != null
Surface(
onClick = if (useKeyHandler) ({}) else onClick,
onLongClick = null,
modifier = modifier
.width(width)
.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
val shelfW = if (!fillCell && width == null) McDimens.shelfPosterWidth() else null
val resolvedW = width ?: shelfW
val resolvedH = height ?: resolvedW?.let { McDimens.posterHeight(it) }
val sizeModifier = when {
fillCell -> Modifier
.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))
.background(Color(0xFF1A1A1A)),
.background(Color(0xFF1A1A1A))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
) {
AsyncImage(
model = item.posterUrl ?: item.backdropUrl,
@ -168,10 +119,9 @@ fun PosterCard(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(progress)
.height(4.dp)
.height(3.dp)
.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) {
movies = await prisma.movie.findMany({
where: { mediaFiles: { some: { available: true } } },
orderBy: { createdAt: "desc" },
skip,
take: CATALOG_PAGE,
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);
})
: [];
} 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({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
title: { contains: search, mode: "insensitive" },
mediaFiles: {
some: {
available: true,
@ -440,6 +479,27 @@ async function getMovieCatalog(
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
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
@ -489,12 +549,41 @@ async function getSeriesCatalog(
});
if (catalogId === "recent-series" && !search) {
series = await prisma.series.findMany({
where: availableFilter,
orderBy: { createdAt: "desc" },
skip,
take: CATALOG_PAGE,
});
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);
})
: [];
} 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) => ({

View file

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