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)
This commit is contained in:
Ray 2026-01-11 12:46:38 -05:00 committed by GitHub
parent ee73e7d723
commit 8bdc8a6f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 620 additions and 328 deletions

View file

@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -84,6 +85,7 @@ data class DialogItem(
val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
val trailingContent: @Composable (() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
val selected: Boolean = false,
) : DialogItemEntry { ) : DialogItemEntry {
constructor( constructor(
@StringRes text: Int, @StringRes text: Int,
@ -266,7 +268,7 @@ fun DialogPopupContent(
is DialogItem -> { is DialogItem -> {
ListItem( ListItem(
selected = false, selected = it.selected,
enabled = !waiting && it.enabled, enabled = !waiting && it.enabled,
onClick = { onClick = {
if (dismissOnClick) { if (dismissOnClick) {
@ -502,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int =
fun chooseStream( fun chooseStream(
context: Context, context: Context,
streams: List<MediaStream>, streams: List<MediaStream>,
currentIndex: Int?,
type: MediaStreamType, type: MediaStreamType,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
@ -513,6 +516,10 @@ fun chooseStream(
if (type == MediaStreamType.SUBTITLE) { if (type == MediaStreamType.SUBTITLE) {
add( add(
DialogItem( DialogItem(
selected = currentIndex == null,
leadingContent = {
SelectedLeadingContent(currentIndex == null)
},
headlineContent = { headlineContent = {
Text(text = stringResource(R.string.none)) Text(text = stringResource(R.string.none))
}, },
@ -524,12 +531,17 @@ fun chooseStream(
} }
addAll( addAll(
streams.filter { it.type == type }.mapIndexed { index, stream -> streams.filter { it.type == type }.mapIndexed { index, stream ->
val title = stream.displayTitle ?: stream.title ?: "" val simpleStream = SimpleMediaStream.from(context, stream, true)
DialogItem( DialogItem(
selected = currentIndex == stream.index,
leadingContent = {
SelectedLeadingContent(currentIndex == stream.index)
},
headlineContent = { headlineContent = {
Text(text = title) Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
}, },
supportingContent = { supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
}, },
onClick = { onClick.invoke(stream.index) }, onClick = { onClick.invoke(stream.index) },
) )

View file

@ -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),
)
}
}

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement 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.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.audioStreamCount
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
import com.github.damontecres.wholphin.ui.theme.WholphinTheme 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.languageName
import com.github.damontecres.wholphin.util.profile.Codec
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.MediaStream
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
@Composable @Composable
@NonRestartableComposable @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 @Composable
fun StreamLabel( fun StreamLabel(
text: String, 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
}

View file

@ -21,11 +21,11 @@ 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.ui.util.StreamFormatting.formatAudioCodec
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.languageName
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.MediaStream
@ -391,7 +391,7 @@ private fun buildAudioStreamInfo(
val bitrateLabel = context.getString(R.string.bitrate) val bitrateLabel = context.getString(R.string.bitrate)
val sampleRateLabel = context.getString(R.string.sample_rate) val sampleRateLabel = context.getString(R.string.sample_rate)
val defaultLabel = context.getString(R.string.default_track) 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 yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = context.getString(R.string.no)
val sampleRateUnit = context.getString(R.string.sample_rate_unit) val sampleRateUnit = context.getString(R.string.sample_rate_unit)
@ -399,11 +399,12 @@ private fun buildAudioStreamInfo(
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let { stream.codec?.let {
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)"
add(codecLabel to formattedCodec) add(codecLabel to formattedCodec)
} }
stream.channelLayout?.let { add(layoutLabel to it) } stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) } 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.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } 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.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let { stream.codec?.let {
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() val formattedCodec = formatSubtitleCodec(it) + " ($it)"
add(codecLabel to formattedCodec) add(codecLabel to formattedCodec)
} }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } 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) add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
} }
if (showPath) { if (showPath) {
stream.path?.let { pathLabel to it } stream.path?.let { add(pathLabel to it) }
} }
} }

View file

@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
@ -181,6 +182,12 @@ fun EpisodeDetails(
chooseStream( chooseStream(
context = context, context = context,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
type = type, type = type,
onClick = { trackIndex -> onClick = { trackIndex ->
viewModel.saveTrackSelection( viewModel.saveTrackSelection(

View file

@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind 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.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
@ -210,6 +211,7 @@ fun MovieDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
viewModel.streamChoiceService viewModel.streamChoiceService
.chooseSource( .chooseSource(
movie.data, movie.data,
@ -220,6 +222,12 @@ fun MovieDetails(
context = context, context = context,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex -> onClick = { trackIndex ->
viewModel.saveTrackSelection( viewModel.saveTrackSelection(
movie, movie,

View file

@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind 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.MediaType
import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -242,6 +243,12 @@ fun SeriesOverview(
context = context, context = context,
streams = source.mediaStreams.orEmpty(), streams = source.mediaStreams.orEmpty(),
type = type, type = type,
currentIndex =
if (type == MediaStreamType.AUDIO) {
chosenStreams?.audioStream?.index
} else {
chosenStreams?.subtitleStream?.index
},
onClick = { trackIndex -> onClick = { trackIndex ->
viewModel.saveTrackSelection( viewModel.saveTrackSelection(
ep, ep,

View file

@ -4,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
data class CurrentMediaInfo( data class CurrentMediaInfo(
val audioStreams: List<AudioStream>, val audioStreams: List<SimpleMediaStream>,
val subtitleStreams: List<SubtitleStream>, val subtitleStreams: List<SimpleMediaStream>,
val chapters: List<Chapter>, val chapters: List<Chapter>,
val trickPlayInfo: TrickplayInfo?, val trickPlayInfo: TrickplayInfo?,
) { ) {

View file

@ -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,
)
}
}

View file

@ -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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button 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.components.TextButton
import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
@ -462,12 +463,12 @@ fun PlaybackButton(
} }
@Composable @Composable
fun BottomDialog( fun <T> BottomDialog(
choices: List<String>, choices: List<BottomDialogItem<T>>,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onSelectChoice: (Int, String) -> Unit, onSelectChoice: (Int, BottomDialogItem<T>) -> Unit,
gravity: Int, gravity: Int,
currentChoice: Int? = null, currentChoice: BottomDialogItem<T>? = null,
) { ) {
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
Dialog( Dialog(
@ -485,7 +486,10 @@ fun BottomDialog(
Modifier Modifier
.wrapContentSize() .wrapContentSize()
.padding(8.dp) .padding(8.dp)
.background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), .background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(8.dp),
),
) { ) {
LazyColumn( LazyColumn(
modifier = modifier =
@ -498,38 +502,27 @@ fun BottomDialog(
) { ) {
itemsIndexed(choices) { index, choice -> itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
val color =
if (focused) {
MaterialTheme.colorScheme.inverseOnSurface
} else {
MaterialTheme.colorScheme.onSurface
}
ListItem( ListItem(
selected = index == currentChoice, selected = choice == currentChoice,
onClick = { onClick = {
onDismissRequest() onDismissRequest()
onSelectChoice(index, choice) onSelectChoice(index, choice)
}, },
leadingContent = { leadingContent = {
if (index == currentChoice) { SelectedLeadingContent(choice == currentChoice)
Box(
modifier =
Modifier
.padding(horizontal = 4.dp)
.clip(CircleShape)
.align(Alignment.Center)
.background(color)
.size(8.dp),
)
}
}, },
headlineContent = { headlineContent = {
Text( Text(
text = choice, text = choice.headline,
color = color,
) )
}, },
supportingContent = {
choice.supporting?.let {
Text(
text = it,
)
}
},
interactionSource = interactionSource, interactionSource = interactionSource,
) )
} }
@ -542,6 +535,12 @@ data class MoreButtonOptions(
val options: Map<String, PlaybackAction>, val options: Map<String, PlaybackAction>,
) )
data class BottomDialogItem<T>(
val data: T,
val headline: String,
val supporting: String?,
)
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
private fun ButtonPreview() { private fun ButtonPreview() {

View file

@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView 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.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider 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.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
@ -35,9 +47,9 @@ enum class PlaybackDialogType {
data class PlaybackSettings( data class PlaybackSettings(
val showDebugInfo: Boolean, val showDebugInfo: Boolean,
val audioIndex: Int?, val audioIndex: Int?,
val audioStreams: List<AudioStream>, val audioStreams: List<SimpleMediaStream>,
val subtitleIndex: Int?, val subtitleIndex: Int?,
val subtitleStreams: List<SubtitleStream>, val subtitleStreams: List<SimpleMediaStream>,
val playbackSpeed: Float, val playbackSpeed: Float,
val contentScale: ContentScale, val contentScale: ContentScale,
val subtitleDelay: Duration, val subtitleDelay: Duration,
@ -59,7 +71,13 @@ fun PlaybackDialog(
PlaybackDialogType.MORE -> { PlaybackDialogType.MORE -> {
val options = val options =
buildList { 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( BottomDialog(
choices = options, choices = options,
@ -75,76 +93,82 @@ fun PlaybackDialog(
} }
PlaybackDialogType.CAPTIONS -> { PlaybackDialogType.CAPTIONS -> {
val subtitleStreams = settings.subtitleStreams SubtitleChoiceBottomDialog(
val options = subtitleStreams.map { it.displayName } choices = settings.subtitleStreams,
Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") currentChoice = settings.subtitleIndex,
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,
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.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, _ -> onSelectChoice = { subtitleIndex ->
if (index in subtitleStreams.indices) { onDismissRequest.invoke()
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) if (subtitleIndex >= 0) {
} else { onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex))
val idx = index - subtitleStreams.size } else if (subtitleIndex == TrackIndex.DISABLED) {
if (idx == 0) {
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
} else { }
},
onSelectSearch = {
onDismissRequest.invoke()
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
}
}
}, },
gravity = Gravity.END, gravity = Gravity.END,
) )
} }
PlaybackDialogType.SETTINGS -> { PlaybackDialogType.SETTINGS -> {
val currentAudio =
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
val options = val options =
buildList { buildList {
add(stringResource(R.string.audio)) add(
add(stringResource(R.string.playback_speed)) 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) { 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) { 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( BottomDialog(
choices = options, choices = options,
currentChoice = null, currentChoice = null,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onSelectChoice = { index, _ -> onSelectChoice = { _, choice ->
when (index) { onClickPlaybackDialogType(choice.data)
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
}
}, },
gravity = Gravity.END, gravity = Gravity.END,
) )
} }
PlaybackDialogType.AUDIO -> { PlaybackDialogType.AUDIO -> {
BottomDialog( StreamChoiceBottomDialog(
choices = settings.audioStreams.map { it.displayName }, choices = settings.audioStreams,
currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, currentChoice = settings.audioIndex,
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
@ -153,17 +177,25 @@ fun PlaybackDialog(
// settingsFocusRequester.tryRequestFocus() // settingsFocusRequester.tryRequestFocus()
// } // }
}, },
onSelectChoice = { index, _ -> onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index))
}, },
gravity = Gravity.END, gravity = Gravity.END,
) )
} }
PlaybackDialogType.PLAYBACK_SPEED -> { PlaybackDialogType.PLAYBACK_SPEED -> {
val choices =
playbackSpeedOptions.map {
BottomDialogItem(
data = it.toFloat(),
headline = it,
supporting = null,
)
}
BottomDialog( BottomDialog(
choices = playbackSpeedOptions, choices = choices,
currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed },
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
@ -173,16 +205,24 @@ fun PlaybackDialog(
// } // }
}, },
onSelectChoice = { _, value -> onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
}, },
gravity = Gravity.END, gravity = Gravity.END,
) )
} }
PlaybackDialogType.VIDEO_SCALE -> { PlaybackDialogType.VIDEO_SCALE -> {
val choices =
playbackScaleOptions.map { (scale, name) ->
BottomDialogItem(
data = scale,
headline = name,
supporting = null,
)
}
BottomDialog( BottomDialog(
choices = playbackScaleOptions.values.toList(), choices = choices,
currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), currentChoice = choices.firstOrNull { it.data == settings.contentScale },
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
onDismissRequest.invoke() onDismissRequest.invoke()
@ -191,8 +231,8 @@ fun PlaybackDialog(
// settingsFocusRequester.tryRequestFocus() // settingsFocusRequester.tryRequestFocus()
// } // }
}, },
onSelectChoice = { index, _ -> onSelectChoice = { _, choice ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
}, },
gravity = Gravity.END, gravity = Gravity.END,
) )
@ -225,3 +265,164 @@ fun PlaybackDialog(
} }
} }
} }
@Composable
fun SubtitleChoiceBottomDialog(
choices: List<SimpleMediaStream>,
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<SimpleMediaStream>,
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,
)
}
}
}
}
}

View file

@ -384,15 +384,18 @@ class PlaybackViewModel
val subtitleStreams = val subtitleStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
?.map(SubtitleStream::from) ?.map {
.orEmpty() SimpleMediaStream.from(context, it, true)
}.orEmpty()
val audioStreams = val audioStreams =
mediaSource.mediaStreams mediaSource.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO } ?.filter { it.type == MediaStreamType.AUDIO }
?.map(AudioStream::from) ?.map {
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) SimpleMediaStream.from(context, it, true)
}
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty() .orEmpty()
val audioStream = val audioStream =
streamChoiceService streamChoiceService
.chooseAudioStream( .chooseAudioStream(

View file

@ -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),
)
}
}

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.MediaStreamType
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import timber.log.Timber import timber.log.Timber
@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
itemId = it.sourceId ?: it.itemId, itemId = it.sourceId ?: it.itemId,
subtitleId = subtitleId, subtitleId = subtitleId,
) )
val currentSource =
this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo
val currentSubtitleStreams = val currentSubtitleStreams =
this@downloadAndSwitchSubtitles currentSource
.currentMediaInfo.value ?.mediaStreams
?.subtitleStreams ?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty() .orEmpty()
val externalPaths = currentSubtitleStreams.map { it.path }
val subtitleCount = currentSubtitleStreams.size val subtitleCount = currentSubtitleStreams.size
var newCount = subtitleCount var newCount = subtitleCount
var maxAttempts = 4 var maxAttempts = 4
var newStreams: List<SubtitleStream> = listOf()
var mediaSource: MediaSourceInfo? = null
// The server triggers a refresh in the background, so query periodically for the item until its updated // The server triggers a refresh in the background, so query periodically for the item until its updated
while (maxAttempts > 0 && subtitleCount == newCount) { while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts-- maxAttempts--
@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
api.userLibraryApi.getItem(itemId = it.itemId).content, api.userLibraryApi.getItem(itemId = it.itemId).content,
api, api,
) )
val mediaSource = streamChoiceService.chooseSource(item.data, it) mediaSource = streamChoiceService.chooseSource(item.data, it)
if (mediaSource == null) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(
@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty() .orEmpty()
newCount = subtitleStreams.size 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) { if (maxAttempts == 0) {
showToast( showToast(
@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
) )
} else { } else {
// Find the new subtitle stream // Find the new subtitle stream
val subtitlesStreams =
mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE }
val newStream = val newStream =
newStreams subtitlesStreams?.firstOrNull { stream ->
.toMutableList() stream.isExternal && stream.path !in externalPaths
.apply { }
removeAll(currentSubtitleStreams)
}.firstOrNull { it.external }
if (newStream != null) { if (newStream != null) {
var audioIndex = currentItemPlayback.value?.audioIndex var audioIndex = currentItemPlayback.value?.audioIndex
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
audioIndex += 1 audioIndex += 1
} }
updateCurrentMedia {
it.copy(
subtitleStreams =
subtitlesStreams.map {
SimpleMediaStream.from(context, it, true)
},
)
}
this@downloadAndSwitchSubtitles.changeStreams( this@downloadAndSwitchSubtitles.changeStreams(
item, item,
currentItemPlayback.value!!, currentItemPlayback.value!!,

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.NavigationDrawerScope
@ -41,7 +42,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@Preview( @Preview(
device = "spec:width=1200dp,height=2000dp", device = "spec:width=1200dp,height=2500dp",
backgroundColor = 0xFF383535, backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION, uiMode = UI_MODE_TYPE_TELEVISION,
) )
@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) {
interactionSource = source, 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 = {},
)
}
} }
} }
} }

View file

@ -41,7 +41,7 @@ val PurpleThemeColors =
val secondaryDark = Color(0xFFD2BCFF) val secondaryDark = Color(0xFFD2BCFF)
val onSecondaryDark = Color(0xFF3B167D) val onSecondaryDark = Color(0xFF3B167D)
val secondaryContainerDark = Color(0xFF48415B) val secondaryContainerDark = Color(0xFF48415B)
val onSecondaryContainerDark = Color(0xFFC2A6FF) val onSecondaryContainerDark = Color(0xFFDFD3F8)
val tertiaryDark = Color(0xFFA071F8) val tertiaryDark = Color(0xFFA071F8)
val onTertiaryDark = Color(0xFF5E0052) val onTertiaryDark = Color(0xFF5E0052)
val tertiaryContainerDark = Color(0xFFB800A3) val tertiaryContainerDark = Color(0xFFB800A3)

View file

@ -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
}
}

View file

@ -403,6 +403,12 @@
<string name="play_trailer">Play trailer</string> <string name="play_trailer">Play trailer</string>
<string name="no_trailers">No trailers</string> <string name="no_trailers">No trailers</string>
<string name="clear_track_choices">Clear track choices</string> <string name="clear_track_choices">Clear track choices</string>
<string name="dts_x">DTS:X</string>
<string name="dts_hd">DTS:HD</string>
<string name="truehd">TrueHD</string>
<string name="dolby_digital">DD</string>
<string name="dolby_digital_plus">DD+</string>
<string name="dts">DTS</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>