Add media information dialog. (#416)

Addresses #414

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Amar Sandhu 2025-12-12 09:49:30 -07:00 committed by GitHub
parent 6d6156917c
commit c3f6fd7f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 401 additions and 105 deletions

View file

@ -310,6 +310,9 @@ fun DialogPopup(
@Composable
fun ScrollableDialog(
onDismissRequest: () -> Unit,
width: Dp = 600.dp,
maxHeight: Dp = 380.dp,
itemSpacing: Dp = 8.dp,
content: LazyListScope.() -> Unit,
) {
val scrollAmount = 100f
@ -331,12 +334,12 @@ fun ScrollableDialog(
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(itemSpacing),
content = content,
modifier =
Modifier
.width(600.dp)
.heightIn(max = 380.dp)
.width(width)
.heightIn(max = maxHeight)
.focusable()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),

View file

@ -1,28 +1,35 @@
package com.github.damontecres.wholphin.ui.data
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.languageName
import org.jellyfin.sdk.model.api.AudioSpatialFormat
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import java.util.Locale
data class ItemDetailsDialogInfo(
@ -37,8 +44,21 @@ fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
showFilePath: Boolean,
onDismissRequest: () -> Unit,
) = ScrollableDialog(
) {
val context = LocalContext.current
// Extract stringResource calls outside of ScrollableDialog's non-composable lambda
val pathLabel = stringResource(R.string.path)
val fileSizeLabel = stringResource(R.string.file_size)
val videoLabel = stringResource(R.string.video)
val audioLabel = stringResource(R.string.audio)
val subtitleLabel = stringResource(R.string.subtitle)
val bitrateLabel = stringResource(R.string.bitrate)
ScrollableDialog(
onDismissRequest = onDismissRequest,
width = 720.dp,
maxHeight = 480.dp,
itemSpacing = 4.dp,
) {
item {
Text(
@ -62,104 +82,298 @@ fun ItemDetailsDialog(
)
}
}
if (info.files.isNotEmpty()) {
// Show detailed media information for the selected source (first one if multiple)
info.files.forEachIndexed { index, source ->
source.mediaStreams?.letNotEmpty { mediaStreams ->
item {
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(8.dp))
}
// General file information
item {
val containerLabel = stringResource(R.string.container)
MediaInfoSection(
title = stringResource(R.string.general),
items =
buildList {
source.container?.let { add(containerLabel to it) }
if (showFilePath) {
source.path?.let { add(pathLabel to it) }
}
source.size?.let {
add(fileSizeLabel to formatBytes(it))
}
source.bitrate?.let {
add(
bitrateLabel to formatBytes(it, byteRateSuffixes),
)
}
},
)
}
// Video streams
items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream ->
MediaInfoSection(
title = videoLabel,
items = buildVideoStreamInfo(context, stream),
)
}
// Audio streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.AUDIO }
.chunked(3),
) { streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
MediaInfoSection(
title = audioLabel,
items = buildAudioStreamInfo(context, stream),
modifier = Modifier.weight(1f),
)
}
// Fill remaining space if less than 3 items
repeat(3 - streamGroup.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
// Subtitle streams - display multiple per row
items(
mediaStreams
.filter { it.type == MediaStreamType.SUBTITLE }
.chunked(3),
) { streamGroup ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
streamGroup.forEach { stream ->
MediaInfoSection(
title = subtitleLabel,
items = buildSubtitleStreamInfo(context, stream),
modifier = Modifier.weight(1f),
)
}
// Fill remaining space if less than 3 items
repeat(3 - streamGroup.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
if (index != info.files.lastIndex) {
item {
Spacer(Modifier.height(8.dp))
}
}
}
items(info.files) { file ->
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
}
}
}
@Composable
fun MediaSourceInfo(
source: MediaSourceInfo,
showFilePath: Boolean,
private fun MediaInfoSection(
title: String,
items: List<Pair<String, String>>,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.padding(vertical = 4.dp),
) {
Text(
text = stringResource(R.string.name) + ": ${source.name}",
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
items.forEach { (label, value) ->
Row(
modifier = Modifier.padding(start = 12.dp),
) {
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
Text(
text = "ID: ${source.id}",
)
if (showFilePath) {
Text(
text = stringResource(R.string.path) + ": ${source.path}",
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
source.size?.let { size ->
Text(
text = stringResource(R.string.file_size) + ": ${formatBytes(size)}",
)
}
source.bitrate?.let { bitrate ->
Text(
text =
stringResource(R.string.bitrate) + ": ${
formatBytes(
bitrate,
byteRateSuffixes,
)
}",
)
}
source.mediaStreams?.letNotEmpty { streams ->
streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream ->
val data =
}
private fun buildVideoStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
val titleLabel = context.getString(R.string.title)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val profileLabel = context.getString(R.string.profile)
val levelLabel = context.getString(R.string.level)
val resolutionLabel = context.getString(R.string.resolution)
val aspectRatioLabel = context.getString(R.string.aspect_ratio)
val anamorphicLabel = context.getString(R.string.anamorphic)
val interlacedLabel = context.getString(R.string.interlaced)
val framerateLabel = context.getString(R.string.framerate)
val bitrateLabel = context.getString(R.string.bitrate)
val bitDepthLabel = context.getString(R.string.bit_depth)
val videoRangeLabel = context.getString(R.string.video_range)
val videoRangeTypeLabel = context.getString(R.string.video_range_type)
val colorSpaceLabel = context.getString(R.string.color_space)
val colorTransferLabel = context.getString(R.string.color_transfer)
val colorPrimariesLabel = context.getString(R.string.color_primaries)
val pixelFormatLabel = context.getString(R.string.pixel_format)
val refFramesLabel = context.getString(R.string.ref_frames)
val nalLabel = context.getString(R.string.nal)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val sdrStr = context.getString(R.string.sdr)
val hdrStr = context.getString(R.string.hdr)
val hdr10Str = context.getString(R.string.hdr10)
val hdr10PlusStr = context.getString(R.string.hdr10_plus)
val hlgStr = context.getString(R.string.hlg)
val bitUnit = context.getString(R.string.bit_unit)
stream.title?.let { add(titleLabel to it) }
stream.codec?.let { add(codecLabel to it.uppercase()) }
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.profile?.let { add(profileLabel to it) }
stream.level?.let { add(levelLabel to it.toString()) }
if (stream.width != null && stream.height != null) {
add("${stream.width}x${stream.height}")
add(resolutionLabel to "${stream.width}x${stream.height}")
}
if (stream.width != null && stream.height != null) {
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio)
}
stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) }
stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) }
stream.averageFrameRate?.let {
add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
}
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
stream.codec?.let(::add)
stream.profile?.let(::add)
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") }
stream.videoRange?.let {
val rangeStr =
when (it) {
VideoRange.SDR -> sdrStr
VideoRange.HDR -> hdrStr
VideoRange.UNKNOWN -> null
else -> null
}
Text(
text = stringResource(R.string.video) + ": " + data.joinToString(" - "),
)
rangeStr?.let { add(videoRangeLabel to it) }
}
stream.videoRangeType?.let {
val rangeTypeStr =
when (it) {
VideoRangeType.SDR -> sdrStr
VideoRangeType.HDR10 -> hdr10Str
VideoRangeType.HDR10_PLUS -> hdr10PlusStr
VideoRangeType.HLG -> hlgStr
VideoRangeType.DOVI,
VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision)
VideoRangeType.UNKNOWN -> null
else -> null
}
rangeTypeStr?.let { add(videoRangeTypeLabel to it) }
}
stream.colorSpace?.let { add(colorSpaceLabel to it) }
stream.colorTransfer?.let { add(colorTransferLabel to it) }
stream.colorPrimaries?.let { add(colorPrimariesLabel to it) }
stream.pixelFormat?.let { add(pixelFormatLabel to it) }
stream.refFrames?.let { add(refFramesLabel to it.toString()) }
stream.nalLengthSize?.let { add(nalLabel to it.toString()) }
}
streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
val data =
private fun buildAudioStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
stream.language?.let { add(languageName(it)) }
stream.codec?.let(::add)
stream.channelLayout?.let(::add)
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName)
if (stream.isDefault) add(stringResource(R.string.default_track))
val titleLabel = context.getString(R.string.title)
val languageLabel = context.getString(R.string.language)
val codecLabel = context.getString(R.string.codec)
val layoutLabel = context.getString(R.string.layout)
val channelsLabel = context.getString(R.string.channels)
val bitrateLabel = context.getString(R.string.bitrate)
val sampleRateLabel = context.getString(R.string.sample_rate)
val defaultLabel = context.getString(R.string.default_track)
val externalLabel = context.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let {
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase()
add(codecLabel to formattedCodec)
}
Text(
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "),
)
stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
}
streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
val data =
private fun buildSubtitleStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
stream.language?.let { add(languageName(it)) }
stream.codec?.let(::add)
if (stream.isDefault) add(stringResource(R.string.default_track))
if (stream.isForced) add(stringResource(R.string.forced_track))
if (stream.isExternal) add(stringResource(R.string.external_track))
}
Text(
text =
stringResource(R.string.subtitle) + " ${index + 1}: " +
data.joinToString(" - "),
)
}
val titleLabel = context.getString(R.string.title)
val languageLabel = context.getString(R.string.language)
val codecLabel = context.getString(R.string.codec)
val avcLabel = context.getString(R.string.avc)
val defaultLabel = context.getString(R.string.default_track)
val forcedLabel = context.getString(R.string.forced_track)
val externalLabel = context.getString(R.string.external_track)
val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no)
stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let {
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
add(codecLabel to formattedCodec)
}
stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) }
stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) }
}
private fun calculateAspectRatio(
width: Int,
height: Int,
): String {
val gcd = gcd(width, height)
val w = width / gcd
val h = height / gcd
return "$w:$h"
}
private fun gcd(
a: Int,
b: Int,
): Int = if (b == 0) a else gcd(b, a % b)

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color
@ -43,6 +44,7 @@ data class MoreDialogActions(
* @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item
* @param onChooseTracks callback to pick a track for the given type of the item
* @param onShowOverview callback to show overview dialog with media information
*/
fun buildMoreDialogItems(
context: Context,
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
): List<DialogItem> =
buildList {
add(
@ -159,6 +162,16 @@ fun buildMoreDialogItems(
},
)
}
if (item.data.mediaSources?.isNotEmpty() == true) {
add(
DialogItem(
context.getString(R.string.media_information),
Icons.Default.Info,
) {
onShowOverview.invoke()
},
)
}
add(
DialogItem(
context.getString(R.string.play_with_transcoding),

View file

@ -195,6 +195,18 @@ fun EpisodeDetails(
)
}
},
onShowOverview = {
val source = chosenStreams?.source ?: ep.data.mediaSources?.firstOrNull()
if (source != null) {
overviewDialog =
ItemDetailsDialogInfo(
title = ep.name ?: context.getString(R.string.unknown),
overview = ep.data.overview,
genres = ep.data.genres.orEmpty(),
files = listOf(source),
)
}
},
),
)
},

View file

@ -242,6 +242,17 @@ fun MovieDetails(
)
}
},
onShowOverview = {
overviewDialog =
ItemDetailsDialogInfo(
title =
movie.name
?: context.getString(R.string.unknown),
overview = movie.data.overview,
genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
)
},
),
)
},

View file

@ -278,6 +278,15 @@ fun SeriesOverview(
)
}
},
onShowOverview = {
overviewDialog =
ItemDetailsDialogInfo(
title = ep.name ?: context.getString(R.string.unknown),
overview = ep.data.overview,
genres = ep.data.genres.orEmpty(),
files = ep.data.mediaSources.orEmpty(),
)
},
),
)

View file

@ -140,6 +140,7 @@
<string name="watch_live">Watch live</string>
<string name="years_old">%1$d years old</string>
<string name="play_with_transcoding">Play with transcoding</string>
<string name="media_information">Media Information</string>
<string name="show_clock">Show Clock</string>
<string name="aired_episode_order">Aired Episode Order</string>
<string name="create_playlist">Create new playlist</string>
@ -354,6 +355,39 @@
<string name="automatic">Automatic</string>
<string name="username_or_password">Use username/password</string>
<string name="login">Login</string>
<string name="general">General</string>
<string name="container">Container</string>
<string name="title">Title</string>
<string name="codec">Codec</string>
<string name="profile">Profile</string>
<string name="level">Level</string>
<string name="resolution">Resolution</string>
<string name="anamorphic">Anamorphic</string>
<string name="interlaced">Interlaced</string>
<string name="framerate">Framerate</string>
<string name="bit_depth">Bit depth</string>
<string name="video_range">Video range</string>
<string name="video_range_type">Video range type</string>
<string name="color_space">Color space</string>
<string name="color_transfer">Color transfer</string>
<string name="color_primaries">Color primaries</string>
<string name="pixel_format">Pixel format</string>
<string name="ref_frames">Ref frames</string>
<string name="nal">NAL</string>
<string name="language">Language</string>
<string name="layout">Layout</string>
<string name="channels">Channels</string>
<string name="sample_rate">Sample rate</string>
<string name="avc">AVC</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="sdr">SDR</string>
<string name="hdr">HDR</string>
<string name="hdr10">HDR10</string>
<string name="hdr10_plus">HDR10+</string>
<string name="hlg">HLG</string>
<string name="bit_unit">bit</string>
<string name="sample_rate_unit">Hz</string>
<string name="show_titles">Show titles</string>
<string-array name="theme_song_volume">