mirror of
https://github.com/jellyfin/jellyfin-androidtv.git
synced 2026-09-14 05:01:51 +02:00
Add playback info overlay to display codec, bitrate, and play method (#5521)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user