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

This commit is contained in:
John Corser
2026-05-09 09:53:24 +02:00
committed by GitHub
parent b8fd850654
commit a2dbcee97a
7 changed files with 193 additions and 16 deletions
@@ -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
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),
)
}
@@ -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)
}
}
+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_audio_night_mode">Levels out audio volume automatically</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_stretch">Stretch</string>
<string name="lbl_fit">Normal</string>
@@ -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
@@ -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