Rename to VMC and polish TV nav, spotlight, Netflix player.

Home billboard shows latest titles; shelves sort by newest files; Films/Series get shelf subnav with search; player overlay closer to Netflix.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-31 22:18:37 +02:00
parent e22cef06dd
commit 06e7640ab5
17 changed files with 940 additions and 340 deletions

View file

@ -42,7 +42,7 @@ export default function LinkDevicePage() {
return ( return (
<main className="link-page"> <main className="link-page">
<div className="link-card"> <div className="link-card">
<p className="page-kicker">Media Cluster</p> <p className="page-kicker">VMC</p>
<h1>Apparaat koppelen</h1> <h1>Apparaat koppelen</h1>
<p className="muted"> <p className="muted">
Log in met je kijker-account en voer de code van je TV of telefoon in. Log in met je kijker-account en voer de code van je TV of telefoon in.

View file

@ -53,7 +53,7 @@ export default function LoginPage() {
<div className="login-brand"> <div className="login-brand">
<span className="mark">MC</span> <span className="mark">MC</span>
<div> <div>
<h1>Media Cluster</h1> <h1>VMC</h1>
</div> </div>
</div> </div>
<p className="login-sub">Authenticated access // ops</p> <p className="login-sub">Authenticated access // ops</p>

View file

@ -56,9 +56,9 @@ export function Nav() {
<> <>
<nav className="topnav"> <nav className="topnav">
<Link href="/dashboard" className="brand"> <Link href="/dashboard" className="brand">
<span className="brand-mark">MC</span> <span className="brand-mark">VMC</span>
<span className="brand-text"> <span className="brand-text">
Media Cluster Vonas Media Cluster
<small>ops console</small> <small>ops console</small>
</span> </span>
</Link> </Link>

View file

@ -6,15 +6,18 @@ plugins {
android { android {
namespace = "nl.vonas.mediacluster.tv" namespace = "nl.vonas.mediacluster.tv"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 7
versionName = "0.5.0" versionName = "0.7.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
} }
buildTypes { buildTypes {
@ -53,10 +56,12 @@ dependencies {
implementation("androidx.tv:tv-foundation:1.0.0-alpha11") implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0") implementation("androidx.tv:tv-material:1.0.0")
// Zelfde stack als Stremio-achtige clients: MediaCodec + HDMI passthrough op Shield // Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib
implementation("androidx.media3:media3-exoplayer:1.5.1") val media3 = "1.10.1"
implementation("androidx.media3:media3-ui:1.5.1") implementation("androidx.media3:media3-exoplayer:$media3")
implementation("androidx.media3:media3-datasource-okhttp:1.5.1") implementation("androidx.media3:media3-ui:$media3")
implementation("androidx.media3:media3-datasource-okhttp:$media3")
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

View file

@ -72,6 +72,7 @@ class ApiClient(
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
HomePayload( HomePayload(
continueWatching = o.optJSONArray("continueWatching").toItems(), continueWatching = o.optJSONArray("continueWatching").toItems(),
spotlight = o.optJSONArray("spotlight").toItems(),
shelves = o.optJSONArray("shelves").toShelves(), shelves = o.optJSONArray("shelves").toShelves(),
) )
} }
@ -153,6 +154,8 @@ class ApiClient(
ShelfPref( ShelfPref(
shelfId = s.getString("shelfId"), shelfId = s.getString("shelfId"),
name = s.getString("name"), name = s.getString("name"),
slug = s.optString("slug"),
kind = s.optString("kind"),
visible = s.optBoolean("visible", true), visible = s.optBoolean("visible", true),
sortOrder = s.optInt("sortOrder", i), sortOrder = s.optInt("sortOrder", i),
) )
@ -248,6 +251,7 @@ data class BrowsePayload(
data class HomePayload( data class HomePayload(
val continueWatching: List<MediaItem>, val continueWatching: List<MediaItem>,
val spotlight: List<MediaItem> = emptyList(),
val shelves: List<ShelfRow>, val shelves: List<ShelfRow>,
) )
@ -271,6 +275,7 @@ data class MediaItem(
val positionMs: Long = 0, val positionMs: Long = 0,
val durationMs: Long = 0, val durationMs: Long = 0,
val progressId: String? = null, val progressId: String? = null,
val badge: String? = null,
) )
data class PlayResult( data class PlayResult(
@ -282,6 +287,8 @@ data class PlayResult(
data class ShelfPref( data class ShelfPref(
val shelfId: String, val shelfId: String,
val name: String, val name: String,
val slug: String = "",
val kind: String = "",
val visible: Boolean, val visible: Boolean,
val sortOrder: Int, val sortOrder: Int,
) )
@ -304,6 +311,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
positionMs = o.optLong("positionMs"), positionMs = o.optLong("positionMs"),
durationMs = o.optLong("durationMs"), durationMs = o.optLong("durationMs"),
progressId = o.optString("progressId").ifBlank { null }, progressId = o.optString("progressId").ifBlank { null },
badge = o.optString("badge").ifBlank { null },
) )
) )
} }

View file

@ -1,28 +1,97 @@
package nl.vonas.mediacluster.tv.playback package nl.vonas.mediacluster.tv.playback
import android.content.Context import android.content.Context
import android.os.Handler
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.VideoRendererEventListener
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
/**
* Hardware-video + FFmpeg-audio (prefer).
* DTS/TrueHD/E-AC3 falen vaak stil bij HDMI-passthrough naar een TV zonder decoder;
* FFmpeg decodeert naar PCM zodat geluid altijd via de TV/speakers werkt.
*/
@OptIn(UnstableApi::class)
private class McRenderersFactory(context: Context) : NextRenderersFactory(context) {
init {
setEnableDecoderFallback(true)
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
}
override fun buildAudioRenderers(
context: Context,
extensionRendererMode: Int,
mediaCodecSelector: MediaCodecSelector,
enableDecoderFallback: Boolean,
audioSink: AudioSink,
eventHandler: Handler,
eventListener: AudioRendererEventListener,
out: ArrayList<Renderer>,
) {
super.buildAudioRenderers(
context,
EXTENSION_RENDERER_MODE_PREFER,
mediaCodecSelector,
enableDecoderFallback,
audioSink,
eventHandler,
eventListener,
out,
)
}
override fun buildVideoRenderers(
context: Context,
extensionRendererMode: Int,
mediaCodecSelector: MediaCodecSelector,
enableDecoderFallback: Boolean,
eventHandler: Handler,
eventListener: VideoRendererEventListener,
allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>,
) {
// Shield HEVC/AV1: altijd MediaCodec, nooit software-video
super.buildVideoRenderers(
context,
EXTENSION_RENDERER_MODE_OFF,
mediaCodecSelector,
enableDecoderFallback,
eventHandler,
eventListener,
allowedVideoJoiningTimeMs,
out,
)
}
}
@OptIn(UnstableApi::class)
fun buildTvPlayer(context: Context): ExoPlayer { fun buildTvPlayer(context: Context): ExoPlayer {
val trackSelector = DefaultTrackSelector(context).apply { val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters() parameters = buildUponParameters()
// Laat DTS/TrueHD door (Shield HDMI passthrough) — niet filteren als "unsupported"
.setExceedRendererCapabilitiesIfNecessary(true) .setExceedRendererCapabilitiesIfNecessary(true)
.setAllowAudioMixedMimeTypeAdaptiveness(true) .setAllowAudioMixedMimeTypeAdaptiveness(true)
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
.setPreferredAudioLanguage("nl") .setPreferredAudioLanguage("nl")
.setPreferredTextLanguage("nl")
.build() .build()
} }
val renderersFactory = DefaultRenderersFactory(context.applicationContext) val renderersFactory = McRenderersFactory(context.applicationContext)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
val audioAttributes = AudioAttributes.Builder() val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
@ -65,6 +134,21 @@ fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
return out return out
} }
/** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */
fun ExoPlayer.ensureAudioSelected() {
if (currentTracks.hasAudioSelected()) {
volume = 1f
return
}
val tracks = listTracks(C.TRACK_TYPE_AUDIO)
if (tracks.isEmpty()) return
val preferred = tracks.firstOrNull { t ->
val l = t.label.lowercase()
!l.contains("comment") && !l.contains("description") && !l.contains("ad ")
} ?: tracks.first()
selectTrack(preferred)
}
fun ExoPlayer.selectTrack(track: PlayerTrack) { fun ExoPlayer.selectTrack(track: PlayerTrack) {
val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
trackSelectionParameters = trackSelectionParameters trackSelectionParameters = trackSelectionParameters
@ -89,6 +173,7 @@ fun ExoPlayer.disableTrackType(type: Int) {
private fun shortMime(mime: String): String = when { private fun shortMime(mime: String): String = when {
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3" MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3" MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
MimeTypes.AUDIO_DTS_HD == mime || mime.contains("dts.hd") || mime.contains("dts-hd") -> "DTS-HD"
MimeTypes.AUDIO_DTS == mime || mime.contains("dts") -> "DTS" MimeTypes.AUDIO_DTS == mime || mime.contains("dts") -> "DTS"
mime.contains("truehd") || mime.contains("mlp") -> "TrueHD" mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC" MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"

View file

@ -34,6 +34,7 @@ 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
@ -61,6 +62,7 @@ import nl.vonas.mediacluster.tv.data.ShelfPref
import nl.vonas.mediacluster.tv.ui.components.MainTab import nl.vonas.mediacluster.tv.ui.components.MainTab
import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.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
@ -191,8 +193,47 @@ private fun MainHub(
var tab by remember { mutableStateOf(MainTab.Home) } var tab by remember { mutableStateOf(MainTab.Home) }
var navFocused by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) }
val navFocus = remember { FocusRequester() } val navFocus = remember { FocusRequester() }
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var movieCatalog by remember { mutableStateOf("movies") }
var seriesCatalog by remember { mutableStateOf("series") }
LaunchedEffect(Unit) {
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
}
val movieSubs = remember(shelfPrefs) {
buildList {
add(SubNavItem("movies", "Alles"))
add(SubNavItem("recent-movies", "Recent"))
add(SubNavItem("4k-movies", "4K"))
shelfPrefs.filter { it.kind.equals("MOVIE", true) && it.visible }.forEach { p ->
add(SubNavItem(p.slug.ifBlank { p.shelfId }, p.name))
}
add(SubNavItem("__search__", "Zoeken"))
}
}
val seriesSubs = remember(shelfPrefs) {
buildList {
add(SubNavItem("series", "Alles"))
add(SubNavItem("recent-series", "Recent"))
shelfPrefs.filter { it.kind.equals("SERIES", true) && it.visible }.forEach { p ->
add(SubNavItem(p.slug.ifBlank { p.shelfId }, p.name))
}
add(SubNavItem("__search__", "Zoeken"))
}
}
val subItems = when (tab) {
MainTab.Movies -> movieSubs
MainTab.Series -> seriesSubs
else -> emptyList()
}
val selectedSubId = when (tab) {
MainTab.Movies -> movieCatalog
MainTab.Series -> seriesCatalog
else -> null
}
// Netflix-achtig: Back brengt je naar de top-nav
BackHandler(enabled = !navFocused && tab != MainTab.Home) { BackHandler(enabled = !navFocused && tab != MainTab.Home) {
tab = MainTab.Home tab = MainTab.Home
navFocused = true navFocused = true
@ -216,6 +257,25 @@ private fun MainHub(
onSelect = { onSelect = {
tab = it tab = it
navFocused = true navFocused = true
when (it) {
MainTab.Movies -> if (movieCatalog == "__search__") movieCatalog = "movies"
MainTab.Series -> if (seriesCatalog == "__search__") seriesCatalog = "series"
else -> Unit
}
},
subItems = subItems,
selectedSubId = selectedSubId,
onSelectSub = { item ->
if (item.id == "__search__") {
tab = MainTab.Search
navFocused = true
} else {
when (tab) {
MainTab.Movies -> movieCatalog = item.id
MainTab.Series -> seriesCatalog = item.id
else -> Unit
}
}
}, },
) )
} }
@ -229,15 +289,15 @@ private fun MainHub(
) )
MainTab.Movies -> BrowseScreen( MainTab.Movies -> BrowseScreen(
api = api, api = api,
catalogId = "movies", catalogId = movieCatalog,
titleHint = "Films", titleHint = movieSubs.firstOrNull { it.id == movieCatalog }?.label ?: "Films",
onOpenMovie = onOpenMovie, onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries, onOpenSeries = onOpenSeries,
) )
MainTab.Series -> BrowseScreen( MainTab.Series -> BrowseScreen(
api = api, api = api,
catalogId = "series", catalogId = seriesCatalog,
titleHint = "Series", titleHint = seriesSubs.firstOrNull { it.id == seriesCatalog }?.label ?: "Series",
onOpenMovie = onOpenMovie, onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries, onOpenSeries = onOpenSeries,
) )
@ -260,9 +320,11 @@ private fun MainHub(
private fun Splash() { private fun Splash() {
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp) Text("VMC", color = McColors.Accent, fontSize = 42.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(6.dp))
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp) Text("Vonas Media Cluster", color = McColors.TextDim, fontSize = 14.sp, letterSpacing = 1.sp)
Spacer(Modifier.height(12.dp))
Text("Laden…", color = McColors.TextDim, fontSize = 15.sp)
} }
} }
} }
@ -326,7 +388,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
.background(McColors.BgElevated.copy(alpha = 0.92f)) .background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp), .padding(40.dp),
) { ) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp) Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
@ -357,35 +419,34 @@ private fun HomeScreen(
} }
} }
val hero = home?.continueWatching?.firstOrNull() val spotlight = home?.spotlight.orEmpty().ifEmpty {
?: home?.shelves?.firstOrNull()?.items?.firstOrNull() home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
}
LazyColumn( LazyColumn(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp), contentPadding = PaddingValues(bottom = 56.dp),
verticalArrangement = Arrangement.spacedBy(26.dp), verticalArrangement = Arrangement.spacedBy(28.dp),
) { ) {
item { item {
HeroHeader( SpotlightBillboard(
hero = hero, items = spotlight,
onPlayHero = { onPlay = { item ->
val h = hero ?: return@HeroHeader
when { when {
!h.mediaFileId.isNullOrBlank() -> onPlay( !item.mediaFileId.isNullOrBlank() -> onPlay(
h.mediaFileId!!, item.mediaFileId!!,
h.id.takeIf { h.type == "movie" }, item.id.takeIf { item.type == "movie" },
h.id.takeIf { h.type == "episode" }, item.id.takeIf { item.type == "episode" },
null, null,
if (h.type == "episode") "EPISODE" else "MOVIE", if (item.type == "episode") "EPISODE" else "MOVIE",
h.positionMs, item.positionMs,
) )
h.type == "movie" -> onOpenMovie(h.id) item.type == "movie" -> onOpenMovie(item.id)
else -> onOpenSeries(h.id) else -> onOpenSeries(item.id)
} }
}, },
onOpenHero = { onOpen = { item ->
val h = hero ?: return@HeroHeader if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
}, },
) )
} }
@ -435,48 +496,134 @@ private fun HomeScreen(
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun HeroHeader( private fun SpotlightBillboard(
hero: MediaItem?, items: List<MediaItem>,
onPlayHero: () -> Unit, onPlay: (MediaItem) -> Unit,
onOpenHero: () -> Unit, onOpen: (MediaItem) -> Unit,
) { ) {
Box(Modifier.fillMaxWidth().height(420.dp)) { 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)]
LaunchedEffect(safeItems.size) {
if (safeItems.size <= 1) return@LaunchedEffect
while (true) {
delay(6500)
index = (index + 1) % safeItems.size
}
}
Box(Modifier.fillMaxWidth().height(480.dp)) {
AsyncImage( AsyncImage(
model = hero?.backdropUrl ?: hero?.posterUrl, model = current.backdropUrl ?: current.posterUrl,
contentDescription = null, contentDescription = current.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(McColors.Bg, Color.Transparent, Color.Transparent)), Brush.verticalGradient(
listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)),
),
), ),
) )
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.58f) .fillMaxWidth(0.55f)
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp), .padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
"VMC",
color = McColors.Brand,
fontSize = 13.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp,
)
if (!current.badge.isNullOrBlank()) {
Text(
current.badge!!.uppercase(),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(3.dp))
.background(McColors.Brand)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Text( Text(
hero?.title ?: "Welkom terug", current.title,
color = Color.White, color = Color.White,
fontSize = 42.sp, fontSize = 46.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Black,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 46.sp, lineHeight = 50.sp,
) )
if (!hero?.subtitle.isNullOrBlank()) { val meta = listOfNotNull(
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp) current.year?.toString(),
if (current.type == "series") "Serie" else "Film",
current.subtitle?.takeIf { it.isNotBlank() },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (hero != null) { if (current.id.isNotBlank()) {
McButton(if (hero.positionMs > 30_000) "Verder kijken" else "Afspelen", primary = true, onClick = onPlayHero) McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
McButton("Details", onClick = onOpenHero) McButton("Meer info", onClick = { onOpen(current) })
}
}
if (safeItems.size > 1) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) {
safeItems.indices.forEach { i ->
Box(
Modifier
.height(4.dp)
.width(if (i == index) 22.dp else 8.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (i == index) Color.White else Color(0x55FFFFFF)),
)
}
} }
} }
} }
@ -491,26 +638,25 @@ private fun ShelfBlock(
onSeeAll: (() -> Unit)?, onSeeAll: (() -> Unit)?,
onClick: (MediaItem) -> Unit, onClick: (MediaItem) -> Unit,
) { ) {
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(start = 48.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row( Row(
Modifier.fillMaxWidth().padding(end = 40.dp), Modifier.fillMaxWidth().padding(end = 48.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
title.uppercase(), title,
color = Color(0xFFE8EEF8), color = Color(0xFFE5E5E5),
fontSize = 17.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.1.sp,
) )
if (onSeeAll != null) { if (onSeeAll != null) {
McButton("Alles bekijken") { onSeeAll() } McButton("Alles") { onSeeAll() }
} }
} }
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 40.dp), contentPadding = PaddingValues(end = 48.dp),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(item, onClick = { onClick(item) }) PosterCard(item, onClick = { onClick(item) })
@ -698,7 +844,7 @@ private fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { item {
Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) 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) Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))

