mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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.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) },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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.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() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 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!!,
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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="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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue