Fix TV audio via ExoPlayer passthrough, visible controls, version labels.
This commit is contained in:
parent
700947d2e7
commit
9e7faba240
7 changed files with 381 additions and 223 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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.Event.Paused -> playing = false
|
||||
MediaPlayer.Event.Buffering -> loading = event.buffering < 100f
|
||||
MediaPlayer.Event.EncounteredError -> error = "Afspeelfout (codec/stream)"
|
||||
MediaPlayer.Event.EndReached -> onBack()
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
mediaPlayer.setEventListener(listener)
|
||||
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()
|
||||
true
|
||||
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,
|
||||
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue