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

View file

@ -1,28 +1,35 @@
package com.github.damontecres.wholphin.ui.data package com.github.damontecres.wholphin.ui.data
import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog 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.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.languageName 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType 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 import java.util.Locale
data class ItemDetailsDialogInfo( data class ItemDetailsDialogInfo(
@ -37,129 +44,336 @@ fun ItemDetailsDialog(
info: ItemDetailsDialogInfo, info: ItemDetailsDialogInfo,
showFilePath: Boolean, showFilePath: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
) { ) {
item { val context = LocalContext.current
Text( // Extract stringResource calls outside of ScrollableDialog's non-composable lambda
text = info.title, val pathLabel = stringResource(R.string.path)
style = MaterialTheme.typography.titleLarge, val fileSizeLabel = stringResource(R.string.file_size)
) val videoLabel = stringResource(R.string.video)
} val audioLabel = stringResource(R.string.audio)
if (info.genres.isNotEmpty()) { 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 { item {
Text( Text(
text = info.genres.joinToString(", "), text = info.title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleLarge,
) )
} }
} if (info.genres.isNotEmpty()) {
if (info.overview.isNotNullOrBlank()) { item {
item { Text(
Text( text = info.genres.joinToString(", "),
text = info.overview, style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.bodyLarge, )
) }
} }
} if (info.overview.isNotNullOrBlank()) {
if (info.files.isNotEmpty()) { item {
item { Text(
Spacer(Modifier.height(12.dp)) text = info.overview,
style = MaterialTheme.typography.bodyLarge,
)
}
} }
}
items(info.files) { file -> // Show detailed media information for the selected source (first one if multiple)
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { info.files.forEachIndexed { index, source ->
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp)) source.mediaStreams?.letNotEmpty { mediaStreams ->
item {
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))
}
}
}
} }
} }
} }
@Composable @Composable
fun MediaSourceInfo( private fun MediaInfoSection(
source: MediaSourceInfo, title: String,
showFilePath: Boolean, items: List<Pair<String, String>>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier, modifier = modifier.padding(vertical = 4.dp),
) { ) {
Text( Text(
text = stringResource(R.string.name) + ": ${source.name}", text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
) )
Text( items.forEach { (label, value) ->
text = "ID: ${source.id}", Row(
) modifier = Modifier.padding(start = 12.dp),
if (showFilePath) { ) {
Text(
text = stringResource(R.string.path) + ": ${source.path}",
)
}
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 =
buildList {
if (stream.width != null && stream.height != null) {
add("${stream.width}x${stream.height}")
}
stream.averageFrameRate?.let {
add(String.format(Locale.getDefault(), "%.3f", it) + " fps")
}
stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) }
stream.codec?.let(::add)
stream.profile?.let(::add)
}
Text( Text(
text = stringResource(R.string.video) + ": " + data.joinToString(" - "), text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
) )
}
streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream ->
val data =
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))
}
Text( Text(
text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "), text = value,
) style = MaterialTheme.typography.bodyMedium,
}
streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream ->
val data =
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(" - "),
) )
} }
} }
} }
} }
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(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(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
}
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
}
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()) }
}
private fun buildAudioStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
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)
}
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) }
}
private fun buildSubtitleStreamInfo(
context: Context,
stream: MediaStream,
): List<Pair<String, String>> =
buildList {
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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.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.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -43,6 +44,7 @@ data class MoreDialogActions(
* @param navigateTo a function to trigger a navigation * @param navigateTo a function to trigger a navigation
* @param onChooseVersion callback to pick a version of the item * @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 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( fun buildMoreDialogItems(
context: Context, context: Context,
@ -54,6 +56,7 @@ fun buildMoreDialogItems(
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
onShowOverview: () -> Unit,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
add( 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( add(
DialogItem( DialogItem(
context.getString(R.string.play_with_transcoding), 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="watch_live">Watch live</string>
<string name="years_old">%1$d years old</string> <string name="years_old">%1$d years old</string>
<string name="play_with_transcoding">Play with transcoding</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="show_clock">Show Clock</string>
<string name="aired_episode_order">Aired Episode Order</string> <string name="aired_episode_order">Aired Episode Order</string>
<string name="create_playlist">Create new playlist</string> <string name="create_playlist">Create new playlist</string>
@ -354,6 +355,39 @@
<string name="automatic">Automatic</string> <string name="automatic">Automatic</string>
<string name="username_or_password">Use username/password</string> <string name="username_or_password">Use username/password</string>
<string name="login">Login</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 name="show_titles">Show titles</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">