Add playback info overlay to display codec, bitrate, and play method (#5521)

This commit is contained in:
John Corser authored and GitHub committed 2026-05-09 09:53:24 +02:00
1 parent b8fd850654
commit a2dbcee97a
7 files changed
+179 -2

No files matched your search

@@ -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<MediaStreamVideoTrack>().firstOrNull()
val audioTrack = stream.tracks.filterIsInstance<MediaStreamAudioTrack>().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,
)
)
}
@@ -49,7 +49,8 @@ import kotlin.time.DurationUnit
@Composable @Composable
fun VideoPlayerControls( fun VideoPlayerControls(
playbackManager: PlaybackManager = koinInject() playbackManager: PlaybackManager = koinInject(),
onPlaybackInfoClick: () -> Unit = {},
) { ) {
val playState by playbackManager.state.playState.collectAsState() val playState by playbackManager.state.playState.collectAsState()
@@ -68,6 +69,8 @@ fun VideoPlayerControls(
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
PlaybackInfoButton(onClick = onPlaybackInfoClick)
MoreOptionsButton { MoreOptionsButton {
PreviousEntryButton(playbackManager) PreviousEntryButton(playbackManager)
NextEntryButton(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),
)
}
@@ -1,9 +1,17 @@
package org.jellyfin.androidtv.ui.player.video 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.unit.dp
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
import org.jellyfin.androidtv.ui.player.base.PlayerOverlayLayout import org.jellyfin.androidtv.ui.player.base.PlayerOverlayLayout
import org.jellyfin.androidtv.ui.player.base.rememberPlayerOverlayVisibility import org.jellyfin.androidtv.ui.player.base.rememberPlayerOverlayVisibility
@@ -21,24 +29,39 @@ fun VideoPlayerOverlay(
mediaToastRegistry: MediaToastRegistry, mediaToastRegistry: MediaToastRegistry,
) { ) {
val visibilityState = rememberPlayerOverlayVisibility() val visibilityState = rememberPlayerOverlayVisibility()
var showPlaybackInfo by remember { mutableStateOf(false) }
val entry by rememberQueueEntry(playbackManager) val entry by rememberQueueEntry(playbackManager)
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
Box(modifier = modifier) {
PlayerOverlayLayout( PlayerOverlayLayout(
visibilityState = visibilityState, visibilityState = visibilityState,
modifier = modifier,
header = { header = {
Column {
VideoPlayerHeader( VideoPlayerHeader(
item = item, item = item,
) )
}
}, },
controls = { controls = {
VideoPlayerControls( VideoPlayerControls(
playbackManager = playbackManager, playbackManager = playbackManager,
onPlaybackInfoClick = { showPlaybackInfo = !showPlaybackInfo },
) )
}, },
) )
// 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) MediaToasts(mediaToastRegistry)
} }
}
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2V7h2v2z" />
</vector>
+14
View File
@@ -183,6 +183,20 @@
<string name="desc_bitstream_ac3">Requires capable hardware</string> <string name="desc_bitstream_ac3">Requires capable hardware</string>
<string name="desc_audio_night_mode">Levels out audio volume automatically</string> <string name="desc_audio_night_mode">Levels out audio volume automatically</string>
<string name="lbl_zoom">Zoom</string> <string name="lbl_zoom">Zoom</string>
<string name="playback_info">Playback info</string>
<string name="playback_info_play_method">Play method: %1$s</string>
<string name="playback_info_direct_play">Directplay</string>
<string name="playback_info_direct_stream">Directstream</string>
<string name="playback_info_transcoding">Transcoding</string>
<string name="playback_info_container">Container: %1$s</string>
<string name="playback_info_video_title">Video</string>
<string name="playback_info_audio_title">Audio</string>
<string name="playback_info_codec">Codec: %1$s</string>
<string name="playback_info_resolution">Resolution: %1$dx%2$d</string>
<string name="playback_info_bitrate">Bitrate: %1$s</string>
<string name="playback_info_channels">Channels: %1$d</string>
<string name="playback_info_video_range">Video range: %1$s</string>
<string name="playback_info_transcoding_note">Server is transcoding this file</string>
<string name="lbl_auto_crop">Auto crop</string> <string name="lbl_auto_crop">Auto crop</string>
<string name="lbl_stretch">Stretch</string> <string name="lbl_stretch">Stretch</string>
<string name="lbl_fit">Normal</string> <string name="lbl_fit">Normal</string>
@@ -54,6 +54,10 @@ data class MediaStreamAudioTrack(
data class MediaStreamVideoTrack( data class MediaStreamVideoTrack(
override val codec: String, override val codec: String,
val bitrate: Int,
val width: Int,
val height: Int,
val videoRange: String?,
) : MediaStreamTrack ) : MediaStreamTrack
// TODO: Add subtitle track // TODO: Add subtitle track
@@ -35,6 +35,10 @@ private fun getAudioTrack(stream: MediaStream) = MediaStreamAudioTrack(
private fun getVideoTrack(stream: MediaStream) = MediaStreamVideoTrack( private fun getVideoTrack(stream: MediaStream) = MediaStreamVideoTrack(
codec = requireNotNull(stream.codec), 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 // TODO Implement Subtitle track type