diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index d544704..c096c2f 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt new file mode 100644 index 0000000..8b00b5a --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -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 { + val out = mutableListOf() + 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 +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 1534a7f..34eddf4 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -432,9 +432,15 @@ private fun HeroHeader( @Composable private fun ShelfBlock(title: String, items: List, 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) + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index bf9bd75..8e7103a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -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(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>(emptyList()) } - var spuTracks by remember { mutableStateOf>(emptyList()) } - var trackMenu by remember { mutableStateOf(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>(emptyList()) } + var textTracks by remember { mutableStateOf>(emptyList()) } + var menu by remember { mutableStateOf(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) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index 4931364..df6dc34 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -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)) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt index e4e0d2c..eb523ae 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt @@ -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), + ), ) } diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index cbaf3f8..96af123 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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",