Show file details in overview dialog (#126)

When clicking the overview for an item, the dialog now shows details
about each file/media source including video, audio, & subtitle
information.

It only shows the full file path if the user is an administrator.
This commit is contained in:
damontecres 2025-10-31 12:27:30 -04:00 committed by GitHub
parent ba09ac09e4
commit 1508fa1ea3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 143 additions and 19 deletions

View file

@ -1,25 +1,38 @@
package com.github.damontecres.wholphin.ui.data
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.byteRateSuffixes
import com.github.damontecres.wholphin.util.formatBytes
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.MediaStreamType
import java.util.Locale
data class ItemDetailsDialogInfo(
val title: String,
val overview: String?,
val files: List<String>,
val files: List<MediaSourceInfo>,
)
@Composable
fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
showFilePath: Boolean,
onDismissRequest: () -> Unit,
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
@ -27,26 +40,107 @@ fun ItemDetailsDialog(
item {
Text(
text = info.title,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleLarge,
)
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
)
}
}
if (info.files.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(12.dp))
}
}
items(info.files) { file ->
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
}
}
}
@Composable
fun MediaSourceInfo(
source: MediaSourceInfo,
showFilePath: Boolean,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = "- $file",
style = MaterialTheme.typography.bodyMedium,
text = "Name: ${source.name}",
)
Text(
text = "ID: ${source.id}",
)
if (showFilePath) {
Text(
text = "Path: ${source.path}",
)
}
source.size?.let { size ->
Text(
text = "Size: ${formatBytes(size)}",
)
}
source.bitrate?.let { bitrate ->
Text(
text = "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 = "Video: " + data.joinToString(" - "),
)
}
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("Default")
}
Text(
text = "Audio ${index + 1}: " + data.joinToString(" - "),
)
}
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("Default")
if (stream.isForced) add("Forced")
if (stream.isExternal) add("External")
}
Text(
text = "Subtitle ${index + 1}: " + data.joinToString(" - "),
)
}
}
}
}

View file

@ -193,6 +193,7 @@ fun PersonPage(
overview = person.data.overview,
files = listOf(),
),
showFilePath = false,
onDismissRequest = { showOverviewDialog = false },
)
}
@ -339,8 +340,10 @@ fun PersonHeader(
// .fillMaxWidth(.7f)
.weight(3f)
.padding(top = 16.dp, end = 32.dp)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), RoundedCornerShape(10))
.padding(16.dp),
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
RoundedCornerShape(10),
).padding(16.dp),
) {
Text(
text = name,

View file

@ -142,10 +142,7 @@ fun MovieDetails(
ItemDetailsDialogInfo(
title = movie.name ?: "Unknown",
overview = movie.data.overview,
files =
movie.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
)
},
moreOnClick = {
@ -228,6 +225,10 @@ fun MovieDetails(
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}

View file

@ -176,6 +176,7 @@ fun SeriesDetails(
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath = false,
onDismissRequest = { overviewDialog = null },
)
}

View file

@ -315,10 +315,7 @@ fun SeriesOverview(
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",
overview = it.data.overview,
files =
it.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
files = it.data.mediaSources.orEmpty(),
)
}
},
@ -331,6 +328,10 @@ fun SeriesOverview(
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}

View file

@ -61,7 +61,7 @@ class SeriesViewModel
constructor(
api: ApiClient,
@param:ApplicationContext val context: Context,
private val serverRepository: ServerRepository,
val serverRepository: ServerRepository,
private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,

View file

@ -140,3 +140,27 @@ fun abbreviateNumber(number: Int): String {
}
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
}
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB")
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
/**
* Format bytes
*/
fun formatBytes(
bytes: Int,
suffixes: List<String> = byteSuffixes,
) = formatBytes(bytes.toLong(), suffixes)
fun formatBytes(
bytes: Long,
suffixes: List<String> = byteSuffixes,
): String {
var unit = 0
var count = bytes.toDouble()
while (count >= 1024 && unit + 1 < suffixes.size) {
count /= 1024
unit++
}
return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit])
}