mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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  #### Choose subtitles during playback  #### Choose audio 
This commit is contained in:
parent
ee73e7d723
commit
8bdc8a6f8f
18 changed files with 620 additions and 328 deletions
|
|
@ -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<MediaStream>,
|
||||
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) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AudioStream>,
|
||||
val subtitleStreams: List<SubtitleStream>,
|
||||
val audioStreams: List<SimpleMediaStream>,
|
||||
val subtitleStreams: List<SimpleMediaStream>,
|
||||
val chapters: List<Chapter>,
|
||||
val trickPlayInfo: TrickplayInfo?,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
fun <T> BottomDialog(
|
||||
choices: List<BottomDialogItem<T>>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelectChoice: (Int, String) -> Unit,
|
||||
onSelectChoice: (Int, BottomDialogItem<T>) -> Unit,
|
||||
gravity: Int,
|
||||
currentChoice: Int? = null,
|
||||
currentChoice: BottomDialogItem<T>? = 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<String, PlaybackAction>,
|
||||
)
|
||||
|
||||
data class BottomDialogItem<T>(
|
||||
val data: T,
|
||||
val headline: String,
|
||||
val supporting: String?,
|
||||
)
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun ButtonPreview() {
|
||||
|
|
|
|||
|
|
@ -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<AudioStream>,
|
||||
val audioStreams: List<SimpleMediaStream>,
|
||||
val subtitleIndex: Int?,
|
||||
val subtitleStreams: List<SubtitleStream>,
|
||||
val subtitleStreams: List<SimpleMediaStream>,
|
||||
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<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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||
?.map {
|
||||
SimpleMediaStream.from(context, it, true)
|
||||
}
|
||||
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||
.orEmpty()
|
||||
|
||||
val audioStream =
|
||||
streamChoiceService
|
||||
.chooseAudioStream(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SubtitleStream> = 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!!,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -403,6 +403,12 @@
|
|||
<string name="play_trailer">Play trailer</string>
|
||||
<string name="no_trailers">No trailers</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">
|
||||
<item>Disabled</item>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue