Fix TV audio via ExoPlayer passthrough, visible controls, version labels.

This commit is contained in:
Jos Vooges | STH 2026-08-31 01:52:11 +02:00
parent 700947d2e7
commit 9e7faba240
7 changed files with 381 additions and 223 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
versionCode = 3
versionName = "0.3.0"
versionCode = 4
versionName = "0.4.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
}
@ -38,14 +38,6 @@ android {
compose = true
buildConfig = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
resources {
excludes += setOf("META-INF/{AL2.0,LGPL2.1}")
}
}
}
dependencies {
@ -61,8 +53,10 @@ dependencies {
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0")
// LibVLC: speelt DTS/TrueHD/HDR-remuxes met geluid op Shield
implementation("org.videolan.android:libvlc-all:3.6.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")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

View file

@ -0,0 +1,107 @@
package nl.vonas.mediacluster.tv.playback
import android.content.Context
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.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
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)
.setPreferredAudioLanguage("nl")
.build()
}
val renderersFactory = DefaultRenderersFactory(context.applicationContext)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
return ExoPlayer.Builder(context.applicationContext, renderersFactory)
.setTrackSelector(trackSelector)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setSeekBackIncrementMs(10_000)
.setSeekForwardIncrementMs(30_000)
.build()
.also { it.volume = 1f }
}
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
val out = mutableListOf<PlayerTrack>()
val groups = currentTracks.groups
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type) continue
for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti)
val mime = format.sampleMimeType ?: "?"
val lang = format.language?.uppercase() ?: ""
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" } ?: ""
val label = listOfNotNull(
format.label?.takeIf { it.isNotBlank() },
lang.takeIf { it.isNotBlank() },
shortMime(mime),
ch.takeIf { it.isNotBlank() },
).joinToString(" · ").ifBlank { "Track ${ti + 1}" }
out += PlayerTrack(gi, ti, label, type)
}
}
return out
}
fun ExoPlayer.selectTrack(track: PlayerTrack) {
val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.clearOverridesOfType(track.type)
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, listOf(track.trackIndex)),
)
.setTrackTypeDisabled(track.type, false)
.build()
volume = 1f
}
fun ExoPlayer.disableTrackType(type: Int) {
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.clearOverridesOfType(type)
.setTrackTypeDisabled(type, true)
.build()
}
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 == mime || mime.contains("dts") -> "DTS"
mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
mime.contains("vorbis") -> "Vorbis"
mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase()
mime.startsWith("application/") || mime.startsWith("text/") -> "SUB"
else -> mime.substringAfterLast('/').uppercase()
}
fun Tracks.hasAudioSelected(): Boolean {
for (i in 0 until groups.size) {
val g = groups[i]
if (g.type == C.TRACK_TYPE_AUDIO && g.isSelected) return true
}
return false
}

View file

@ -432,9 +432,15 @@ private fun HeroHeader(
@Composable
private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(title, color = McColors.Text, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
Text(
title.uppercase(),
color = Color(0xFFE8EEF8),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(14.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(end = 40.dp),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
@ -472,11 +478,16 @@ private fun MovieDetailScreen(
if (arr != null) {
for (i in 0 until arr.length()) {
val f = arr.getJSONObject(i)
val label = listOfNotNull(
f.optString("resolution").ifBlank { null },
f.optString("hdrType").ifBlank { null },
f.optString("videoCodec").ifBlank { null },
).joinToString(" · ").ifBlank { "Afspelen" }
val label = buildList {
f.optString("resolution").ifBlank { null }?.let(::add)
f.optString("hdrType").ifBlank { null }?.let(::add)
f.optString("videoCodec").ifBlank { null }?.let(::add)
f.optString("audioCodec").ifBlank { null }?.let(::add)
f.optString("releaseName").ifBlank { null }?.let { rn ->
if (isEmpty()) add(rn.take(42))
}
f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) }
}.joinToString(" · ").ifBlank { "Versie ${i + 1}" }
list += f.getString("id") to label
}
}
@ -515,11 +526,13 @@ private fun MovieDetailScreen(
}
}
}
if (files.size > 1) {
Text("Versies", color = McColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
if (files.isNotEmpty()) {
Text("Versies", color = Color(0xFFF5F7FB), fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(files) { (id, label) ->
McButton(label.take(40)) { onPlay(id, title, 0) }
McButton(label.take(48), primary = id == files.first().first) {
onPlay(id, title, 0)
}
}
}
}

View file

@ -1,7 +1,6 @@
package nl.vonas.mediacluster.tv.ui
import android.net.Uri
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
@ -26,37 +25,42 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
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.listTracks
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout
data class VlcTrack(val id: Int, val name: String)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -71,79 +75,69 @@ fun PlayerScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var controlsVisible by remember { mutableStateOf(true) }
var controls by remember { mutableStateOf(true) }
var playing by remember { mutableStateOf(true) }
var positionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) }
var spuTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) }
var trackMenu by remember { mutableStateOf<String?>(null) } // "audio" | "spu"
val libVlc = remember {
LibVLC(
context,
arrayListOf(
"--aout=android_audiotrack",
"--audio-time-stretch",
"--network-caching=2500",
"--file-caching=2500",
"--avcodec-skiploopfilter=4",
"--android-display-chroma=RV32",
),
)
}
val mediaPlayer = remember { MediaPlayer(libVlc) }
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text
BackHandler {
if (trackMenu != null) trackMenu = null
else if (controlsVisible) onBack()
else controlsVisible = true
when {
menu != null -> menu = null
controls -> onBack()
else -> {
controls = true
}
}
}
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
val media = Media(libVlc, Uri.parse(play.streamUrl))
media.setHWDecoderEnabled(true, false)
media.addOption(":network-caching=2500")
mediaPlayer.media = media
media.release()
mediaPlayer.play()
if (startMs > 5_000) {
delay(600)
mediaPlayer.time = startMs
}
loading = false
player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
if (startMs > 5_000) player.seekTo(startMs)
player.play()
} catch (e: Exception) {
error = e.message ?: "Afspelen mislukt"
loading = false
}
}
DisposableEffect(mediaPlayer) {
val listener = MediaPlayer.EventListener { event ->
when (event.type) {
MediaPlayer.Event.Playing -> {
playing = true
loading = false
audioTracks = mediaPlayer.audioTracks
?.map { VlcTrack(it.id, it.name ?: "Audio ${it.id}") }
?.filter { it.id >= 0 }
.orEmpty()
spuTracks = mediaPlayer.spuTracks
?.map { VlcTrack(it.id, it.name ?: "Ondertitel ${it.id}") }
.orEmpty()
}
MediaPlayer.Event.Paused -> playing = false
MediaPlayer.Event.Buffering -> loading = event.buffering < 100f
MediaPlayer.Event.EncounteredError -> error = "Afspeelfout (codec/stream)"
MediaPlayer.Event.EndReached -> onBack()
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
if (state == Player.STATE_READY) {
player.volume = 1f
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
}
}
mediaPlayer.setEventListener(listener)
override fun onIsPlayingChanged(isPlaying: Boolean) {
playing = isPlaying
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
player.volume = 1f
}
override fun onPlayerError(e: PlaybackException) {
error = e.message ?: "Spelerfout"
}
}
player.addListener(listener)
onDispose {
try {
runBlocking {
@ -152,39 +146,37 @@ fun PlayerScreen(
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = mediaPlayer.time.coerceAtLeast(0),
durationMs = mediaPlayer.length.coerceAtLeast(0),
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
)
}
} catch (_: Exception) {
}
mediaPlayer.stop()
mediaPlayer.detachViews()
mediaPlayer.release()
libVlc.release()
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(mediaPlayer) {
LaunchedEffect(player) {
while (true) {
delay(500)
positionMs = mediaPlayer.time.coerceAtLeast(0)
durationMs = mediaPlayer.length.coerceAtLeast(0)
delay(400)
positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = player.duration.coerceAtLeast(0)
}
}
LaunchedEffect(mediaPlayer) {
LaunchedEffect(player) {
while (true) {
delay(12_000)
if (mediaPlayer.isPlaying) {
if (player.isPlaying) {
try {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = mediaPlayer.time.coerceAtLeast(0),
durationMs = mediaPlayer.length.coerceAtLeast(0),
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
)
} catch (_: Exception) {
}
@ -192,40 +184,55 @@ fun PlayerScreen(
}
}
LaunchedEffect(controlsVisible, trackMenu) {
if (controlsVisible && trackMenu == null && playing) {
delay(4_500)
controlsVisible = false
LaunchedEffect(controls, menu, playing) {
if (controls && menu == null && playing) {
delay(5_000)
controls = false
}
}
LaunchedEffect(controls) {
if (controls) {
delay(50)
runCatching { controlsFocus.requestFocus() }
}
}
fun showControls() {
controls = true
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.focusable()
.onKeyEvent { ev ->
if (ev.type != KeyEventType.KeyUp) return@onKeyEvent false
.onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) {
Key.DirectionCenter, Key.Enter -> {
controlsVisible = true
if (mediaPlayer.isPlaying) mediaPlayer.pause() else mediaPlayer.play()
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
showControls()
true
} else {
false // laat knoppen de OK afhandelen
}
}
Key.DirectionLeft -> {
controlsVisible = true
mediaPlayer.time = (mediaPlayer.time - 10_000).coerceAtLeast(0)
if (menu != null || controls) return@onPreviewKeyEvent false
player.seekBack()
showControls()
true
}
Key.DirectionRight -> {
controlsVisible = true
mediaPlayer.time = mediaPlayer.time + 30_000
if (menu != null || controls) return@onPreviewKeyEvent false
player.seekForward()
showControls()
true
}
Key.Menu, Key.ButtonY -> {
controlsVisible = true
trackMenu = "audio"
true
Key.DirectionUp, Key.DirectionDown, Key.Menu -> {
showControls()
false
}
else -> false
}
@ -233,23 +240,27 @@ fun PlayerScreen(
) {
AndroidView(
factory = { ctx ->
val layout = VLCVideoLayout(ctx)
layout.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
mediaPlayer.attachViews(layout, null, false, false)
layout
PlayerView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
this.player = player
useController = false // eigen Compose-controls (D-pad focus)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
keepScreenOn = true
isFocusable = false
isFocusableInTouchMode = false
}
},
update = { it.player = player },
modifier = Modifier.fillMaxSize(),
)
if (loading && error == null) {
Text("Laden…", color = McColors.TextDim, fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
}
if (error != null) {
Column(
Modifier.align(Alignment.Center).padding(32.dp),
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
@ -258,37 +269,28 @@ fun PlayerScreen(
}
}
if (controlsVisible && error == null) {
if (controls && error == null) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0x66000000), Color.Transparent, Color(0xEE000000)),
),
listOf(Color(0x99000000), Color.Transparent, Color(0xF2000000)),
),
)
.focusable(),
) {
Row(
Modifier
.align(Alignment.TopStart)
.padding(28.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
McButton("Terug", onClick = onBack)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(horizontal = 36.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// Progress
Box(
Modifier
.fillMaxWidth()
.height(6.dp)
.height(5.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0x44FFFFFF)),
) {
@ -296,71 +298,89 @@ fun PlayerScreen(
Box(
Modifier
.fillMaxWidth(pct)
.height(6.dp)
.height(5.dp)
.background(McColors.Accent),
)
}
Text(
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
color = McColors.TextDim,
fontSize = 13.sp,
color = Color(0xFFC5CDDB),
fontSize = 14.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.focusRequester(controlsFocus),
) {
McButton("Terug", onClick = onBack)
McButton(if (playing) "Pauze" else "Play", primary = true) {
if (mediaPlayer.isPlaying) mediaPlayer.pause() else mediaPlayer.play()
if (player.isPlaying) player.pause() else player.play()
showControls()
}
McButton("-10s") {
player.seekBack()
showControls()
}
McButton("+30s") {
player.seekForward()
showControls()
}
McButton("Audio (${audioTracks.size})") {
menu = "audio"
showControls()
}
McButton("Ondertitel") {
menu = "text"
showControls()
}
McButton("-10s") { mediaPlayer.time = (mediaPlayer.time - 10_000).coerceAtLeast(0) }
McButton("+30s") { mediaPlayer.time = mediaPlayer.time + 30_000 }
McButton("Audio (${audioTracks.size})") { trackMenu = "audio" }
McButton("Ondertitel") { trackMenu = "spu" }
}
}
}
}
if (trackMenu != null) {
val isAudio = trackMenu == "audio"
val tracks = if (isAudio) audioTracks else spuTracks
if (menu != null) {
val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else textTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
.background(Color(0xDD05070D)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(480.dp)
.clip(RoundedCornerShape(18.dp))
.background(McColors.BgElevated)
.padding(24.dp),
.width(520.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF121A2A))
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (isAudio) "Audiospoor" else "Ondertitel",
color = McColors.Text,
if (isAudio) "Audiospoor kiezen" else "Ondertitel kiezen",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
if (!isAudio) {
McButton("Uit") {
mediaPlayer.spuTrack = -1
trackMenu = null
McButton("Ondertitel uit") {
player.disableTrackType(C.TRACK_TYPE_TEXT)
menu = null
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(280.dp)) {
items(tracks, key = { it.id }) { track ->
McButton(track.name) {
if (isAudio) mediaPlayer.audioTrack = track.id
else mediaPlayer.spuTrack = track.id
trackMenu = null
scope.launch {
delay(100)
controlsVisible = true
if (tracks.isEmpty()) {
Text("Geen sporen gevonden", color = Color(0xFF9AA6BD))
}
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
McButton(track.label) {
player.selectTrack(track)
menu = null
}
}
}
}
McButton("Sluiten") { trackMenu = null }
McButton("Sluiten") { menu = null }
}
}
}
@ -369,9 +389,9 @@ fun PlayerScreen(
private fun formatTime(ms: Long): String {
if (ms <= 0) return "0:00"
val totalSec = ms / 1000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
val total = ms / 1000
val h = total / 3600
val m = (total % 3600) / 60
val s = total % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}

View file

@ -14,6 +14,7 @@ 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
@ -38,26 +39,32 @@ 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)
Surface(
onClick = onClick,
modifier = modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 8.dp),
focusedGlow = Glow(elevationColor = McColors.Accent.copy(alpha = 0.7f), elevation = 10.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Accent else McColors.Surface,
focusedContainerColor = if (primary) Color(0xFF7EB0FF) else McColors.SurfaceFocus,
contentColor = if (primary) Color(0xFF041022) else McColors.Text,
focusedContentColor = if (primary) Color(0xFF041022) else McColors.Text,
containerColor = bg,
focusedContainerColor = bgFocus,
contentColor = fg,
focusedContentColor = fg,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp),
color = fg,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@ -68,8 +75,8 @@ fun PosterCard(
item: MediaItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 160.dp,
height: Dp = 240.dp,
width: Dp = 152.dp,
height: Dp = 228.dp,
) {
val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
@ -80,14 +87,14 @@ fun PosterCard(
Surface(
onClick = onClick,
modifier = modifier.width(width),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 12.dp),
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 16.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = McColors.Surface,
focusedContainerColor = McColors.SurfaceFocus,
containerColor = Color(0xFF101827),
focusedContainerColor = Color(0xFF182338),
),
) {
Column {
@ -95,8 +102,8 @@ fun PosterCard(
Modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
.background(McColors.BgElevated),
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(Color(0xFF0B1220)),
) {
AsyncImage(
model = item.posterUrl ?: item.backdropUrl,
@ -104,18 +111,29 @@ 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
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(4.dp)
.height(3.dp)
.background(Color(0x55FFFFFF)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(4.dp)
.height(3.dp)
.background(McColors.Accent),
)
}
@ -124,9 +142,9 @@ fun PosterCard(
Text(
item.title,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = McColors.Text,
color = Color(0xFFF5F7FB),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
@ -135,13 +153,20 @@ fun PosterCard(
Text(
item.subtitle,
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
color = McColors.TextDim,
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(4.dp))
Spacer(Modifier.height(6.dp))
}
}
}

View file

@ -1,24 +1,22 @@
package nl.vonas.mediacluster.tv.ui.theme
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object McColors {
val Bg = Color(0xFF05070D)
val BgElevated = Color(0xFF0E1420)
val Surface = Color(0xFF151C2B)
val SurfaceFocus = Color(0xFF1E2A42)
val Accent = Color(0xFF5B9DFF)
val AccentSoft = Color(0x335B9DFF)
val Text = Color(0xFFF4F7FC)
val TextDim = Color(0xFF9AA6BD)
val Danger = Color(0xFFFF7B8A)
val Success = Color(0xFF7DFFB3)
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)
val HeroScrim = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color(0xCC05070D), Color(0xFF05070D)),
)
val CardScrim = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color(0xE605070D)),
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),
),
)
}

View file

@ -760,6 +760,7 @@ export class ViewerService {
resolution: f.resolution,
hdrType: f.hdrType,
videoCodec: f.videoCodec,
audioCodec: f.audioCodec,
releaseName: f.releaseName,
nodeName: f.node.name,
nodeOnline: f.node.status === "ONLINE",