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 (
<main className="link-page">
<div className="link-card">
<p className="page-kicker">Media Cluster</p>
<p className="page-kicker">VMC</p>
<h1>Apparaat koppelen</h1>
<p className="muted">
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">
<span className="mark">MC</span>
<div>
<h1>Media Cluster</h1>
<h1>VMC</h1>
</div>
</div>
<p className="login-sub">Authenticated access // ops</p>

View file

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

View file

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

View file

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

View file

@ -1,28 +1,97 @@
package nl.vonas.mediacluster.tv.playback
import android.content.Context
import android.os.Handler
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride
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.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.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 {
val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters()
// Laat DTS/TrueHD door (Shield HDMI passthrough) — niet filteren als "unsupported"
.setExceedRendererCapabilitiesIfNecessary(true)
.setAllowAudioMixedMimeTypeAdaptiveness(true)
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
.setPreferredAudioLanguage("nl")
.setPreferredTextLanguage("nl")
.build()
}
val renderersFactory = DefaultRenderersFactory(context.applicationContext)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
val renderersFactory = McRenderersFactory(context.applicationContext)
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
@ -65,6 +134,21 @@ fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
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) {
val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
trackSelectionParameters = trackSelectionParameters
@ -89,6 +173,7 @@ fun ExoPlayer.disableTrackType(type: Int) {
private fun shortMime(mime: String): String = when {
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-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"
mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
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.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
@ -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.McButton
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
@ -191,8 +193,47 @@ private fun MainHub(
var tab by remember { mutableStateOf(MainTab.Home) }
var navFocused by remember { mutableStateOf(false) }
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) {
tab = MainTab.Home
navFocused = true
@ -216,6 +257,25 @@ private fun MainHub(
onSelect = {
tab = it
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(
api = api,
catalogId = "movies",
titleHint = "Films",
catalogId = movieCatalog,
titleHint = movieSubs.firstOrNull { it.id == movieCatalog }?.label ?: "Films",
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
)
MainTab.Series -> BrowseScreen(
api = api,
catalogId = "series",
titleHint = "Series",
catalogId = seriesCatalog,
titleHint = seriesSubs.firstOrNull { it.id == seriesCatalog }?.label ?: "Series",
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
)
@ -260,9 +320,11 @@ private fun MainHub(
private fun Splash() {
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(8.dp))
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp)
Text("VMC", color = McColors.Accent, fontSize = 42.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
Spacer(Modifier.height(6.dp))
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))
.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("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)
@ -357,35 +419,34 @@ private fun HomeScreen(
}
}
val hero = home?.continueWatching?.firstOrNull()
?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
val spotlight = home?.spotlight.orEmpty().ifEmpty {
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
}
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(26.dp),
contentPadding = PaddingValues(bottom = 56.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
item {
HeroHeader(
hero = hero,
onPlayHero = {
val h = hero ?: return@HeroHeader
SpotlightBillboard(
items = spotlight,
onPlay = { item ->
when {
!h.mediaFileId.isNullOrBlank() -> onPlay(
h.mediaFileId!!,
h.id.takeIf { h.type == "movie" },
h.id.takeIf { h.type == "episode" },
!item.mediaFileId.isNullOrBlank() -> onPlay(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
null,
if (h.type == "episode") "EPISODE" else "MOVIE",
h.positionMs,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
)
h.type == "movie" -> onOpenMovie(h.id)
else -> onOpenSeries(h.id)
item.type == "movie" -> onOpenMovie(item.id)
else -> onOpenSeries(item.id)
}
},
onOpenHero = {
val h = hero ?: return@HeroHeader
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
onOpen = { item ->
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
},
)
}
@ -435,48 +496,134 @@ private fun HomeScreen(
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun HeroHeader(
hero: MediaItem?,
onPlayHero: () -> Unit,
onOpenHero: () -> Unit,
private fun SpotlightBillboard(
items: List<MediaItem>,
onPlay: (MediaItem) -> 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(
model = hero?.backdropUrl ?: hero?.posterUrl,
contentDescription = null,
model = current.backdropUrl ?: current.posterUrl,
contentDescription = current.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
Box(
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(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(0.58f)
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp),
.fillMaxWidth(0.55f)
.padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
hero?.title ?: "Welkom terug",
"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 = 42.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(3.dp))
.background(McColors.Brand)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Text(
current.title,
color = Color.White,
fontSize = 46.sp,
fontWeight = FontWeight.Black,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 46.sp,
lineHeight = 50.sp,
)
if (!hero?.subtitle.isNullOrBlank()) {
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp)
val meta = listOfNotNull(
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)) {
if (hero != null) {
McButton(if (hero.positionMs > 30_000) "Verder kijken" else "Afspelen", primary = true, onClick = onPlayHero)
McButton("Details", onClick = onOpenHero)
if (current.id.isNotBlank()) {
McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
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)?,
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(
Modifier.fillMaxWidth().padding(end = 40.dp),
Modifier.fillMaxWidth().padding(end = 48.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title.uppercase(),
color = Color(0xFFE8EEF8),
fontSize = 17.sp,
title,
color = Color(0xFFE5E5E5),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.1.sp,
)
if (onSeeAll != null) {
McButton("Alles bekijken") { onSeeAll() }
McButton("Alles") { onSeeAll() }
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(end = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(end = 48.dp),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(item, onClick = { onClick(item) })
@ -698,7 +844,7 @@ private fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
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)
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp))

View file

@ -3,6 +3,9 @@ package nl.vonas.mediacluster.tv.ui
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
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.focusable
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.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
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -49,7 +55,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 kotlinx.coroutines.delay
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.buildTvPlayer
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.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton
@ -77,6 +86,7 @@ fun PlayerScreen(
val context = LocalContext.current
val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
@ -88,13 +98,24 @@ fun PlayerScreen(
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
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 {
when {
menu != null -> menu = null
controls -> onBack()
else -> {
controls = true
}
else -> showControls()
}
}
@ -118,6 +139,7 @@ fun PlayerScreen(
loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
if (state == Player.STATE_READY) {
player.volume = 1f
player.ensureAudioSelected()
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
}
@ -130,6 +152,7 @@ fun PlayerScreen(
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
player.ensureAudioSelected()
player.volume = 1f
}
@ -186,22 +209,21 @@ fun PlayerScreen(
LaunchedEffect(controls, menu, playing) {
if (controls && menu == null && playing) {
delay(5_000)
controls = false
delay(4_500)
hideControls()
}
}
LaunchedEffect(controls) {
LaunchedEffect(controls, menu) {
delay(40)
if (menu != null) return@LaunchedEffect
if (controls) {
delay(50)
runCatching { controlsFocus.requestFocus() }
} else {
runCatching { catcherFocus.requestFocus() }
}
}
fun showControls() {
controls = true
}
Box(
Modifier
.fillMaxSize()
@ -210,12 +232,22 @@ fun PlayerScreen(
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) {
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 (!controls) {
showControls()
true
} else {
false // laat knoppen de OK afhandelen
false
}
}
Key.DirectionLeft -> {
@ -230,9 +262,10 @@ fun PlayerScreen(
showControls()
true
}
Key.DirectionUp, Key.DirectionDown, Key.Menu -> {
Key.DirectionUp, Key.Menu -> {
if (menu != null) return@onPreviewKeyEvent false
showControls()
false
true
}
else -> false
}
@ -243,18 +276,29 @@ fun PlayerScreen(
PlayerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
this.player = player
useController = false // eigen Compose-controls (D-pad focus)
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
keepScreenOn = true
isFocusable = false
isFocusableInTouchMode = false
descendantFocusability = FrameLayout.FOCUS_BLOCK_DESCENDANTS
}
},
update = { it.player = player },
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) {
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
}
@ -269,66 +313,110 @@ fun PlayerScreen(
}
}
if (controls && error == null) {
AnimatedVisibility(
visible = controls && error == null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(Modifier.fillMaxSize()) {
// Top / bottom scrim zoals Netflix
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0x99000000), Color.Transparent, Color(0xF2000000)),
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(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 36.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
.padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(5.dp)
.clip(RoundedCornerShape(3.dp))
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0x44FFFFFF)),
) {
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Box(
Modifier
.fillMaxWidth(pct)
.height(5.dp)
.background(McColors.Accent),
.height(3.dp)
.background(McColors.Brand),
)
}
Text(
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
color = Color(0xFFC5CDDB),
fontSize = 14.sp,
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.focusRequester(controlsFocus),
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
McButton("Terug", onClick = onBack)
McButton(if (playing) "Pauze" else "Play", primary = true) {
if (player.isPlaying) player.pause() else player.play()
showControls()
Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
}
McButton("-10s") {
Row(
Modifier
.fillMaxWidth()
.focusRequester(controlsFocus),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
NetflixIconButton("↺ 10") {
player.seekBack()
showControls()
}
McButton("+30s") {
NetflixIconButton("10 ↻") {
player.seekForward()
showControls()
}
McButton("Audio (${audioTracks.size})") {
Spacer(Modifier.weight(1f))
NetflixTextButton("Audio") {
menu = "audio"
showControls()
}
McButton("Ondertitel") {
NetflixTextButton("Ondertiteling") {
menu = "text"
showControls()
}
@ -343,50 +431,134 @@ fun PlayerScreen(
Box(
Modifier
.fillMaxSize()
.background(Color(0xDD05070D)),
contentAlignment = Alignment.Center,
.background(Color(0x99000000)),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Modifier
.width(520.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF121A2A))
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.fillMaxHeight()
.width(400.dp)
.background(Color(0xF20A0A0A))
.padding(horizontal = 28.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (isAudio) "Audiospoor kiezen" else "Ondertitel kiezen",
if (isAudio) "Audio" else "Ondertiteling",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(12.dp))
if (!isAudio) {
McButton("Ondertitel uit") {
NetflixTrackRow("Uit", selected = false) {
player.disableTrackType(C.TRACK_TYPE_TEXT)
menu = null
}
}
if (tracks.isEmpty()) {
Text("Geen sporen gevonden", color = Color(0xFF9AA6BD))
Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
}
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
McButton(track.label) {
NetflixTrackRow(track.label, selected = false) {
player.selectTrack(track)
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 {
if (ms <= 0) return "0:00"
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.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.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -14,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
@ -39,28 +37,29 @@ fun McButton(
primary: Boolean = false,
onClick: () -> Unit,
) {
val bg = if (primary) McColors.Accent else Color(0xFF1A2336)
val bgFocus = if (primary) Color(0xFF8BB8FF) else Color(0xFF2A3A58)
val fg = if (primary) Color(0xFF061018) else Color(0xFFF5F7FB)
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,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
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(
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = fg,
focusedContentColor = fgFocus,
),
) {
Text(
text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp),
color = fg,
modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -75,8 +74,8 @@ fun PosterCard(
item: MediaItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 152.dp,
height: Dp = 228.dp,
width: Dp = 140.dp,
height: Dp = 210.dp,
) {
val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
@ -87,23 +86,26 @@ fun PosterCard(
Surface(
onClick = onClick,
modifier = modifier.width(width),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 16.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
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(0xFF101827),
focusedContainerColor = Color(0xFF182338),
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF1A1A1A),
),
) {
Column {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(Color(0xFF0B1220)),
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A1A1A)),
) {
AsyncImage(
model = item.posterUrl ?: item.backdropUrl,
@ -111,17 +113,6 @@ fun PosterCard(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(56.dp)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color(0xCC000000)),
),
),
)
if (progress > 0.02f) {
Box(
Modifier
@ -139,35 +130,5 @@ fun PosterCard(
}
}
}
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
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import nl.vonas.mediacluster.tv.ui.theme.McColors
@ -23,33 +38,105 @@ enum class MainTab(val label: String) {
Settings("Instellingen"),
}
data class SubNavItem(
val id: String,
val label: String,
)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun TopNavBar(
selected: MainTab,
onSelect: (MainTab) -> Unit,
subItems: List<SubNavItem> = emptyList(),
selectedSubId: String? = null,
onSelectSub: (SubNavItem) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxWidth()) {
Row(
modifier
Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
.padding(horizontal = 40.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"MC",
color = McColors.Accent,
fontSize = 22.sp,
"VMC",
color = McColors.Brand,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(end = 12.dp),
modifier = Modifier.padding(end = 16.dp),
)
MainTab.entries.forEach { tab ->
McButton(
label = tab.label,
primary = selected == tab,
onClick = { onSelect(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
object McColors {
val Bg = androidx.compose.ui.graphics.Color(0xFF0B0F16)
val BgElevated = androidx.compose.ui.graphics.Color(0xFF121826)
val Surface = androidx.compose.ui.graphics.Color(0xFF1A2336)
val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFF2A3A58)
val Accent = androidx.compose.ui.graphics.Color(0xFF5B9DFF)
val AccentSoft = androidx.compose.ui.graphics.Color(0x335B9DFF)
val Text = androidx.compose.ui.graphics.Color(0xFFF5F7FB)
val TextDim = androidx.compose.ui.graphics.Color(0xFF9AA6BD)
val Danger = androidx.compose.ui.graphics.Color(0xFFFF7B8A)
val Success = androidx.compose.ui.graphics.Color(0xFF7DFFB3)
// Netflix-achtige basis: bijna zwart, weinig blauw “dashboard”
val Bg = androidx.compose.ui.graphics.Color(0xFF0B0B0B)
val BgElevated = androidx.compose.ui.graphics.Color(0xFF141414)
val Surface = androidx.compose.ui.graphics.Color(0xFF1F1F1F)
val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFFFFFFFF)
val Accent = androidx.compose.ui.graphics.Color(0xFFE50914)
val AccentSoft = androidx.compose.ui.graphics.Color(0x33E50914)
val Brand = androidx.compose.ui.graphics.Color(0xFFE50914)
val Text = androidx.compose.ui.graphics.Color(0xFFF5F5F5)
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(
colors = listOf(
androidx.compose.ui.graphics.Color.Transparent,
androidx.compose.ui.graphics.Color(0x990B0F16),
androidx.compose.ui.graphics.Color(0xFF0B0F16),
androidx.compose.ui.graphics.Color(0x660B0B0B),
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"?>
<resources>
<string name="app_name">Media Cluster</string>
<string name="app_name">VMC</string>
</resources>

View file

@ -1,5 +1,5 @@
plugins {
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.plugin.compose") 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.2.10" apply false
}

View file

@ -1,9 +1,9 @@
# 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.workers.max=2
org.gradle.workers.max=1
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
kotlin.daemon.jvmargs=-Xmx1024m
kotlin.daemon.jvmargs=-Xmx768m

View file

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

View file

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

View file

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