View file

@ -3,6 +3,9 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -14,9 +17,12 @@ 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.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
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.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -49,7 +55,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 kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -57,6 +65,7 @@ import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
import nl.vonas.mediacluster.tv.playback.buildTvPlayer import nl.vonas.mediacluster.tv.playback.buildTvPlayer
import nl.vonas.mediacluster.tv.playback.disableTrackType import nl.vonas.mediacluster.tv.playback.disableTrackType
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.listTracks 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
@ -77,6 +86,7 @@ fun PlayerScreen(
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildTvPlayer(context) } val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() } val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
@ -88,13 +98,24 @@ fun PlayerScreen(
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) } var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text var menu by remember { mutableStateOf<String?>(null) } // audio | text
fun showControls() {
controls = true
}
fun hideControls() {
if (menu == null) controls = false
}
fun togglePlay() {
if (player.isPlaying) player.pause() else player.play()
showControls()
}
BackHandler { BackHandler {
when { when {
menu != null -> menu = null menu != null -> menu = null
controls -> onBack() controls -> onBack()
else -> { else -> showControls()
controls = true
}
} }
} }
@ -118,6 +139,7 @@ fun PlayerScreen(
loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
if (state == Player.STATE_READY) { if (state == Player.STATE_READY) {
player.volume = 1f player.volume = 1f
player.ensureAudioSelected()
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
} }
@ -130,6 +152,7 @@ fun PlayerScreen(
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
player.ensureAudioSelected()
player.volume = 1f player.volume = 1f
} }
@ -186,22 +209,21 @@ fun PlayerScreen(
LaunchedEffect(controls, menu, playing) { LaunchedEffect(controls, menu, playing) {
if (controls && menu == null && playing) { if (controls && menu == null && playing) {
delay(5_000) delay(4_500)
controls = false hideControls()
} }
} }
LaunchedEffect(controls) { LaunchedEffect(controls, menu) {
delay(40)
if (menu != null) return@LaunchedEffect
if (controls) { if (controls) {
delay(50)
runCatching { controlsFocus.requestFocus() } runCatching { controlsFocus.requestFocus() }
} else {
runCatching { catcherFocus.requestFocus() }
} }
} }
fun showControls() {
controls = true
}
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -210,12 +232,22 @@ fun PlayerScreen(
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) { when (ev.key) {
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
// Netflix: OK toont bediening én pauzeert/hervat
togglePlay()
true
} else {
false
}
}
Key.DirectionDown -> {
if (menu != null) return@onPreviewKeyEvent false if (menu != null) return@onPreviewKeyEvent false
if (!controls) { if (!controls) {
showControls() showControls()
true true
} else { } else {
false // laat knoppen de OK afhandelen false
} }
} }
Key.DirectionLeft -> { Key.DirectionLeft -> {
@ -230,9 +262,10 @@ fun PlayerScreen(
showControls() showControls()
true true
} }
Key.DirectionUp, Key.DirectionDown, Key.Menu -> { Key.DirectionUp, Key.Menu -> {
if (menu != null) return@onPreviewKeyEvent false
showControls() showControls()
false true
} }
else -> false else -> false
} }
@ -243,18 +276,29 @@ fun PlayerScreen(
PlayerView(ctx).apply { PlayerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
this.player = player this.player = player
useController = false // eigen Compose-controls (D-pad focus) useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
keepScreenOn = true keepScreenOn = true
isFocusable = false isFocusable = false
isFocusableInTouchMode = false isFocusableInTouchMode = false
descendantFocusability = FrameLayout.FOCUS_BLOCK_DESCENDANTS
} }
}, },
update = { it.player = player }, update = { it.player = player },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet
if (!controls && menu == null && error == null) {
Box(
Modifier
.fillMaxSize()
.focusRequester(catcherFocus)
.focusable(),
)
}
if (loading && error == null) { if (loading && error == null) {
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center)) Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
} }
@ -269,66 +313,110 @@ fun PlayerScreen(
} }
} }
if (controls && error == null) { AnimatedVisibility(
Box( visible = controls && error == null,
Modifier enter = fadeIn(),
.fillMaxSize() exit = fadeOut(),
.background( ) {
Brush.verticalGradient( Box(Modifier.fillMaxSize()) {
listOf(Color(0x99000000), Color.Transparent, Color(0xF2000000)), // Top / bottom scrim zoals Netflix
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color(0xCC000000),
Color.Transparent,
Color.Transparent,
Color(0xF2000000),
),
),
), ),
) )
.focusable(),
) { // Terug linksboven
Row(
Modifier
.align(Alignment.TopStart)
.padding(28.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetflixTextButton("← Terug", onClick = onBack)
}
// 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.size(88.dp), contentAlignment = Alignment.Center) {
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
}
}
}
Column( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 28.dp), .padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(5.dp) .height(3.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(2.dp))
.background(Color(0x44FFFFFF)), .background(Color(0x44FFFFFF)),
) { ) {
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Box( Box(
Modifier Modifier
.fillMaxWidth(pct) .fillMaxWidth(pct)
.height(5.dp) .height(3.dp)
.background(McColors.Accent), .background(McColors.Brand),
) )
} }
Text(
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
color = Color(0xFFC5CDDB),
fontSize = 14.sp,
)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), Modifier.fillMaxWidth(),
modifier = Modifier.focusRequester(controlsFocus), horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
McButton("Terug", onClick = onBack) Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
McButton(if (playing) "Pauze" else "Play", primary = true) { Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
if (player.isPlaying) player.pause() else player.play() }
showControls()
} Row(
McButton("-10s") { Modifier
.fillMaxWidth()
.focusRequester(controlsFocus),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
NetflixIconButton("↺ 10") {
player.seekBack() player.seekBack()
showControls() showControls()
} }
McButton("+30s") { NetflixIconButton("10 ↻") {
player.seekForward() player.seekForward()
showControls() showControls()
} }
McButton("Audio (${audioTracks.size})") { Spacer(Modifier.weight(1f))
NetflixTextButton("Audio") {
menu = "audio" menu = "audio"
showControls() showControls()
} }
McButton("Ondertitel") { NetflixTextButton("Ondertiteling") {
menu = "text" menu = "text"
showControls() showControls()
} }
@ -343,50 +431,134 @@ fun PlayerScreen(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xDD05070D)), .background(Color(0x99000000)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.CenterEnd,
) { ) {
Column( Column(
Modifier Modifier
.width(520.dp) .fillMaxHeight()
.clip(RoundedCornerShape(16.dp)) .width(400.dp)
.background(Color(0xFF121A2A)) .background(Color(0xF20A0A0A))
.padding(22.dp), .padding(horizontal = 28.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text( Text(
if (isAudio) "Audiospoor kiezen" else "Ondertitel kiezen", if (isAudio) "Audio" else "Ondertiteling",
color = Color.White, color = Color.White,
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(12.dp))
if (!isAudio) { if (!isAudio) {
McButton("Ondertitel uit") { NetflixTrackRow("Uit", selected = false) {
player.disableTrackType(C.TRACK_TYPE_TEXT) player.disableTrackType(C.TRACK_TYPE_TEXT)
menu = null menu = null
} }
} }
if (tracks.isEmpty()) { if (tracks.isEmpty()) {
Text("Geen sporen gevonden", color = Color(0xFF9AA6BD)) Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
} }
LazyColumn( LazyColumn(
modifier = Modifier.height(300.dp), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track -> items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
McButton(track.label) { NetflixTrackRow(track.label, selected = false) {
player.selectTrack(track) player.selectTrack(track)
menu = null menu = null
} }
} }
} }
McButton("Sluiten") { menu = null } Spacer(Modifier.height(8.dp))
NetflixTextButton("Sluiten") { menu = null }
} }
} }
} }
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
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),
contentAlignment = Alignment.Center,
) {
Text(label, color = Color.Unspecified, 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,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@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()
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (selected) "✓ " else " ",
color = McColors.Brand,
fontSize = 15.sp,
)
Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
}
}
private fun formatTime(ms: Long): String { private fun formatTime(ms: Long): String {
if (ms <= 0) return "0:00" if (ms <= 0) return "0:00"
val total = ms / 1000 val total = ms / 1000

View file

@ -2,8 +2,6 @@ package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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
@ -14,7 +12,6 @@ import androidx.compose.runtime.Composable
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.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -22,6 +19,7 @@ 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.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow import androidx.tv.material3.Glow
@ -39,28 +37,29 @@ fun McButton(
primary: Boolean = false, primary: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = if (primary) McColors.Accent else Color(0xFF1A2336) val bg = if (primary) Color.White else Color(0x33FFFFFF)
val bgFocus = if (primary) Color(0xFF8BB8FF) else Color(0xFF2A3A58) val bgFocus = if (primary) Color.White else Color.White
val fg = if (primary) Color(0xFF061018) else Color(0xFFF5F7FB) val fg = if (primary) Color.Black else Color.White
val fgFocus = Color.Black
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow( glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent.copy(alpha = 0.7f), elevation = 10.dp), focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
), ),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = bg, containerColor = bg,
focusedContainerColor = bgFocus, focusedContainerColor = bgFocus,
contentColor = fg, contentColor = fg,
focusedContentColor = fg, focusedContentColor = fgFocus,
), ),
) { ) {
Text( Text(
text = label.ifBlank { "—" }, text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp), modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
color = fg, color = Color.Unspecified,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@ -75,8 +74,8 @@ fun PosterCard(
item: MediaItem, item: MediaItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
width: Dp = 152.dp, width: Dp = 140.dp,
height: Dp = 228.dp, height: Dp = 210.dp,
) { ) {
val progress = if (item.durationMs > 0) { val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f) (item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
@ -87,87 +86,49 @@ fun PosterCard(
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.width(width), modifier = modifier.width(width),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
glow = ClickableSurfaceDefaults.glow( border = ClickableSurfaceDefaults.border(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 16.dp), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF101827), containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF182338), focusedContainerColor = Color(0xFF1A1A1A),
), ),
) { ) {
Column { Box(
Box( Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .height(height)
.height(height) .clip(RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .background(Color(0xFF1A1A1A)),
.background(Color(0xFF0B1220)), ) {
) { AsyncImage(
AsyncImage( model = item.posterUrl ?: item.backdropUrl,
model = item.posterUrl ?: item.backdropUrl, contentDescription = item.title,
contentDescription = item.title, modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
contentScale = ContentScale.Crop, )
) if (progress > 0.02f) {
Box( Box(
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .height(3.dp)
.background( .background(Color(0x55FFFFFF)),
Brush.verticalGradient( ) {
listOf(Color.Transparent, Color(0xCC000000)),
),
),
)
if (progress > 0.02f) {
Box( Box(
Modifier Modifier
.align(Alignment.BottomStart) .fillMaxWidth(progress)
.fillMaxWidth()
.height(3.dp) .height(3.dp)
.background(Color(0x55FFFFFF)), .background(McColors.Accent),
) { )
Box(
Modifier
.fillMaxWidth(progress)
.height(3.dp)
.background(McColors.Accent),
)
}
} }
} }
Text(
item.title,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = Color(0xFFF5F7FB),
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
)
if (!item.subtitle.isNullOrBlank()) {
Text(
item.subtitle,
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
color = Color(0xFF9AA6BD),
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (item.year != null) {
Text(
item.year.toString(),
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
color = Color(0xFF9AA6BD),
fontSize = 11.sp,
)
} else {
Spacer(Modifier.height(6.dp))
}
} }
} }
} }

View file

@ -1,17 +1,32 @@
package nl.vonas.mediacluster.tv.ui.components package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.animation.animateColorAsState
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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.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.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 nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
@ -23,33 +38,105 @@ enum class MainTab(val label: String) {
Settings("Instellingen"), Settings("Instellingen"),
} }
data class SubNavItem(
val id: String,
val label: String,
)
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun TopNavBar( fun TopNavBar(
selected: MainTab, selected: MainTab,
onSelect: (MainTab) -> Unit, onSelect: (MainTab) -> Unit,
subItems: List<SubNavItem> = emptyList(),
selectedSubId: String? = null,
onSelectSub: (SubNavItem) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Column(modifier.fillMaxWidth()) {
modifier Row(
.fillMaxWidth() Modifier
.padding(horizontal = 28.dp, vertical = 14.dp), .fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(10.dp), .padding(horizontal = 40.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
) { verticalAlignment = Alignment.CenterVertically,
Text( ) {
"MC", Text(
color = McColors.Accent, "VMC",
fontSize = 22.sp, color = McColors.Brand,
fontWeight = FontWeight.Black, fontSize = 28.sp,
modifier = Modifier.padding(end = 12.dp), fontWeight = FontWeight.Black,
) modifier = Modifier.padding(end = 16.dp),
MainTab.entries.forEach { tab ->
McButton(
label = tab.label,
primary = selected == tab,
onClick = { onSelect(tab) },
) )
MainTab.entries.forEach { tab ->
val active = selected == tab
val color by animateColorAsState(
if (active) Color.White else Color(0xFF808080),
label = "tabColor",
)
Surface(
onClick = { onSelect(tab) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = color,
focusedContentColor = Color.White,
),
) {
Column(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
tab.label,
color = Color.Unspecified,
fontSize = 16.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(if (active) 28.dp else 0.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (active) McColors.Brand else Color.Transparent),
)
}
}
}
}
if (subItems.isNotEmpty()) {
LazyRow(
contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
items(subItems, key = { it.id }) { item ->
val active = item.id == selectedSubId
Surface(
onClick = { onSelectSub(item) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color.White else Color(0x22FFFFFF),
focusedContainerColor = Color.White,
contentColor = if (active) Color.Black else Color(0xFFE0E0E0),
focusedContentColor = Color.Black,
),
) {
Text(
item.label,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = Color.Unspecified,
fontSize = 13.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
)
}
}
}
} }
} }
} }

