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

View file

@ -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 @Composable
private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) { private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { 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( LazyRow(
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(end = 40.dp), contentPadding = PaddingValues(end = 40.dp),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
@ -472,11 +478,16 @@ private fun MovieDetailScreen(
if (arr != null) { if (arr != null) {
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val f = arr.getJSONObject(i) val f = arr.getJSONObject(i)
val label = listOfNotNull( val label = buildList {
f.optString("resolution").ifBlank { null }, f.optString("resolution").ifBlank { null }?.let(::add)
f.optString("hdrType").ifBlank { null }, f.optString("hdrType").ifBlank { null }?.let(::add)
f.optString("videoCodec").ifBlank { null }, f.optString("videoCodec").ifBlank { null }?.let(::add)
).joinToString(" · ").ifBlank { "Afspelen" } 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 list += f.getString("id") to label
} }
} }
@ -515,11 +526,13 @@ private fun MovieDetailScreen(
} }
} }
} }
if (files.size > 1) { if (files.isNotEmpty()) {
Text("Versies", color = McColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) Text("Versies", color = Color(0xFFF5F7FB), fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(files) { (id, label) -> 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 package nl.vonas.mediacluster.tv.ui
import android.net.Uri import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -26,37 +25,42 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key 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.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient 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.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -71,79 +75,69 @@ fun PlayerScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val player = remember { buildTvPlayer(context) }
val controlsFocus = remember { FocusRequester() }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var controlsVisible by remember { mutableStateOf(true) } var controls by remember { mutableStateOf(true) }
var playing by remember { mutableStateOf(true) } var playing by remember { mutableStateOf(true) }
var positionMs by remember { mutableLongStateOf(0L) } var positionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) } var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var spuTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) } var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var trackMenu by remember { mutableStateOf<String?>(null) } // "audio" | "spu" var menu by remember { mutableStateOf<String?>(null) } // audio | text
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) }
BackHandler { BackHandler {
if (trackMenu != null) trackMenu = null when {
else if (controlsVisible) onBack() menu != null -> menu = null
else controlsVisible = true controls -> onBack()
else -> {
controls = true
}
}
} }
LaunchedEffect(mediaFileId) { LaunchedEffect(mediaFileId) {
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
val media = Media(libVlc, Uri.parse(play.streamUrl)) player.volume = 1f
media.setHWDecoderEnabled(true, false) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
media.addOption(":network-caching=2500") player.prepare()
mediaPlayer.media = media if (startMs > 5_000) player.seekTo(startMs)
media.release() player.play()
mediaPlayer.play()
if (startMs > 5_000) {
delay(600)
mediaPlayer.time = startMs
}
loading = false
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Afspelen mislukt" error = e.message ?: "Afspelen mislukt"
loading = false loading = false
} }
} }
DisposableEffect(mediaPlayer) { DisposableEffect(player) {
val listener = MediaPlayer.EventListener { event -> val listener = object : Player.Listener {
when (event.type) { override fun onPlaybackStateChanged(state: Int) {
MediaPlayer.Event.Playing -> { loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
playing = true if (state == Player.STATE_READY) {
loading = false player.volume = 1f
audioTracks = mediaPlayer.audioTracks audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
?.map { VlcTrack(it.id, it.name ?: "Audio ${it.id}") } textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
?.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)" override fun onIsPlayingChanged(isPlaying: Boolean) {
MediaPlayer.Event.EndReached -> onBack() 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"
} }
} }
mediaPlayer.setEventListener(listener) player.addListener(listener)
onDispose { onDispose {
try { try {
runBlocking { runBlocking {
@ -152,39 +146,37 @@ fun PlayerScreen(
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = mediaPlayer.time.coerceAtLeast(0), positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = mediaPlayer.length.coerceAtLeast(0), durationMs = player.duration.coerceAtLeast(0),
) )
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
mediaPlayer.stop() player.removeListener(listener)
mediaPlayer.detachViews() player.release()
mediaPlayer.release()
libVlc.release()
} }
} }
LaunchedEffect(mediaPlayer) { LaunchedEffect(player) {
while (true) { while (true) {
delay(500) delay(400)
positionMs = mediaPlayer.time.coerceAtLeast(0) positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = mediaPlayer.length.coerceAtLeast(0) durationMs = player.duration.coerceAtLeast(0)
} }
} }
LaunchedEffect(mediaPlayer) { LaunchedEffect(player) {
while (true) { while (true) {
delay(12_000) delay(12_000)
if (mediaPlayer.isPlaying) { if (player.isPlaying) {
try { try {
api.postProgress( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = mediaPlayer.time.coerceAtLeast(0), positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = mediaPlayer.length.coerceAtLeast(0), durationMs = player.duration.coerceAtLeast(0),
) )
} catch (_: Exception) { } catch (_: Exception) {
} }
@ -192,40 +184,55 @@ fun PlayerScreen(
} }
} }
LaunchedEffect(controlsVisible, trackMenu) { LaunchedEffect(controls, menu, playing) {
if (controlsVisible && trackMenu == null && playing) { if (controls && menu == null && playing) {
delay(4_500) delay(5_000)
controlsVisible = false controls = false
} }
} }
LaunchedEffect(controls) {
if (controls) {
delay(50)
runCatching { controlsFocus.requestFocus() }
}
}
fun showControls() {
controls = true
}
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusable() .onPreviewKeyEvent { ev ->
.onKeyEvent { ev -> if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
if (ev.type != KeyEventType.KeyUp) return@onKeyEvent false
when (ev.key) { when (ev.key) {
Key.DirectionCenter, Key.Enter -> { Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
controlsVisible = true if (menu != null) return@onPreviewKeyEvent false
if (mediaPlayer.isPlaying) mediaPlayer.pause() else mediaPlayer.play() if (!controls) {
true showControls()
true
} else {
false // laat knoppen de OK afhandelen
}
} }
Key.DirectionLeft -> { Key.DirectionLeft -> {
controlsVisible = true if (menu != null || controls) return@onPreviewKeyEvent false
mediaPlayer.time = (mediaPlayer.time - 10_000).coerceAtLeast(0) player.seekBack()
showControls()
true true
} }
Key.DirectionRight -> { Key.DirectionRight -> {
controlsVisible = true if (menu != null || controls) return@onPreviewKeyEvent false
mediaPlayer.time = mediaPlayer.time + 30_000 player.seekForward()
showControls()
true true
} }
Key.Menu, Key.ButtonY -> { Key.DirectionUp, Key.DirectionDown, Key.Menu -> {
controlsVisible = true showControls()
trackMenu = "audio" false
true
} }
else -> false else -> false
} }
@ -233,23 +240,27 @@ fun PlayerScreen(
) { ) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
val layout = VLCVideoLayout(ctx) PlayerView(ctx).apply {
layout.layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
ViewGroup.LayoutParams.MATCH_PARENT, this.player = player
ViewGroup.LayoutParams.MATCH_PARENT, useController = false // eigen Compose-controls (D-pad focus)
) resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
mediaPlayer.attachViews(layout, null, false, false) setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
layout keepScreenOn = true
isFocusable = false
isFocusableInTouchMode = false
}
}, },
update = { it.player = player },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
if (loading && error == null) { 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) { if (error != null) {
Column( Column(
Modifier.align(Alignment.Center).padding(32.dp), Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
@ -258,37 +269,28 @@ fun PlayerScreen(
} }
} }
if (controlsVisible && error == null) { if (controls && error == null) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.verticalGradient( 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( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(28.dp), .padding(horizontal = 36.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
// Progress
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(6.dp) .height(5.dp)
.clip(RoundedCornerShape(3.dp)) .clip(RoundedCornerShape(3.dp))
.background(Color(0x44FFFFFF)), .background(Color(0x44FFFFFF)),
) { ) {
@ -296,71 +298,89 @@ fun PlayerScreen(
Box( Box(
Modifier Modifier
.fillMaxWidth(pct) .fillMaxWidth(pct)
.height(6.dp) .height(5.dp)
.background(McColors.Accent), .background(McColors.Accent),
) )
} }
Text( Text(
"${formatTime(positionMs)} / ${formatTime(durationMs)}", "${formatTime(positionMs)} / ${formatTime(durationMs)}",
color = McColors.TextDim, color = Color(0xFFC5CDDB),
fontSize = 13.sp, 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) { 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) { if (menu != null) {
val isAudio = trackMenu == "audio" val isAudio = menu == "audio"
val tracks = if (isAudio) audioTracks else spuTracks val tracks = if (isAudio) audioTracks else textTracks
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xCC000000)), .background(Color(0xDD05070D)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
Modifier Modifier
.width(480.dp) .width(520.dp)
.clip(RoundedCornerShape(18.dp)) .clip(RoundedCornerShape(16.dp))
.background(McColors.BgElevated) .background(Color(0xFF121A2A))
.padding(24.dp), .padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
if (isAudio) "Audiospoor" else "Ondertitel", if (isAudio) "Audiospoor kiezen" else "Ondertitel kiezen",
color = McColors.Text, color = Color.White,
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
if (!isAudio) { if (!isAudio) {
McButton("Uit") { McButton("Ondertitel uit") {
mediaPlayer.spuTrack = -1 player.disableTrackType(C.TRACK_TYPE_TEXT)
trackMenu = null menu = null
} }
} }
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(280.dp)) { if (tracks.isEmpty()) {
items(tracks, key = { it.id }) { track -> Text("Geen sporen gevonden", color = Color(0xFF9AA6BD))
McButton(track.name) { }
if (isAudio) mediaPlayer.audioTrack = track.id LazyColumn(
else mediaPlayer.spuTrack = track.id modifier = Modifier.height(300.dp),
trackMenu = null verticalArrangement = Arrangement.spacedBy(8.dp),
scope.launch { ) {
delay(100) items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
controlsVisible = true 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 { private fun formatTime(ms: Long): String {
if (ms <= 0) return "0:00" if (ms <= 0) return "0:00"
val totalSec = ms / 1000 val total = ms / 1000
val h = totalSec / 3600 val h = total / 3600
val m = (totalSec % 3600) / 60 val m = (total % 3600) / 60
val s = totalSec % 60 val s = total % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -38,26 +39,32 @@ fun McButton(
primary: Boolean = false, primary: Boolean = false,
onClick: () -> Unit, 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( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
glow = ClickableSurfaceDefaults.glow( 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( colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Accent else McColors.Surface, containerColor = bg,
focusedContainerColor = if (primary) Color(0xFF7EB0FF) else McColors.SurfaceFocus, focusedContainerColor = bgFocus,
contentColor = if (primary) Color(0xFF041022) else McColors.Text, contentColor = fg,
focusedContentColor = if (primary) Color(0xFF041022) else McColors.Text, focusedContentColor = fg,
), ),
) { ) {
Text( Text(
label, text = label.ifBlank { "—" },
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp),
color = fg,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
@ -68,8 +75,8 @@ fun PosterCard(
item: MediaItem, item: MediaItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
width: Dp = 160.dp, width: Dp = 152.dp,
height: Dp = 240.dp, height: Dp = 228.dp,
) { ) {
val progress = if (item.durationMs > 0) { val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f) (item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
@ -80,14 +87,14 @@ fun PosterCard(
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier.width(width), modifier = modifier.width(width),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
glow = ClickableSurfaceDefaults.glow( glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 12.dp), focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 16.dp),
), ),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = McColors.Surface, containerColor = Color(0xFF101827),
focusedContainerColor = McColors.SurfaceFocus, focusedContainerColor = Color(0xFF182338),
), ),
) { ) {
Column { Column {
@ -95,8 +102,8 @@ fun PosterCard(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(McColors.BgElevated), .background(Color(0xFF0B1220)),
) { ) {
AsyncImage( AsyncImage(
model = item.posterUrl ?: item.backdropUrl, model = item.posterUrl ?: item.backdropUrl,
@ -104,18 +111,29 @@ fun PosterCard(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(56.dp)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color(0xCC000000)),
),
),
)
if (progress > 0.02f) { if (progress > 0.02f) {
Box( Box(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.height(4.dp) .height(3.dp)
.background(Color(0x55FFFFFF)), .background(Color(0x55FFFFFF)),
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth(progress) .fillMaxWidth(progress)
.height(4.dp) .height(3.dp)
.background(McColors.Accent), .background(McColors.Accent),
) )
} }
@ -124,9 +142,9 @@ fun PosterCard(
Text( Text(
item.title, item.title,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = McColors.Text, color = Color(0xFFF5F7FB),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp, lineHeight = 16.sp,
@ -135,13 +153,20 @@ fun PosterCard(
Text( Text(
item.subtitle, item.subtitle,
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp), modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
color = McColors.TextDim, color = Color(0xFF9AA6BD),
fontSize = 11.sp, fontSize = 11.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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 { } else {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(6.dp))
} }
} }
} }

View file

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

View file

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