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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
@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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue