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:
parent
e22cef06dd
commit
06e7640ab5
17 changed files with 940 additions and 340 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ dependencyResolutionManagement {
|
|||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "MediaClusterTv"
|
||||
rootProject.name = "VMC"
|
||||
include(":app")
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue