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 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.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
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.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.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.components.ScrollableDialog
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.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( data class ItemDetailsDialogInfo(
val title: String, val title: String,
val overview: String?, val overview: String?,
val files: List<String>, val files: List<MediaSourceInfo>,
) )
@Composable @Composable
fun ItemDetailsDialog( fun ItemDetailsDialog(
info: ItemDetailsDialogInfo, info: ItemDetailsDialogInfo,
showFilePath: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) = ScrollableDialog( ) = ScrollableDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -27,26 +40,107 @@ fun ItemDetailsDialog(
item { item {
Text( Text(
text = info.title, text = info.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
) )
} }
if (info.overview.isNotNullOrBlank()) { if (info.overview.isNotNullOrBlank()) {
item { item {
Text( Text(
text = info.overview, text = info.overview,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
) )
} }
} }
if (info.files.isNotEmpty()) { if (info.files.isNotEmpty()) {
item { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(12.dp))
} }
} }
items(info.files) { file -> items(info.files) { file ->
Text( ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
text = "- $file", MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp))
style = MaterialTheme.typography.bodyMedium, }
) }
}
@Composable
fun MediaSourceInfo(
source: MediaSourceInfo,
showFilePath: Boolean,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
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, overview = person.data.overview,
files = listOf(), files = listOf(),
), ),
showFilePath = false,
onDismissRequest = { showOverviewDialog = false }, onDismissRequest = { showOverviewDialog = false },
) )
} }
@ -339,8 +340,10 @@ fun PersonHeader(
// .fillMaxWidth(.7f) // .fillMaxWidth(.7f)
.weight(3f) .weight(3f)
.padding(top = 16.dp, end = 32.dp) .padding(top = 16.dp, end = 32.dp)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), RoundedCornerShape(10)) .background(
.padding(16.dp), MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
RoundedCornerShape(10),
).padding(16.dp),
) { ) {
Text( Text(
text = name, text = name,

View file

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

View file

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

View file

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

View file

@ -61,7 +61,7 @@ class SeriesViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
@param:ApplicationContext val context: Context, @param:ApplicationContext val context: Context,
private val serverRepository: ServerRepository, val serverRepository: ServerRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val itemPlaybackRepository: ItemPlaybackRepository, private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, 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]) 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])
}