From a2dbcee97a2d0bb51f8094f2ec3ef0da0fb361fa Mon Sep 17 00:00:00 2001 From: John Corser Date: Sat, 9 May 2026 03:53:24 -0400 Subject: [PATCH] Add playback info overlay to display codec, bitrate, and play method (#5521) --- .../ui/player/video/PlaybackInfoOverlay.kt | 106 ++++++++++++++++++ .../ui/player/video/VideoPlayerControls.kt | 17 ++- .../ui/player/video/VideoPlayerOverlay.kt | 53 ++++++--- app/src/main/res/drawable/ic_info.xml | 11 ++ app/src/main/res/values/strings.xml | 14 +++ .../main/kotlin/mediastream/MediaStream.kt | 4 + .../src/main/kotlin/mediastream/tracks.kt | 4 + 7 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/androidtv/ui/player/video/PlaybackInfoOverlay.kt create mode 100644 app/src/main/res/drawable/ic_info.xml diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/player/video/PlaybackInfoOverlay.kt b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/PlaybackInfoOverlay.kt new file mode 100644 index 000000000..cf49f6a1f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/PlaybackInfoOverlay.kt @@ -0,0 +1,106 @@ +package org.jellyfin.androidtv.ui.player.video + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.ui.base.Text +import org.jellyfin.androidtv.ui.composable.rememberQueueEntry +import org.jellyfin.playback.core.PlaybackManager +import org.jellyfin.playback.core.mediastream.MediaConversionMethod +import org.jellyfin.playback.core.mediastream.MediaStreamAudioTrack +import org.jellyfin.playback.core.mediastream.MediaStreamVideoTrack +import org.jellyfin.playback.core.mediastream.mediaStreamFlow + +@Composable +fun PlaybackInfoOverlay( + playbackManager: PlaybackManager, + modifier: Modifier = Modifier, +) { + val entry by rememberQueueEntry(playbackManager) + val mediaStream by entry?.mediaStreamFlow?.collectAsState(null) ?: return + val stream = mediaStream ?: return + + val videoTrack = stream.tracks.filterIsInstance().firstOrNull() + val audioTrack = stream.tracks.filterIsInstance().firstOrNull() + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .background(Color.Black.copy(alpha = 0.8f)) + .padding(16.dp) + ) { + // Play method + val methodStr = when (stream.conversionMethod) { + MediaConversionMethod.None -> stringResource(R.string.playback_info_direct_play) + MediaConversionMethod.Remux -> stringResource(R.string.playback_info_direct_stream) + MediaConversionMethod.Transcode -> stringResource(R.string.playback_info_transcoding) + } + InfoText(stringResource(R.string.playback_info_play_method, methodStr)) + + // Container + InfoText(stringResource(R.string.playback_info_container, stream.container.format.uppercase())) + + // Video info + if (videoTrack != null) { + InfoText("") + InfoText(stringResource(R.string.playback_info_video_title)) + InfoText(stringResource(R.string.playback_info_codec, videoTrack.codec.uppercase())) + if (videoTrack.width > 0 && videoTrack.height > 0) { + InfoText(stringResource(R.string.playback_info_resolution, videoTrack.width, videoTrack.height)) + } + if (videoTrack.bitrate > 0) { + InfoText(stringResource(R.string.playback_info_bitrate, formatBitrate(videoTrack.bitrate))) + } + videoTrack.videoRange?.let { InfoText(stringResource(R.string.playback_info_video_range, it)) } + } + + // Audio info + if (audioTrack != null) { + InfoText("") + InfoText(stringResource(R.string.playback_info_audio_title)) + InfoText(stringResource(R.string.playback_info_codec, audioTrack.codec.uppercase())) + InfoText(stringResource(R.string.playback_info_channels, audioTrack.channels)) + if (audioTrack.bitrate > 0) { + InfoText(stringResource(R.string.playback_info_bitrate, formatBitrate(audioTrack.bitrate))) + } + } + + // Transcoding note + if (stream.conversionMethod == MediaConversionMethod.Transcode) { + InfoText("") + InfoText(stringResource(R.string.playback_info_transcoding_note)) + } + } +} + +@Composable +private fun formatBitrate(bitrate: Int): String { + val mbitStr = stringResource(R.string.bitrate_mbit, 0f).substringAfter("0") + val kbitStr = stringResource(R.string.bitrate_kbit, 0f).substringAfter("0") + return when { + bitrate >= 1_000_000 -> "%.1f$mbitStr".format(bitrate / 1_000_000f) + else -> "%.0f$kbitStr".format(bitrate / 1_000f) + } +} + +@Composable +private fun InfoText(text: String) { + Text( + text = text, + style = TextStyle( + color = Color.White, + fontSize = 14.sp, + ) + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerControls.kt b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerControls.kt index b728c8cb4..57e016253 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerControls.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerControls.kt @@ -49,7 +49,8 @@ import kotlin.time.DurationUnit @Composable fun VideoPlayerControls( - playbackManager: PlaybackManager = koinInject() + playbackManager: PlaybackManager = koinInject(), + onPlaybackInfoClick: () -> Unit = {}, ) { val playState by playbackManager.state.playState.collectAsState() @@ -68,6 +69,8 @@ fun VideoPlayerControls( Spacer(Modifier.weight(1f)) + PlaybackInfoButton(onClick = onPlaybackInfoClick) + MoreOptionsButton { PreviousEntryButton(playbackManager) NextEntryButton(playbackManager) @@ -267,3 +270,15 @@ private fun MoreOptionsButton( } } } + +@Composable +fun PlaybackInfoButton( + onClick: () -> Unit, +) = IconButton( + onClick = onClick, +) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_info), + contentDescription = stringResource(R.string.playback_info), + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerOverlay.kt b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerOverlay.kt index 25b1c0d6b..c1d3bec05 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerOverlay.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/player/video/VideoPlayerOverlay.kt @@ -1,9 +1,17 @@ package org.jellyfin.androidtv.ui.player.video +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import org.jellyfin.androidtv.ui.composable.rememberQueueEntry import org.jellyfin.androidtv.ui.player.base.PlayerOverlayLayout import org.jellyfin.androidtv.ui.player.base.rememberPlayerOverlayVisibility @@ -21,24 +29,39 @@ fun VideoPlayerOverlay( mediaToastRegistry: MediaToastRegistry, ) { val visibilityState = rememberPlayerOverlayVisibility() + var showPlaybackInfo by remember { mutableStateOf(false) } val entry by rememberQueueEntry(playbackManager) val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value - PlayerOverlayLayout( - visibilityState = visibilityState, - modifier = modifier, - header = { - VideoPlayerHeader( - item = item, - ) - }, - controls = { - VideoPlayerControls( - playbackManager = playbackManager, - ) - }, - ) + Box(modifier = modifier) { + PlayerOverlayLayout( + visibilityState = visibilityState, + header = { + Column { + VideoPlayerHeader( + item = item, + ) + } + }, + controls = { + VideoPlayerControls( + playbackManager = playbackManager, + onPlaybackInfoClick = { showPlaybackInfo = !showPlaybackInfo }, + ) + }, + ) - MediaToasts(mediaToastRegistry) + // Playback info overlay - positioned below header area, always visible when enabled + if (showPlaybackInfo) { + PlaybackInfoOverlay( + playbackManager = playbackManager, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 48.dp, top = 100.dp) + ) + } + + MediaToasts(mediaToastRegistry) + } } diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 000000000..12425288c --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39ba40c25..cbc959fd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -183,6 +183,20 @@ Requires capable hardware Levels out audio volume automatically Zoom + Playback info + Play method: %1$s + Directplay + Directstream + Transcoding + Container: %1$s + Video + Audio + Codec: %1$s + Resolution: %1$dx%2$d + Bitrate: %1$s + Channels: %1$d + Video range: %1$s + Server is transcoding this file Auto crop Stretch Normal diff --git a/playback/core/src/main/kotlin/mediastream/MediaStream.kt b/playback/core/src/main/kotlin/mediastream/MediaStream.kt index 1e4361752..f29256010 100644 --- a/playback/core/src/main/kotlin/mediastream/MediaStream.kt +++ b/playback/core/src/main/kotlin/mediastream/MediaStream.kt @@ -54,6 +54,10 @@ data class MediaStreamAudioTrack( data class MediaStreamVideoTrack( override val codec: String, + val bitrate: Int, + val width: Int, + val height: Int, + val videoRange: String?, ) : MediaStreamTrack // TODO: Add subtitle track diff --git a/playback/jellyfin/src/main/kotlin/mediastream/tracks.kt b/playback/jellyfin/src/main/kotlin/mediastream/tracks.kt index 82cedfa92..89d8ec78b 100644 --- a/playback/jellyfin/src/main/kotlin/mediastream/tracks.kt +++ b/playback/jellyfin/src/main/kotlin/mediastream/tracks.kt @@ -35,6 +35,10 @@ private fun getAudioTrack(stream: MediaStream) = MediaStreamAudioTrack( private fun getVideoTrack(stream: MediaStream) = MediaStreamVideoTrack( codec = requireNotNull(stream.codec), + bitrate = stream.bitRate ?: 0, + width = stream.width ?: 0, + height = stream.height ?: 0, + videoRange = stream.videoRangeType.name, ) // TODO Implement Subtitle track type