View file

@ -1,22 +1,25 @@
package nl.vonas.mediacluster.tv.ui.theme package nl.vonas.mediacluster.tv.ui.theme
object McColors { object McColors {
val Bg = androidx.compose.ui.graphics.Color(0xFF0B0F16) // Netflix-achtige basis: bijna zwart, weinig blauw “dashboard”
val BgElevated = androidx.compose.ui.graphics.Color(0xFF121826) val Bg = androidx.compose.ui.graphics.Color(0xFF0B0B0B)
val Surface = androidx.compose.ui.graphics.Color(0xFF1A2336) val BgElevated = androidx.compose.ui.graphics.Color(0xFF141414)
val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFF2A3A58) val Surface = androidx.compose.ui.graphics.Color(0xFF1F1F1F)
val Accent = androidx.compose.ui.graphics.Color(0xFF5B9DFF) val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFFFFFFFF)
val AccentSoft = androidx.compose.ui.graphics.Color(0x335B9DFF) val Accent = androidx.compose.ui.graphics.Color(0xFFE50914)
val Text = androidx.compose.ui.graphics.Color(0xFFF5F7FB) val AccentSoft = androidx.compose.ui.graphics.Color(0x33E50914)
val TextDim = androidx.compose.ui.graphics.Color(0xFF9AA6BD) val Brand = androidx.compose.ui.graphics.Color(0xFFE50914)
val Danger = androidx.compose.ui.graphics.Color(0xFFFF7B8A) val Text = androidx.compose.ui.graphics.Color(0xFFF5F5F5)
val Success = androidx.compose.ui.graphics.Color(0xFF7DFFB3) val TextDim = androidx.compose.ui.graphics.Color(0xFFB3B3B3)
val Danger = androidx.compose.ui.graphics.Color(0xFFFF6B6B)
val Success = androidx.compose.ui.graphics.Color(0xFF46D369)
val HeroScrim = androidx.compose.ui.graphics.Brush.verticalGradient( val HeroScrim = androidx.compose.ui.graphics.Brush.verticalGradient(
colors = listOf( colors = listOf(
androidx.compose.ui.graphics.Color.Transparent, androidx.compose.ui.graphics.Color.Transparent,
androidx.compose.ui.graphics.Color(0x990B0F16), androidx.compose.ui.graphics.Color(0x660B0B0B),
androidx.compose.ui.graphics.Color(0xFF0B0F16), androidx.compose.ui.graphics.Color(0xEE0B0B0B),
androidx.compose.ui.graphics.Color(0xFF0B0B0B),
), ),
) )
} }

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Media Cluster</string> <string name="app_name">VMC</string>
</resources> </resources>

View file

@ -1,5 +1,5 @@
plugins { plugins {
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.2.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
} }

View file

@ -1,9 +1,9 @@
# Lichter voor ~16GB RAM + Android Studio open # Lichter voor ~16GB RAM + Android Studio open
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m -Dfile.encoding=UTF-8
org.gradle.parallel=false org.gradle.parallel=false
org.gradle.workers.max=2 org.gradle.workers.max=1
org.gradle.caching=true org.gradle.caching=true
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.daemon.jvmargs=-Xmx1024m kotlin.daemon.jvmargs=-Xmx768m

View file

@ -12,5 +12,5 @@ dependencyResolutionManagement {
mavenCentral() mavenCentral()
} }
} }
rootProject.name = "MediaClusterTv" rootProject.name = "VMC"
include(":app") include(":app")

View file

@ -79,7 +79,7 @@ async function buildManifest(tokenId: string | null, config: Config) {
return { return {
id: "com.mediacluster.library", id: "com.mediacluster.library",
version: `1.7.${shelfPatch}`, version: `1.7.${shelfPatch}`,
name: "Media Cluster", name: "VMC",
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG", description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs], catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
resources: [ resources: [

View file

@ -289,17 +289,19 @@ export class ViewerService {
} }
async home(viewerId: string) { async home(viewerId: string) {
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([ const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] =
prisma.libraryShelf.findMany({ await Promise.all([
where: { enabled: true }, prisma.libraryShelf.findMany({
orderBy: { sortOrder: "asc" }, where: { enabled: true },
}), orderBy: { sortOrder: "asc" },
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }), }),
this.continueWatching(viewerId), prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
this.recentMovies(48), this.continueWatching(viewerId),
this.recentSeries(36), this.recentMovies(48),
this.fourKMovies(36), this.recentSeries(36),
]); this.fourKMovies(36),
this.spotlight(12),
]);
const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
const ordered = shelves const ordered = shelves
@ -368,51 +370,154 @@ export class ViewerService {
return { return {
continueWatching: continueItems, continueWatching: continueItems,
spotlight,
shelves: rows, shelves: rows,
}; };
} }
async recentMovies(take: number) { /** Nieuwste titels (op mediabestand-datum) voor de home billboard. */
const movies = await prisma.movie.findMany({ async spotlight(take: number) {
where: { mediaFiles: { some: { available: true } } }, const [movieFiles, episodeFiles] = await Promise.all([
orderBy: { createdAt: "desc" }, prisma.mediaFile.findMany({
take, where: { available: true, movieId: { not: null } },
include: { orderBy: { createdAt: "desc" },
mediaFiles: { take: take * 3,
where: { available: true }, include: { movie: true },
orderBy: { createdAt: "desc" }, }),
take: 1, prisma.mediaFile.findMany({
where: { available: true, episodeId: { not: null } },
orderBy: { createdAt: "desc" },
take: take * 4,
include: {
episode: { include: { series: true } },
}, },
}, }),
]);
type Spot = {
type: "movie" | "series";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
mediaFileId: string | null;
addedAt: number;
badge: string;
};
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);
spots.push({
type: "movie",
id: m.id,
title: m.title,
year: m.year,
posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl,
mediaFileId: f.id,
addedAt: f.createdAt.getTime(),
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);
spots.push({
type: "series",
id: s.id,
title: s.title,
year: s.year,
posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
mediaFileId: null,
addedAt: f.createdAt.getTime(),
badge: "Nieuwe aflevering",
});
}
return spots
.sort((a, b) => b.addedAt - a.addedAt)
.slice(0, take)
.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 },
}); });
return movies.map((m) => ({ const seen = new Set<string>();
type: "movie" as const, const out: Array<{
id: m.id, type: "movie";
title: m.title, id: string;
year: m.year, title: string;
posterUrl: m.posterUrl, year: number | null;
backdropUrl: m.backdropUrl, posterUrl: string | null;
mediaFileId: m.mediaFiles[0]?.id ?? 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 recentSeries(take: number) { async recentSeries(take: number) {
const series = await prisma.series.findMany({ const files = await prisma.mediaFile.findMany({
where: { where: { available: true, episodeId: { not: null } },
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take, take: Math.max(take * 5, take),
include: { episode: { include: { series: true } } },
}); });
return series.map((s) => ({ const seen = new Set<string>();
type: "series" as const, const out: Array<{
id: s.id, type: "series";
title: s.title, id: string;
year: s.year, title: string;
posterUrl: s.posterUrl, year: number | null;
backdropUrl: s.backdropUrl, posterUrl: string | null;
mediaFileId: null as 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;
} }
async fourKMovies(take: number) { async fourKMovies(take: number) {
@ -458,52 +563,80 @@ export class ViewerService {
} }
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
// Sorteer op laatst toegevoegd mediabestand (niet op metadata-aanmaakdatum)
if (kind === "MOVIE") { if (kind === "MOVIE") {
const movies = await prisma.movie.findMany({ const files = await prisma.mediaFile.findMany({
where: { where: { shelfId, available: true, movieId: { not: null } },
mediaFiles: { some: { shelfId, available: true } },
},
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take, take: Math.max(take * 4, take),
include: { include: { movie: true },
mediaFiles: {
where: { shelfId, available: true },
orderBy: { createdAt: "desc" },
take: 1,
},
},
}); });
return movies.map((m) => ({ const seen = new Set<string>();
type: "movie" as const, const out: Array<{
id: m.id, type: "movie";
title: m.title, id: string;
year: m.year, title: string;
posterUrl: m.posterUrl, year: number | null;
backdropUrl: m.backdropUrl, posterUrl: string | null;
mediaFileId: m.mediaFiles[0]?.id ?? 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;
} }
const series = await prisma.series.findMany({ const files = await prisma.mediaFile.findMany({
where: { where: {
episodes: { shelfId,
some: { available: true,
mediaFiles: { some: { shelfId, available: true } }, episodeId: { not: null },
},
},
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take, take: Math.max(take * 6, take),
include: {
episode: { include: { series: true } },
},
}); });
return series.map((s) => ({ const seen = new Set<string>();
type: "series" as const, const out: Array<{
id: s.id, type: "series";
title: s.title, id: string;
year: s.year, title: string;
posterUrl: s.posterUrl, year: number | null;
backdropUrl: s.backdropUrl, posterUrl: string | null;
mediaFileId: null as 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;
} }
async continueWatching(viewerId: string) { async continueWatching(viewerId: string) {