mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
ba09ac09e4
commit
1508fa1ea3
7 changed files with 143 additions and 19 deletions
|
|
@ -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(" - "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue