From 8bdc8a6f8fce6c68bb7070d6c3cb845490a98f04 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:46:38 -0500 Subject: [PATCH] Consistent audio/subtitle info across dialogs (#664) ## Description Makes the labels for audio & subtitle stream consistent across the app The More->Choose dialogs now highlight which track is currently choosen. Also uses more of the brand names for audio codecs (eg DD+) and allows for these to be translated into other languages. ### Related issues Closes #621 Fixes #668 Related to https://github.com/damontecres/Wholphin/issues/528#issuecomment-3678876207, I adjusted the background & selection colors of the dialogs during playback ### Screenshots #### Choose subtitles from More dialog ![subtitle_dialog1 Large](https://github.com/user-attachments/assets/edaf96a0-fea4-4110-b274-e8c57494bc59) #### Choose subtitles during playback ![subtitle_dialog2 Large](https://github.com/user-attachments/assets/c0dd76ab-bb2c-42a9-bfa7-2d3b6ffe2b35) #### Choose audio ![audio_tracks Large](https://github.com/user-attachments/assets/3214879d-9845-48ff-918c-e9cd470ed0bc) --- .../wholphin/ui/components/Dialogs.kt | 18 +- .../ui/components/SelectedLeadingContent.kt | 32 ++ .../ui/components/VideoStreamDetails.kt | 129 +------- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 13 +- .../ui/detail/episode/EpisodeDetails.kt | 7 + .../wholphin/ui/detail/movie/MovieDetails.kt | 8 + .../ui/detail/series/SeriesOverview.kt | 7 + .../wholphin/ui/playback/CurrentMediaInfo.kt | 4 +- .../wholphin/ui/playback/Models.kt | 73 ---- .../wholphin/ui/playback/PlaybackControls.kt | 53 ++- .../wholphin/ui/playback/PlaybackDialog.kt | 313 ++++++++++++++---- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../wholphin/ui/playback/SimpleMediaStream.kt | 25 ++ .../ui/playback/SubtitleSearchUtils.kt | 53 ++- .../wholphin/ui/theme/ThemePreview.kt | 21 +- .../ui/theme/colors/PurpleThemeColors.kt | 2 +- .../wholphin/ui/util/StreamFormatting.kt | 171 ++++++++++ app/src/main/res/values/strings.xml | 6 + 18 files changed, 620 insertions(+), 328 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 4b985056..85065b43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -84,6 +85,7 @@ data class DialogItem( val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, + val selected: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @@ -266,7 +268,7 @@ fun DialogPopupContent( is DialogItem -> { ListItem( - selected = false, + selected = it.selected, enabled = !waiting && it.enabled, onClick = { if (dismissOnClick) { @@ -502,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( context: Context, streams: List, + currentIndex: Int?, type: MediaStreamType, onClick: (Int) -> Unit, ): DialogParams = @@ -513,6 +516,10 @@ fun chooseStream( if (type == MediaStreamType.SUBTITLE) { add( DialogItem( + selected = currentIndex == null, + leadingContent = { + SelectedLeadingContent(currentIndex == null) + }, headlineContent = { Text(text = stringResource(R.string.none)) }, @@ -524,12 +531,17 @@ fun chooseStream( } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> - val title = stream.displayTitle ?: stream.title ?: "" + val simpleStream = SimpleMediaStream.from(context, stream, true) DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, headlineContent = { - Text(text = title) + Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) }, supportingContent = { + if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, onClick = { onClick.invoke(stream.index) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt new file mode 100644 index 00000000..efc2ddac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor + +@Composable +fun BoxScope.SelectedLeadingContent( + selected: Boolean, + modifier: Modifier = Modifier, +) { + if (selected) { + Box( + modifier = + modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .align(Alignment.Center) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index e9a11d54..7805a95a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -31,17 +30,18 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString import com.github.damontecres.wholphin.util.languageName -import com.github.damontecres.wholphin.util.profile.Codec import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType @Composable @NonRestartableComposable @@ -155,35 +155,6 @@ fun VideoStreamDetails( } } -fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" - -// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 -fun resolutionString( - width: Int, - height: Int, - interlaced: Boolean, -): String = - if (height > width) { - // Vertical video - resolutionString(height, width, interlaced) - } else { - when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" - else -> height.toString() + interlaced(interlaced) - } - } - @Composable fun StreamLabel( text: String, @@ -257,93 +228,3 @@ private fun StreamLabelPreview() { } } } - -fun formatVideoRange( - context: Context, - videoRange: VideoRange?, - type: VideoRangeType?, - doviTitle: String?, -): String? = - when (videoRange) { - VideoRange.UNKNOWN, - VideoRange.SDR, null, - -> { - null - } - - VideoRange.HDR -> { - if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) - } else { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null - - VideoRangeType.HDR10 -> "HDR10" - - VideoRangeType.HDR10_PLUS -> "HDR10+" - - VideoRangeType.HLG -> "HLG" - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) - } - } - } - } - -fun formatAudioCodec( - context: Context, - codec: String?, - profile: String?, -): String? = - when { - profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) - } - - profile?.contains("DTS:X", true) == true -> { - "DTS:X" - } - - profile?.contains("DTS:HD", true) == true -> { - "DTS:HD" - } - - else -> { - when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> "TrueHD" - - Codec.Audio.OGG, - Codec.Audio.OPUS, - Codec.Audio.VORBIS, - -> codec.replaceFirstChar { it.uppercase() } - - null -> null - - else -> codec.uppercase() - } - } - } - -fun formatSubtitleCodec(codec: String?): String? = - when (codec?.lowercase()) { - Codec.Subtitle.DVBSUB -> "DVB" - Codec.Subtitle.DVDSUB -> "DVD" - Codec.Subtitle.PGSSUB -> "PGS" - Codec.Subtitle.SUBRIP -> "SRT" - null -> null - else -> codec.uppercase() - } - -fun String?.concatWithSpace(str: String?): String? = - when { - this != null && str != null -> "$this $str" - this == null -> str - else -> this - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 8e6eb07f..fa32c595 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -21,11 +21,11 @@ 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.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -391,7 +391,7 @@ private fun buildAudioStreamInfo( 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 profileLabel = context.getString(R.string.profile) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) val sampleRateUnit = context.getString(R.string.sample_rate_unit) @@ -399,11 +399,12 @@ private fun buildAudioStreamInfo( 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() + val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } + stream.profile?.let { add(profileLabel to it) } 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) } @@ -428,7 +429,7 @@ private fun buildSubtitleStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() + val formattedCodec = formatSubtitleCodec(it) + " ($it)" add(codecLabel to formattedCodec) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -438,7 +439,7 @@ private fun buildSubtitleStreamInfo( add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) } if (showPath) { - stream.path?.let { pathLabel to it } + stream.path?.let { add(pathLabel to it) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 6d53b0c1..cb9e2977 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -181,6 +182,12 @@ fun EpisodeDetails( chooseStream( context = context, streams = source.mediaStreams.orEmpty(), + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, type = type, onClick = { trackIndex -> viewModel.saveTrackSelection( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index a63dd128..6055ef1f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -210,6 +211,7 @@ fun MovieDetails( moreDialog = null }, onChooseTracks = { type -> + viewModel.streamChoiceService .chooseSource( movie.data, @@ -220,6 +222,12 @@ fun MovieDetails( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( movie, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 891d3bc2..7553c9e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -242,6 +243,12 @@ fun SeriesOverview( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( ep, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 0fe5c564..309f099b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( - val audioStreams: List, - val subtitleStreams: List, + val audioStreams: List, + val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt deleted file mode 100644 index 728f283a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication -import org.jellyfin.sdk.model.api.MediaStream - -data class SubtitleStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val external: Boolean, - val forced: Boolean, - val default: Boolean, - val displayTitle: String?, -) { - val displayName: String - get() = - displayTitle ?: listOfNotNull( - language, - title, - codec, - ).joinToString(" - ") - .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } - - companion object { - fun from(it: MediaStream): SubtitleStream = - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } -} - -data class AudioStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val channels: Int?, - val channelLayout: String?, -) { - val displayName: String - get() = - listOfNotNull( - language, - title, - codec, - channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" }, - ).joinToString(" - ").ifBlank { "Unknown" } - - companion object { - fun from(it: MediaStream): AudioStream = - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8cb05699..75ac0951 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward @@ -462,12 +463,12 @@ fun PlaybackButton( } @Composable -fun BottomDialog( - choices: List, +fun BottomDialog( + choices: List>, onDismissRequest: () -> Unit, - onSelectChoice: (Int, String) -> Unit, + onSelectChoice: (Int, BottomDialogItem) -> Unit, gravity: Int, - currentChoice: Int? = null, + currentChoice: BottomDialogItem? = null, ) { // TODO enforcing a width ends up ignore the gravity Dialog( @@ -485,7 +486,10 @@ fun BottomDialog( Modifier .wrapContentSize() .padding(8.dp) - .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(8.dp), + ), ) { LazyColumn( modifier = @@ -498,38 +502,27 @@ fun BottomDialog( ) { itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - val color = - if (focused) { - MaterialTheme.colorScheme.inverseOnSurface - } else { - MaterialTheme.colorScheme.onSurface - } ListItem( - selected = index == currentChoice, + selected = choice == currentChoice, onClick = { onDismissRequest() onSelectChoice(index, choice) }, leadingContent = { - if (index == currentChoice) { - Box( - modifier = - Modifier - .padding(horizontal = 4.dp) - .clip(CircleShape) - .align(Alignment.Center) - .background(color) - .size(8.dp), - ) - } + SelectedLeadingContent(choice == currentChoice) }, headlineContent = { Text( - text = choice, - color = color, + text = choice.headline, ) }, + supportingContent = { + choice.supporting?.let { + Text( + text = it, + ) + } + }, interactionSource = interactionSource, ) } @@ -542,6 +535,12 @@ data class MoreButtonOptions( val options: Map, ) +data class BottomDialogItem( + val data: T, + val headline: String, + val supporting: String?, +) + @PreviewTvSpec @Composable private fun ButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 1f13b278..f56a8e2d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView @@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import timber.log.Timber +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import kotlin.time.Duration enum class PlaybackDialogType { @@ -35,9 +47,9 @@ enum class PlaybackDialogType { data class PlaybackSettings( val showDebugInfo: Boolean, val audioIndex: Int?, - val audioStreams: List, + val audioStreams: List, val subtitleIndex: Int?, - val subtitleStreams: List, + val subtitleStreams: List, val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, @@ -59,7 +71,13 @@ fun PlaybackDialog( PlaybackDialogType.MORE -> { val options = buildList { - add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) + add( + BottomDialogItem( + data = 0, + headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ), + ) } BottomDialog( choices = options, @@ -75,76 +93,82 @@ fun PlaybackDialog( } PlaybackDialogType.CAPTIONS -> { - val subtitleStreams = settings.subtitleStreams - val options = subtitleStreams.map { it.displayName } - Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") - val currentChoice = - subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size - BottomDialog( - choices = - options + - listOf( - stringResource(R.string.none), - stringResource(R.string.search_and_download), - ), - currentChoice = currentChoice, + SubtitleChoiceBottomDialog( + choices = settings.subtitleStreams, + currentChoice = settings.subtitleIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// // TODO this is hacky, but playback changes force refocus and this is a workaround -// delay(250L) -// captionFocusRequester.tryRequestFocus() -// } }, - onSelectChoice = { index, _ -> - if (index in subtitleStreams.indices) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) - } else { - val idx = index - subtitleStreams.size - if (idx == 0) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) - } else { - onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) - } + onSelectChoice = { subtitleIndex -> + onDismissRequest.invoke() + if (subtitleIndex >= 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) + } else if (subtitleIndex == TrackIndex.DISABLED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) } }, + onSelectSearch = { + onDismissRequest.invoke() + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) + }, gravity = Gravity.END, ) } PlaybackDialogType.SETTINGS -> { + val currentAudio = + remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add(stringResource(R.string.audio)) - add(stringResource(R.string.playback_speed)) + add( + BottomDialogItem( + data = PlaybackDialogType.AUDIO, + headline = stringResource(R.string.audio), + supporting = currentAudio?.displayTitle, + ), + ) + add( + BottomDialogItem( + data = PlaybackDialogType.PLAYBACK_SPEED, + headline = stringResource(R.string.playback_speed), + supporting = settings.playbackSpeed.toString(), + ), + ) if (enableVideoScale) { - add(stringResource(R.string.video_scale)) + add( + BottomDialogItem( + data = PlaybackDialogType.VIDEO_SCALE, + headline = stringResource(R.string.video_scale), + supporting = playbackScaleOptions[settings.contentScale], + ), + ) } if (enableSubtitleDelay) { - add(stringResource(R.string.subtitle_delay)) + add( + BottomDialogItem( + data = PlaybackDialogType.SUBTITLE_DELAY, + headline = stringResource(R.string.subtitle_delay), + supporting = settings.subtitleDelay.toString(), + ), + ) } } BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, - onSelectChoice = { index, _ -> - when (index) { - 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) - 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) - 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) - 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY) - } + onSelectChoice = { _, choice -> + onClickPlaybackDialogType(choice.data) }, gravity = Gravity.END, ) } PlaybackDialogType.AUDIO -> { - BottomDialog( - choices = settings.audioStreams.map { it.displayName }, - currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, + StreamChoiceBottomDialog( + choices = settings.audioStreams, + currentChoice = settings.audioIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -153,17 +177,25 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index)) }, gravity = Gravity.END, ) } PlaybackDialogType.PLAYBACK_SPEED -> { + val choices = + playbackSpeedOptions.map { + BottomDialogItem( + data = it.toFloat(), + headline = it, + supporting = null, + ) + } BottomDialog( - choices = playbackSpeedOptions, - currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -173,16 +205,24 @@ fun PlaybackDialog( // } }, onSelectChoice = { _, value -> - onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) + onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, gravity = Gravity.END, ) } PlaybackDialogType.VIDEO_SCALE -> { + val choices = + playbackScaleOptions.map { (scale, name) -> + BottomDialogItem( + data = scale, + headline = name, + supporting = null, + ) + } BottomDialog( - choices = playbackScaleOptions.values.toList(), - currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.contentScale }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -191,8 +231,8 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, gravity = Gravity.END, ) @@ -225,3 +265,164 @@ fun PlaybackDialog( } } } + +@Composable +fun SubtitleChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int) -> Unit, + onSelectSearch: () -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + ListItem( + selected = currentChoice == TrackIndex.DISABLED, + onClick = { + onSelectChoice(TrackIndex.DISABLED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.DISABLED) + }, + headlineContent = { + Text( + text = stringResource(R.string.none), + ) + }, + supportingContent = {}, + ) + } + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onSelectChoice(choice.index) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + item { + HorizontalDivider() + ListItem( + selected = false, + onClick = onSelectSearch, + leadingContent = {}, + headlineContent = { + Text( + text = stringResource(R.string.search_and_download), + ) + }, + supportingContent = {}, + ) + } + } + } + } +} + +@Composable +fun StreamChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int, SimpleMediaStream) -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onDismissRequest() + onSelectChoice(index, choice) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index a9792bb8..eb7c314b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -384,15 +384,18 @@ class PlaybackViewModel val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map(SubtitleStream::from) - .orEmpty() + ?.map { + SimpleMediaStream.from(context, it, true) + }.orEmpty() + val audioStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } - ?.map(AudioStream::from) - ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + ?.map { + SimpleMediaStream.from(context, it, true) + } +// ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - val audioStream = streamChoiceService .chooseAudioStream( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt new file mode 100644 index 00000000..885be814 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -0,0 +1,25 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle +import org.jellyfin.sdk.model.api.MediaStream + +data class SimpleMediaStream( + val index: Int, + val streamTitle: String?, + val displayTitle: String, +) { + companion object { + fun from( + context: Context, + mediaStream: MediaStream, + includeFlags: Boolean = true, + ): SimpleMediaStream = + SimpleMediaStream( + index = mediaStream.index, + streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, + displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 8568b286..021f4290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber @@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) + val currentSource = + this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo val currentSubtitleStreams = - this@downloadAndSwitchSubtitles - .currentMediaInfo.value - ?.subtitleStreams + currentSource + ?.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() + val externalPaths = currentSubtitleStreams.map { it.path } + val subtitleCount = currentSubtitleStreams.size var newCount = subtitleCount var maxAttempts = 4 - var newStreams: List = listOf() + var mediaSource: MediaSourceInfo? = null // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- @@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( api.userLibraryApi.getItem(itemId = it.itemId).content, api, ) - val mediaSource = streamChoiceService.chooseSource(item.data, it) + mediaSource = streamChoiceService.chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() newCount = subtitleStreams.size - - if (subtitleCount != newCount) { - newStreams = - subtitleStreams.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } - updateCurrentMedia { - it.copy(subtitleStreams = newStreams) - } - } } if (maxAttempts == 0) { showToast( @@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } else { // Find the new subtitle stream + val subtitlesStreams = + mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE } val newStream = - newStreams - .toMutableList() - .apply { - removeAll(currentSubtitleStreams) - }.firstOrNull { it.external } + subtitlesStreams?.firstOrNull { stream -> + stream.isExternal && stream.path !in externalPaths + } if (newStream != null) { var audioIndex = currentItemPlayback.value?.audioIndex if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { @@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 } + updateCurrentMedia { + it.copy( + subtitleStreams = + subtitlesStreams.map { + SimpleMediaStream.from(context, it, true) + }, + ) + } this@downloadAndSwitchSubtitles.changeStreams( item, currentItemPlayback.value!!, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 8d3735e9..9cae3698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawerScope @@ -41,7 +42,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2000dp", + device = "spec:width=1200dp,height=2500dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + ListItem( + selected = false, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + ListItem( + selected = true, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt index 813aaf79..68583c4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt @@ -41,7 +41,7 @@ val PurpleThemeColors = val secondaryDark = Color(0xFFD2BCFF) val onSecondaryDark = Color(0xFF3B167D) val secondaryContainerDark = Color(0xFF48415B) - val onSecondaryContainerDark = Color(0xFFC2A6FF) + val onSecondaryContainerDark = Color(0xFFDFD3F8) val tertiaryDark = Color(0xFFA071F8) val onTertiaryDark = Color(0xFF5E0052) val tertiaryContainerDark = Color(0xFFB800A3) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt new file mode 100644 index 00000000..ff4e62da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.util + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.util.languageName +import com.github.damontecres.wholphin.util.profile.Codec +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 + +/** + * Collection of utility functions for formatting the display of media streams + */ +object StreamFormatting { + fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" + + // Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 + fun resolutionString( + width: Int, + height: Int, + interlaced: Boolean, + ): String = + if (height > width) { + // Vertical video + resolutionString(height, width, interlaced) + } else { + when { + width <= 256 && height <= 144 -> "144" + interlaced(interlaced) + width <= 426 && height <= 240 -> "240" + interlaced(interlaced) + width <= 640 && height <= 360 -> "360" + interlaced(interlaced) + width <= 682 && height <= 384 -> "384" + interlaced(interlaced) + width <= 720 && height <= 404 -> "404" + interlaced(interlaced) + width <= 854 && height <= 480 -> "480" + interlaced(interlaced) + width <= 960 && height <= 544 -> "540" + interlaced(interlaced) + width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) + width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) + width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) + width <= 4096 && height <= 3072 -> "4K" + width <= 8192 && height <= 6144 -> "8K" + else -> height.toString() + interlaced(interlaced) + } + } + + fun formatVideoRange( + context: Context, + videoRange: VideoRange?, + type: VideoRangeType?, + doviTitle: String?, + ): String? = + when (videoRange) { + VideoRange.UNKNOWN, + VideoRange.SDR, null, + -> { + null + } + + VideoRange.HDR -> { + if (doviTitle.isNotNullOrBlank()) { + context.getString(R.string.dolby_vision) + } else { + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null + + VideoRangeType.HDR10 -> "HDR10" + + VideoRangeType.HDR10_PLUS -> "HDR10+" + + VideoRangeType.HLG -> "HLG" + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } + } + } + } + + fun formatAudioCodec( + context: Context, + codec: String?, + profile: String?, + ): String? = + when { + profile?.contains("Dolby Atmos", true) == true -> { + context.getString(R.string.dolby_atmos) + } + + profile?.contains("DTS:X", true) == true -> { + context.getString(R.string.dts_x) + } + + profile?.contains("DTS:HD", true) == true -> { + context.getString(R.string.dts_hd) + } + + else -> { + when (codec?.lowercase()) { + Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + + Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + + Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + + Codec.Audio.DCA -> context.getString(R.string.dts) + + Codec.Audio.OGG, + Codec.Audio.OPUS, + Codec.Audio.VORBIS, + -> codec.replaceFirstChar { it.uppercase() } + + null -> null + + else -> codec.uppercase() + } + } + } + + fun formatSubtitleCodec(codec: String?): String? = + when (codec?.lowercase()) { + Codec.Subtitle.DVBSUB -> "DVB" + Codec.Subtitle.DVDSUB -> "DVD" + Codec.Subtitle.PGSSUB -> "PGS" + Codec.Subtitle.SUBRIP -> "SRT" + null -> null + else -> codec.uppercase() + } + + fun String?.concatWithSpace(str: String?): String? = + when { + this != null && str != null -> "$this $str" + this == null -> str + else -> this + } + + fun mediaStreamDisplayTitle( + context: Context, + stream: MediaStream, + includeFlags: Boolean, + ): String { + val name = + buildList { + add(languageName(stream.language)) + if (stream.type == MediaStreamType.AUDIO) { + add(formatAudioCodec(context, stream.codec, stream.profile)) + add(stream.channelLayout) + } else if (stream.type == MediaStreamType.SUBTITLE) { + "SDH".takeIf { stream.isHearingImpaired }?.let(::add) + add(formatSubtitleCodec(stream.codec)) + } + }.joinToString(" ") + if (includeFlags) { + val flags = + buildList { + if (stream.isDefault) add(stream.localizedDefault) + if (stream.isForced) add(stream.localizedForced) + if (stream.isExternal) add(stream.localizedExternal) + }.joinToString(", ") + if (flags.isNotEmpty()) { + return "$name ($flags)" + } + } + return name + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d589ee14..6b6f8c2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,12 @@ Play trailer No trailers Clear track choices + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS Disabled