Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Damontecres 2026-01-11 17:18:51 -05:00
commit d563ebabe0
No known key found for this signature in database
51 changed files with 2486 additions and 438 deletions

View file

@ -230,6 +230,7 @@ dependencies {
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)

View file

@ -133,7 +133,7 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave) Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave) toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId val seriesId = item.data.seriesId
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
if (type == MediaStreamType.AUDIO) { if (type == MediaStreamType.AUDIO) {
val stream = source.mediaStreams?.first { it.index == trackIndex } val stream = source.mediaStreams?.first { it.index == trackIndex }
if (stream?.language != null) { if (stream?.language != null) {
@ -147,7 +147,7 @@ class ItemPlaybackRepository
subtitlesDisabled = true, subtitlesDisabled = true,
) )
} else { } else {
val stream = source.mediaStreams?.first { it.index == trackIndex } val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
if (stream?.language != null) { if (stream?.language != null) {
streamChoiceService.updateSubtitles( streamChoiceService.updateSubtitles(
item.data, item.data,

View file

@ -54,4 +54,9 @@ object TrackIndex {
* The user has explicitly disabled the tracks (eg turned off subtitles) * The user has explicitly disabled the tracks (eg turned off subtitles)
*/ */
const val DISABLED = -2 const val DISABLED = -2
/**
* The user wants only forced subtitles (for foreign language dialogue, signs, etc.)
*/
const val ONLY_FORCED = -3
} }

View file

@ -162,6 +162,40 @@ class StreamChoiceService
} }
} }
/**
* Resolves ONLY_FORCED to an actual subtitle track index.
* Returns the original index if not ONLY_FORCED or DISABLED.
*/
suspend fun resolveSubtitleIndex(
source: MediaSourceInfo,
audioStreamIndex: Int?,
seriesId: UUID?,
subtitleIndex: Int,
prefs: UserPreferences,
): Int? =
if (subtitleIndex != TrackIndex.ONLY_FORCED) {
subtitleIndex
} else {
val audioStream =
source.mediaStreams?.firstOrNull {
it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex
}
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser.value!!.rowId,
itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
subtitleIndex = TrackIndex.ONLY_FORCED,
)
chooseSubtitleStream(
source = source,
audioStream = audioStream,
seriesId = seriesId,
itemPlayback = itemPlayback,
plc = null,
prefs = prefs,
)?.index
}
fun chooseSubtitleStream( fun chooseSubtitleStream(
audioStreamLang: String?, audioStreamLang: String?,
candidates: List<MediaStream>, candidates: List<MediaStream>,
@ -171,6 +205,14 @@ class StreamChoiceService
): MediaStream? { ): MediaStream? {
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
return null return null
} else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
// Client-side manual override: User selected "Only Forced" in player menu
val seriesLang =
playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
val subtitleLanguage =
(seriesLang ?: userConfig?.subtitleLanguagePreference)
?.takeIf { it.isNotNullOrBlank() }
return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
} else if (itemPlayback?.subtitleIndexEnabled == true) { } else if (itemPlayback?.subtitleIndexEnabled == true) {
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
} else { } else {
@ -261,6 +303,37 @@ class StreamChoiceService
} }
} }
} }
/** Returns true if the track is forced (via metadata flag or title patterns). */
private fun isForcedOrSigns(track: MediaStream): Boolean {
if (track.isForced) return true
val title = track.title ?: track.displayTitle ?: return false
return title.contains("forced", ignoreCase = true) ||
title.contains("signs", ignoreCase = true) ||
title.contains("songs", ignoreCase = true)
}
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
private fun findForcedTrack(
candidates: List<MediaStream>,
subtitleLanguage: String?,
audioLanguage: String?,
): MediaStream? {
// 1. User's preferred subtitle language
if (subtitleLanguage != null) {
candidates
.firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) }
?.let { return it }
}
// 2. Audio language (for sign-subtitles if no preference match)
if (audioLanguage != null) {
candidates
.firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) }
?.let { return it }
}
// 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
}
} }
private val String?.isUnknown: Boolean private val String?.isUnknown: Boolean

View file

@ -56,6 +56,7 @@ val DefaultItemFields =
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.CHAPTERS, ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
) )
/** /**
@ -68,6 +69,7 @@ val SlimItemFields =
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
) )
object Cards { object Cards {

View file

@ -108,6 +108,7 @@ fun DiscoverItemCard(
watched = false, watched = false,
unwatchedCount = 0, unwatchedCount = 0,
watchedPercent = null, watchedPercent = null,
numberOfVersions = -1,
useFallbackText = false, useFallbackText = false,
contentScale = ContentScale.FillBounds, contentScale = ContentScale.FillBounds,
modifier = modifier =

View file

@ -97,6 +97,7 @@ fun EpisodeCard(
watched = dto?.userData?.played ?: false, watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage, watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false, useFallbackText = false,
modifier = modifier =
Modifier Modifier

View file

@ -68,6 +68,7 @@ fun ExtrasRow(
isPlayed = false, isPlayed = false,
unplayedItemCount = -1, unplayedItemCount = -1,
playedPercentage = -1.0, playedPercentage = -1.0,
numberOfVersions = -1,
aspectRatio = AspectRatios.FOUR_THREE, // TODO aspectRatio = AspectRatios.FOUR_THREE, // TODO
) )
}, },

View file

@ -91,6 +91,7 @@ fun GridCard(
watched = dto?.userData?.played ?: false, watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage, watchedPercent = dto?.userData?.playedPercentage,
numberOfVersions = dto?.mediaSourceCount ?: 0,
useFallbackText = false, useFallbackText = false,
contentScale = imageContentScale, contentScale = imageContentScale,
modifier = modifier =

View file

@ -58,6 +58,7 @@ fun ItemCardImage(
watched: Boolean, watched: Boolean,
unwatchedCount: Int, unwatchedCount: Int,
watchedPercent: Double?, watchedPercent: Double?,
numberOfVersions: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageType: ImageType = ImageType.PRIMARY, imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
@ -86,6 +87,7 @@ fun ItemCardImage(
watched = watched, watched = watched,
unwatchedCount = unwatchedCount, unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent, watchedPercent = watchedPercent,
numberOfVersions = numberOfVersions,
modifier = modifier =
modifier.onLayoutRectChanged( modifier.onLayoutRectChanged(
throttleMillis = 100, throttleMillis = 100,
@ -107,6 +109,7 @@ fun ItemCardImage(
watched: Boolean, watched: Boolean,
unwatchedCount: Int, unwatchedCount: Int,
watchedPercent: Double?, watchedPercent: Double?,
numberOfVersions: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
@ -143,6 +146,7 @@ fun ItemCardImage(
watched = watched, watched = watched,
unwatchedCount = unwatchedCount, unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent, watchedPercent = watchedPercent,
numberOfVersions = numberOfVersions,
modifier = Modifier, modifier = Modifier,
) )
} }
@ -198,20 +202,45 @@ fun ItemCardImageOverlay(
watched: Boolean, watched: Boolean,
unwatchedCount: Int, unwatchedCount: Int,
watchedPercent: Double?, watchedPercent: Double?,
numberOfVersions: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
if (favorite) { Row(
Text( horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = verticalAlignment = Alignment.CenterVertically,
Modifier modifier =
.align(Alignment.TopStart) Modifier
.padding(8.dp), .align(Alignment.TopStart)
color = colorResource(android.R.color.holo_red_light), .padding(4.dp),
text = stringResource(R.string.fa_heart), ) {
fontSize = 20.sp, if (numberOfVersions > 1) {
fontFamily = FontAwesome, Box(
) modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = numberOfVersions.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
if (favorite) {
Text(
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
modifier = Modifier,
)
}
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),

View file

@ -77,6 +77,7 @@ fun PersonCard(
favorite = item.favorite, favorite = item.favorite,
watched = false, watched = false,
unwatchedCount = -1, unwatchedCount = -1,
numberOfVersions = -1,
watchedPercent = null, watchedPercent = null,
useFallbackText = false, useFallbackText = false,
modifier = modifier =

View file

@ -88,6 +88,7 @@ fun SeasonCard(
isPlayed = item?.data?.userData?.played ?: false, isPlayed = item?.data?.userData?.played ?: false,
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier, modifier = modifier,
@ -112,6 +113,7 @@ fun SeasonCard(
isPlayed: Boolean, isPlayed: Boolean,
unplayedItemCount: Int, unplayedItemCount: Int,
playedPercentage: Double, playedPercentage: Double,
numberOfVersions: Int,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -172,6 +174,7 @@ fun SeasonCard(
watched = isPlayed, watched = isPlayed,
unwatchedCount = unplayedItemCount, unwatchedCount = unplayedItemCount,
watchedPercent = playedPercentage, watchedPercent = playedPercentage,
numberOfVersions = numberOfVersions,
useFallbackText = false, useFallbackText = false,
modifier = modifier =
Modifier Modifier

View file

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

View file

@ -0,0 +1,32 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.tv.material3.LocalContentColor
@Composable
fun BoxScope.SelectedLeadingContent(
selected: Boolean,
modifier: Modifier = Modifier,
) {
if (selected) {
Box(
modifier =
modifier
.padding(horizontal = 4.dp)
.clip(CircleShape)
.align(Alignment.Center)
.background(LocalContentColor.current)
.size(8.dp),
)
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
@ -42,7 +43,8 @@ fun EpisodeName(
text = episodeName ?: "", text = episodeName ?: "",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 2, fontSize = 20.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier, modifier = modifier,
) )

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -31,28 +30,31 @@ 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
fun VideoStreamDetails( fun VideoStreamDetails(
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
numberOfVersions: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = VideoStreamDetails( ) = VideoStreamDetails(
chosenStreams?.source, chosenStreams?.source,
chosenStreams?.videoStream, chosenStreams?.videoStream,
chosenStreams?.audioStream, chosenStreams?.audioStream,
chosenStreams?.subtitleStream, chosenStreams?.subtitleStream,
numberOfVersions,
modifier, modifier,
) )
@ -62,6 +64,7 @@ fun VideoStreamDetails(
videoStream: MediaStream?, videoStream: MediaStream?,
audioStream: MediaStream?, audioStream: MediaStream?,
subtitleStream: MediaStream?, subtitleStream: MediaStream?,
numberOfVersions: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -86,13 +89,16 @@ fun VideoStreamDetails(
null null
} }
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
listOfNotNull( resName.concatWithSpace(range)
resName.concatWithSpace(range), }
it.codec?.uppercase(),
)
}.orEmpty()
} }
video.forEach { video?.let {
StreamLabel(
text = it,
count = numberOfVersions,
)
}
videoStream?.codec?.uppercase()?.let {
StreamLabel(it) StreamLabel(it)
} }
@ -149,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,
@ -251,93 +228,3 @@ private fun StreamLabelPreview() {
} }
} }
} }
fun formatVideoRange(
context: Context,
videoRange: VideoRange?,
type: VideoRangeType?,
doviTitle: String?,
): String? =
when (videoRange) {
VideoRange.UNKNOWN,
VideoRange.SDR, null,
-> {
null
}
VideoRange.HDR -> {
if (doviTitle.isNotNullOrBlank()) {
context.getString(R.string.dolby_vision)
} else {
when (type) {
VideoRangeType.UNKNOWN,
VideoRangeType.SDR,
null,
-> null
VideoRangeType.HDR10 -> "HDR10"
VideoRangeType.HDR10_PLUS -> "HDR10+"
VideoRangeType.HLG -> "HLG"
VideoRangeType.DOVI,
VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision)
}
}
}
}
fun formatAudioCodec(
context: Context,
codec: String?,
profile: String?,
): String? =
when {
profile?.contains("Dolby Atmos", true) == true -> {
context.getString(R.string.dolby_atmos)
}
profile?.contains("DTS:X", true) == true -> {
"DTS:X"
}
profile?.contains("DTS:HD", true) == true -> {
"DTS:HD"
}
else -> {
when (codec?.lowercase()) {
Codec.Audio.TRUEHD -> "TrueHD"
Codec.Audio.OGG,
Codec.Audio.OPUS,
Codec.Audio.VORBIS,
-> codec.replaceFirstChar { it.uppercase() }
null -> null
else -> codec.uppercase()
}
}
}
fun formatSubtitleCodec(codec: String?): String? =
when (codec?.lowercase()) {
Codec.Subtitle.DVBSUB -> "DVB"
Codec.Subtitle.DVDSUB -> "DVD"
Codec.Subtitle.PGSSUB -> "PGS"
Codec.Subtitle.SUBRIP -> "SRT"
null -> null
else -> codec.uppercase()
}
fun String?.concatWithSpace(str: String?): String? =
when {
this != null && str != null -> "$this $str"
this == null -> str
else -> this
}

View file

@ -21,11 +21,11 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
import com.github.damontecres.wholphin.util.languageName import com.github.damontecres.wholphin.util.languageName
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
@ -391,7 +391,7 @@ private fun buildAudioStreamInfo(
val bitrateLabel = context.getString(R.string.bitrate) val bitrateLabel = context.getString(R.string.bitrate)
val sampleRateLabel = context.getString(R.string.sample_rate) val sampleRateLabel = context.getString(R.string.sample_rate)
val defaultLabel = context.getString(R.string.default_track) val defaultLabel = context.getString(R.string.default_track)
val externalLabel = context.getString(R.string.external_track) val profileLabel = context.getString(R.string.profile)
val yesStr = context.getString(R.string.yes) val yesStr = context.getString(R.string.yes)
val noStr = context.getString(R.string.no) val noStr = context.getString(R.string.no)
val sampleRateUnit = context.getString(R.string.sample_rate_unit) val sampleRateUnit = context.getString(R.string.sample_rate_unit)
@ -399,11 +399,12 @@ private fun buildAudioStreamInfo(
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let { stream.codec?.let {
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)"
add(codecLabel to formattedCodec) add(codecLabel to formattedCodec)
} }
stream.channelLayout?.let { add(layoutLabel to it) } stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) } stream.channels?.let { add(channelsLabel to it.toString()) }
stream.profile?.let { add(profileLabel to it) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
@ -428,7 +429,7 @@ private fun buildSubtitleStreamInfo(
stream.title?.let { add(titleLabel to it) } stream.title?.let { add(titleLabel to it) }
stream.language?.let { add(languageLabel to languageName(it)) } stream.language?.let { add(languageLabel to languageName(it)) }
stream.codec?.let { stream.codec?.let {
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() val formattedCodec = formatSubtitleCodec(it) + " ($it)"
add(codecLabel to formattedCodec) add(codecLabel to formattedCodec)
} }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
@ -438,7 +439,7 @@ private fun buildSubtitleStreamInfo(
add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
} }
if (showPath) { if (showPath) {
stream.path?.let { pathLabel to it } stream.path?.let { add(pathLabel to it) }
} }
} }

View file

@ -360,7 +360,7 @@ fun PersonPageContent(
onClickItem = onClickItem, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
showIfEmpty = false, showIfEmpty = false,
horizontalPadding = 32.dp, horizontalPadding = 24.dp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, mod, onClick, onLongClick -> cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard( SeasonCard(

View file

@ -444,6 +444,7 @@ fun PlaylistItem(
watched = item?.data?.userData?.played ?: false, watched = item?.data?.userData?.played ?: false,
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
watchedPercent = 0.0, watchedPercent = 0.0,
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
modifier = Modifier.width(160.dp), modifier = Modifier.width(160.dp),
useFallbackText = false, useFallbackText = false,
) )

View file

@ -442,6 +442,7 @@ fun TrailerRow(
isPlayed = false, isPlayed = false,
unplayedItemCount = 0, unplayedItemCount = 0,
playedPercentage = 0.0, playedPercentage = 0.0,
numberOfVersions = -1,
onClick = { onClickTrailer.invoke(item) }, onClick = { onClickTrailer.invoke(item) },
onLongClick = {}, onLongClick = {},
modifier = cardModifier, modifier = cardModifier,

View file

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

View file

@ -59,6 +59,7 @@ fun EpisodeDetailsHeader(
VideoStreamDetails( VideoStreamDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -72,6 +72,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
@ -215,6 +216,7 @@ fun MovieDetails(
moreDialog = null moreDialog = null
}, },
onChooseTracks = { type -> onChooseTracks = { type ->
viewModel.streamChoiceService viewModel.streamChoiceService
.chooseSource( .chooseSource(
movie.data, movie.data,
@ -225,6 +227,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,
@ -510,6 +518,14 @@ fun MovieDetailsContent(
} }
if (similar.isNotEmpty()) { if (similar.isNotEmpty()) {
item { item {
val imageHeight =
remember(movie.type) {
if (movie.type == BaseItemKind.MOVIE) {
Cards.height2x3
} else {
Cards.heightEpisode
}
}
ItemRow( ItemRow(
title = stringResource(R.string.more_like_this), title = stringResource(R.string.more_like_this),
items = similar, items = similar,
@ -528,7 +544,7 @@ fun MovieDetailsContent(
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, modifier = mod,
showImageOverlay = true, showImageOverlay = true,
imageHeight = Cards.height2x3, imageHeight = imageHeight,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
) )
}, },

View file

@ -73,6 +73,7 @@ fun MovieDetailsHeader(
VideoStreamDetails( VideoStreamDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
numberOfVersions = movie.data.mediaSourceCount ?: 0,
modifier = Modifier.padding(bottom = padding), modifier = Modifier.padding(bottom = padding),
) )
dto.taglines?.firstOrNull()?.let { tagline -> dto.taglines?.firstOrNull()?.let { tagline ->

View file

@ -38,6 +38,7 @@ fun FocusedEpisodeHeader(
if (dto != null) { if (dto != null) {
VideoStreamDetails( VideoStreamDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
modifier = Modifier, modifier = Modifier,
) )
} }

View file

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

View file

@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -259,6 +260,9 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) }
}
Box(modifier = modifier) { Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
@ -379,7 +383,7 @@ fun HomePageContent(
.onFocusChanged { .onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
position = RowColumn(rowIndex, index) position = RowColumn(rowIndex, index)
item?.let(onUpdateBackdrop) // item?.let(onUpdateBackdrop)
} }
if (it.isFocused && onFocusPosition != null) { if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore = val nonEmptyRowBefore =
@ -442,7 +446,6 @@ fun HomePageHeader(
item?.let { item?.let {
val isEpisode = item.type == BaseItemKind.EPISODE val isEpisode = item.type == BaseItemKind.EPISODE
val dto = item.data val dto = item.data
HomePageHeader( HomePageHeader(
title = item.title, title = item.title,
subtitle = if (isEpisode) dto.name else null, subtitle = if (isEpisode) dto.name else null,
@ -490,17 +493,8 @@ fun HomePageHeader(
.fillMaxWidth(.6f) .fillMaxWidth(.6f)
.fillMaxHeight(), .fillMaxHeight(),
) { ) {
// val isEpisode = item.type == BaseItemKind.EPISODE
// val subtitle = if (isEpisode) dto.name else null
// val overview = dto.overview
subtitle?.let { subtitle?.let {
Text( EpisodeName(it)
text = subtitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
quickDetails?.invoke() quickDetails?.invoke()
val overviewModifier = val overviewModifier =

View file

@ -49,6 +49,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs)
private const val TOP_SCRIM_ALPHA = 0.55f
private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
@HiltViewModel @HiltViewModel
class ApplicationContentViewModel class ApplicationContentViewModel
@Inject @Inject
@ -73,6 +77,7 @@ fun ApplicationContent(
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enableTopScrim: Boolean = true,
viewModel: ApplicationContentViewModel = hiltViewModel(), viewModel: ApplicationContentViewModel = hiltViewModel(),
) { ) {
val backStack: MutableList<NavKey> = val backStack: MutableList<NavKey> =
@ -179,6 +184,20 @@ fun ApplicationContent(
.alpha(.95f) .alpha(.95f)
.drawWithContent { .drawWithContent {
drawContent() drawContent()
// Subtle top scrim for system UI readability (clock, tabs)
if (enableTopScrim) {
drawRect(
brush =
Brush.verticalGradient(
colorStops =
arrayOf(
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
TOP_SCRIM_END_FRACTION to Color.Transparent,
),
),
blendMode = BlendMode.Multiply,
)
}
drawRect( drawRect(
brush = brush =
Brush.horizontalGradient( Brush.horizontalGradient(

View file

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

View file

@ -0,0 +1,27 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.Player
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.seekBack
import com.google.common.util.concurrent.ListenableFuture
import timber.log.Timber
class MediaSessionPlayer(
player: Player,
private val controllerViewState: ControllerViewState,
private val playbackPreferences: PlaybackPreferences,
) : ForwardingSimpleBasePlayer(player) {
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady")
if (!playWhenReady && player.isPlaying) {
controllerViewState.showControls()
} else if (playWhenReady) {
playbackPreferences.skipBackOnResume?.let {
player.seekBack(it)
}
}
return super.handleSetPlayWhenReady(playWhenReady)
}
}

View file

@ -1,73 +0,0 @@
package com.github.damontecres.wholphin.ui.playback
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.MediaStream
data class SubtitleStream(
val index: Int,
val language: String?,
val title: String?,
val codec: String?,
val codecTag: String?,
val external: Boolean,
val forced: Boolean,
val default: Boolean,
val displayTitle: String?,
) {
val displayName: String
get() =
displayTitle ?: listOfNotNull(
language,
title,
codec,
).joinToString(" - ")
.ifBlank { WholphinApplication.instance.getString(R.string.unknown) }
companion object {
fun from(it: MediaStream): SubtitleStream =
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
it.displayTitle,
)
}
}
data class AudioStream(
val index: Int,
val language: String?,
val title: String?,
val codec: String?,
val codecTag: String?,
val channels: Int?,
val channelLayout: String?,
) {
val displayName: String
get() =
listOfNotNull(
language,
title,
codec,
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
).joinToString(" - ").ifBlank { "Unknown" }
companion object {
fun from(it: MediaStream): AudioStream =
AudioStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.channels,
it.channelLayout,
)
}
}

View file

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

View file

@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity import android.view.Gravity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
enum class PlaybackDialogType { enum class PlaybackDialogType {
@ -35,12 +47,13 @@ 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,
val hasSubtitleDownloadPermission: Boolean,
) )
@Composable @Composable
@ -59,7 +72,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 +94,85 @@ 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 = hasDownloadPermission = settings.hasSubtitleDownloadPermission,
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 if (subtitleIndex == TrackIndex.ONLY_FORCED) {
} else { onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED))
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
}
} }
}, },
onSelectSearch = {
onDismissRequest.invoke()
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 +181,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 +209,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 +235,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 +269,183 @@ fun PlaybackDialog(
} }
} }
} }
@Composable
fun SubtitleChoiceBottomDialog(
choices: List<SimpleMediaStream>,
onDismissRequest: () -> Unit,
onSelectChoice: (Int) -> Unit,
onSelectSearch: () -> Unit,
gravity: Int,
hasDownloadPermission: Boolean,
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 = {},
)
}
item {
ListItem(
selected = currentChoice == TrackIndex.ONLY_FORCED,
onClick = {
onSelectChoice(TrackIndex.ONLY_FORCED)
},
leadingContent = {
SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED)
},
headlineContent = {
Text(
text = stringResource(R.string.only_forced_subtitles),
)
},
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,
enabled = hasDownloadPermission,
onClick = onSelectSearch,
leadingContent = {},
headlineContent = {
Text(
text = stringResource(R.string.search_and_download),
)
},
supportingContent = {},
)
}
}
}
}
}
@Composable
fun StreamChoiceBottomDialog(
choices: List<SimpleMediaStream>,
onDismissRequest: () -> Unit,
onSelectChoice: (Int, SimpleMediaStream) -> Unit,
gravity: Int,
currentChoice: Int? = null,
) {
// TODO enforcing a width ends up ignore the gravity
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = true),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre
window.setDimAmount(0f) // Remove dimmed background of ongoing playback
}
Box(
modifier =
Modifier
.wrapContentSize()
.padding(8.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(8.dp),
),
) {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
// .widthIn(max = 240.dp)
.wrapContentWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(
selected = choice.index == currentChoice,
onClick = {
onDismissRequest()
onSelectChoice(index, choice)
},
leadingContent = {
SelectedLeadingContent(choice.index == currentChoice)
},
headlineContent = {
Text(
text = choice.streamTitle ?: choice.displayTitle,
)
},
supportingContent = {
if (choice.streamTitle != null) Text(choice.displayTitle)
},
interactionSource = interactionSource,
)
}
}
}
}
}

View file

@ -61,27 +61,8 @@ class PlaybackKeyHandler(
} }
} else if (isMedia(it)) { } else if (isMedia(it)) {
when (it.key) { when (it.key) {
Key.MediaPlay -> { Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> {
Util.handlePlayButtonAction(player) // no-op, MediaSession will handle
skipBackOnResume?.let {
player.seekBack(it)
}
}
Key.MediaPause -> {
Util.handlePauseButtonAction(player)
controllerViewState.showControls()
}
Key.MediaPlayPause -> {
Util.handlePlayPauseButtonAction(player)
if (!player.isPlaying) {
controllerViewState.showControls()
} else {
skipBackOnResume?.let {
player.seekBack(it)
}
}
} }
Key.MediaFastForward, Key.MediaSkipForward -> { Key.MediaFastForward, Key.MediaSkipForward -> {

View file

@ -130,6 +130,7 @@ fun PlaybackPage(
val player = viewModel.player val player = viewModel.player
val mediaInfo by viewModel.currentMediaInfo.observeAsState() val mediaInfo by viewModel.currentMediaInfo.observeAsState()
val userDto by viewModel.currentUserDto.observeAsState()
val currentPlayback by viewModel.currentPlayback.collectAsState() val currentPlayback by viewModel.currentPlayback.collectAsState()
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
@ -561,6 +562,8 @@ fun PlaybackPage(
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
contentScale = contentScale, contentScale = contentScale,
subtitleDelay = subtitleDelay, subtitleDelay = subtitleDelay,
hasSubtitleDownloadPermission =
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = null playbackDialog = null

View file

@ -21,6 +21,7 @@ import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.session.MediaSession
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.size.Size import coil3.size.Size
@ -134,6 +135,7 @@ class PlaybackViewModel
val player by lazy { val player by lazy {
playerFactory.createVideoPlayer() playerFactory.createVideoPlayer()
} }
private var mediaSession: MediaSession? = null
internal val mutex = Mutex() internal val mutex = Mutex()
val controllerViewState = val controllerViewState =
@ -166,6 +168,8 @@ class PlaybackViewModel
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null) val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language) val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
val currentUserDto = serverRepository.currentUserDto
init { init {
addCloseable { addCloseable {
player.removeListener(this@PlaybackViewModel) player.removeListener(this@PlaybackViewModel)
@ -177,6 +181,7 @@ class PlaybackViewModel
} }
jobs.forEach { it.cancel() } jobs.forEach { it.cancel() }
player.release() player.release()
mediaSession?.release()
} }
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
player.addListener(this) player.addListener(this)
@ -271,6 +276,18 @@ class PlaybackViewModel
} else { } else {
throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
} }
val sessionPlayer =
MediaSessionPlayer(
player,
controllerViewState,
preferences.appPreferences.playbackPreferences,
)
mediaSession =
MediaSession
.Builder(context, sessionPlayer)
.build()
val item = BaseItem.from(base, api) val item = BaseItem.from(base, api)
val played = val played =
@ -312,12 +329,15 @@ class PlaybackViewModel
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.i("Playing ${item.id}") Timber.i("Playing ${item.id}")
// Starting playback, so want to invalidate the last played timestamp for this item
datePlayedService.invalidate(item)
// New item, so we can clear the media segment tracker & subtitle cues // New item, so we can clear the media segment tracker & subtitle cues
autoSkippedSegments.clear() resetSegmentState()
this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf())
viewModelScope.launchIO {
// Starting playback, so want to invalidate the last played timestamp for this item
datePlayedService.invalidate(item)
}
if (item.type !in supportItemKinds) { if (item.type !in supportItemKinds) {
showToast( showToast(
context, context,
@ -360,15 +380,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(
@ -444,7 +467,7 @@ class PlaybackViewModel
player.prepare() player.prepare()
player.play() player.play()
} }
listenForSegments() listenForSegments(item.id)
return@withContext true return@withContext true
} }
@ -478,8 +501,9 @@ class PlaybackViewModel
if (externalSubtitle == null) { if (externalSubtitle == null) {
val result = val result =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
TrackSelectionUtils.applyTrackSelections( TrackSelectionUtils.createTrackSelections(
player, onMain { player.trackSelectionParameters },
onMain { player.currentTracks },
playerBackend, playerBackend,
true, true,
audioIndex, audioIndex,
@ -488,13 +512,20 @@ class PlaybackViewModel
) )
} }
if (result.bothSelected) { if (result.bothSelected) {
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
// TODO lots of duplicate code in this block // TODO lots of duplicate code in this block
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
val itemPlayback = val itemPlayback =
currentItemPlayback.copy( currentItemPlayback.copy(
sourceId = source.id?.toUUIDOrNull(), sourceId = source.id?.toUUIDOrNull(),
audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED,
subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index
subtitleIndex =
if (currentItemPlayback.subtitleIndex < 0) {
currentItemPlayback.subtitleIndex
} else {
subtitleIndex ?: TrackIndex.DISABLED
},
) )
if (userInitiated) { if (userInitiated) {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -680,8 +711,9 @@ class PlaybackViewModel
Timber.v("onTracksChanged: $tracks") Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) { if (tracks.groups.isNotEmpty()) {
val result = val result =
TrackSelectionUtils.applyTrackSelections( TrackSelectionUtils.createTrackSelections(
player, player.trackSelectionParameters,
player.currentTracks,
playerBackend, playerBackend,
source.supportsDirectPlay, source.supportsDirectPlay,
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
@ -689,6 +721,8 @@ class PlaybackViewModel
source, source,
) )
if (result.bothSelected) { if (result.bothSelected) {
player.trackSelectionParameters =
result.trackSelectionParameters
player.removeListener(this) player.removeListener(this)
} }
viewModelScope.launchIO { loadSubtitleDelay() } viewModelScope.launchIO { loadSubtitleDelay() }
@ -712,11 +746,27 @@ class PlaybackViewModel
type = MediaStreamType.AUDIO, type = MediaStreamType.AUDIO,
) )
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
// Resolve ONLY_FORCED to actual track based on new audio language
val source = currentPlayback.value?.mediaSourceInfo
val resolvedSubtitleIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = index,
seriesId = item.data.seriesId,
subtitleIndex = itemPlayback.subtitleIndex,
prefs = preferences,
)
} else {
itemPlayback.subtitleIndex.takeIf { it >= 0 }
}
changeStreams( changeStreams(
item, item,
itemPlayback, itemPlayback,
index, index,
itemPlayback.subtitleIndex, resolvedSubtitleIndex,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,
) )
@ -734,11 +784,27 @@ class PlaybackViewModel
type = MediaStreamType.SUBTITLE, type = MediaStreamType.SUBTITLE,
) )
this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback)
// Resolve ONLY_FORCED to actual track index for playback
val source = currentPlayback.value?.mediaSourceInfo
val resolvedIndex =
if (source != null) {
streamChoiceService.resolveSubtitleIndex(
source = source,
audioStreamIndex = itemPlayback.audioIndex,
seriesId = item.data.seriesId,
subtitleIndex = index,
prefs = preferences,
)
} else {
index.takeIf { it >= 0 }
}
changeStreams( changeStreams(
item, item,
itemPlayback, itemPlayback,
itemPlayback.audioIndex, itemPlayback.audioIndex,
index, resolvedIndex,
onMain { player.currentPosition }, onMain { player.currentPosition },
true, true,
) )
@ -796,10 +862,19 @@ class PlaybackViewModel
private var segmentJob: Job? = null private var segmentJob: Job? = null
/**
* Cancels listening for segments and clears current segment state
*/
private suspend fun resetSegmentState() {
segmentJob?.cancel()
autoSkippedSegments.clear()
currentSegment.setValueOnMain(null)
}
/** /**
* This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc) * This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc)
*/ */
private fun listenForSegments() { private fun listenForSegments(itemId: UUID) {
segmentJob?.cancel() segmentJob?.cancel()
segmentJob = segmentJob =
viewModelScope.launchIO { viewModelScope.launchIO {
@ -815,7 +890,10 @@ class PlaybackViewModel
.firstOrNull { .firstOrNull {
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
} }
if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { if (currentSegment != null &&
currentSegment.itemId == this@PlaybackViewModel.itemId &&
autoSkippedSegments.add(currentSegment.id)
) {
Timber.d( Timber.d(
"Found media segment for %s: %s, %s", "Found media segment for %s: %s, %s",
currentSegment.itemId, currentSegment.itemId,
@ -1025,6 +1103,7 @@ class PlaybackViewModel
Timber.v("release") Timber.v("release")
activityListener?.release() activityListener?.release()
player.release() player.release()
mediaSession?.release()
activityListener = null activityListener = null
} }

View file

@ -0,0 +1,25 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
import org.jellyfin.sdk.model.api.MediaStream
data class SimpleMediaStream(
val index: Int,
val streamTitle: String?,
val displayTitle: String,
) {
companion object {
fun from(
context: Context,
mediaStream: MediaStream,
includeFlags: Boolean = true,
): SimpleMediaStream =
SimpleMediaStream(
index = mediaStream.index,
streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() },
displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags),
)
}
}

View file

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

View file

@ -3,8 +3,9 @@ package com.github.damontecres.wholphin.ui.playback
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
@ -12,24 +13,24 @@ import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import timber.log.Timber import timber.log.Timber
import kotlin.math.max
object TrackSelectionUtils { object TrackSelectionUtils {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun applyTrackSelections( fun createTrackSelections(
player: Player, trackSelectionParams: TrackSelectionParameters,
tracks: Tracks,
playerBackend: PlayerBackend, playerBackend: PlayerBackend,
supportsDirectPlay: Boolean, supportsDirectPlay: Boolean,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
source: MediaSourceInfo, source: MediaSourceInfo,
): TrackSelectionResult { ): TrackSelectionResult {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
val embeddedSubtitleCount = source.embeddedSubtitleCount val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = player.trackSelectionParameters.buildUpon() val paramsBuilder = trackSelectionParams.buildUpon()
val tracks = player.currentTracks.groups val groups = tracks.groups
val subtitleSelected = val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) { if (subtitleIndex != null && subtitleIndex >= 0) {
@ -37,7 +38,7 @@ object TrackSelectionUtils {
if (subtitleIsExternal || supportsDirectPlay) { if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack = val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
tracks.firstOrNull { group -> groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported && group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.mapNotNull { .mapNotNull {
@ -45,23 +46,38 @@ object TrackSelectionUtils {
}.any { it.endsWith("e:$subtitleIndex") } }.any { it.endsWith("e:$subtitleIndex") }
} }
} else { } else {
val actualEmbeddedCount =
groups
.filter { group ->
group.type == C.TRACK_TYPE_TEXT &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.none { it.contains("e:") }
}.size
val indexToFind = val indexToFind =
calculateIndexToFind( calculateIndexToFind(
subtitleIndex, subtitleIndex,
MediaStreamType.SUBTITLE, MediaStreamType.SUBTITLE,
playerBackend, playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount, embeddedSubtitleCount,
externalSubtitleCount, externalSubtitleCount,
subtitleIsExternal, subtitleIsExternal,
actualEmbeddedCount,
source,
) )
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track") Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1 // subtitleIndex - externalSubtitleCount + 1
tracks.firstOrNull { group -> groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported && group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.map { .filter {
if (subtitleIsExternal) {
group.getTrackFormat(0).id?.contains("e:") == true
} else {
group.getTrackFormat(0).id?.contains("e:") == false
}
}.map {
group.getTrackFormat(it).idAsInt group.getTrackFormat(it).idAsInt
}.contains(indexToFind) }.contains(indexToFind)
} }
@ -95,14 +111,14 @@ object TrackSelectionUtils {
audioIndex, audioIndex,
MediaStreamType.AUDIO, MediaStreamType.AUDIO,
playerBackend, playerBackend,
videoStreamCount,
audioStreamCount,
embeddedSubtitleCount, embeddedSubtitleCount,
externalSubtitleCount, externalSubtitleCount,
false, false,
null,
source,
) )
val chosenTrack = val chosenTrack =
tracks.firstOrNull { group -> groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported && group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length) (0..<group.mediaTrackGroup.length)
.map { .map {
@ -124,10 +140,7 @@ object TrackSelectionUtils {
} else { } else {
audioIndex == null audioIndex == null
} }
if (audioSelected && subtitleSelected) { return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
player.trackSelectionParameters = paramsBuilder.build()
}
return TrackSelectionResult(audioSelected, subtitleSelected)
} }
/** /**
@ -137,11 +150,11 @@ object TrackSelectionUtils {
serverIndex: Int, serverIndex: Int,
type: MediaStreamType, type: MediaStreamType,
playerBackend: PlayerBackend, playerBackend: PlayerBackend,
videoStreamCount: Int,
audioStreamCount: Int,
embeddedSubtitleCount: Int, embeddedSubtitleCount: Int,
externalSubtitleCount: Int, externalSubtitleCount: Int,
subtitleIsExternal: Boolean, subtitleIsExternal: Boolean,
actualEmbeddedCount: Int?,
source: MediaSourceInfo,
): Int = ): Int =
when (playerBackend) { when (playerBackend) {
PlayerBackend.EXO_PLAYER, PlayerBackend.EXO_PLAYER,
@ -158,13 +171,22 @@ object TrackSelectionUtils {
} }
MediaStreamType.AUDIO -> { MediaStreamType.AUDIO -> {
serverIndex - externalSubtitleCount - videoStreamCount + 1 val videoStreamsBeforeAudioCount =
source.mediaStreams
.orEmpty()
.indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount
serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1
} }
MediaStreamType.SUBTITLE -> { MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) { if (subtitleIsExternal) {
serverIndex + embeddedSubtitleCount + 1 // Need to account for the actual embedded count because if the library
// disables embedded subtitles, they still exist in the direct played file,
// but not included in the MediaStreams list
serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1
} else { } else {
val videoStreamCount = source.videoStreamCount
val audioStreamCount = source.audioStreamCount
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
} }
} }
@ -228,12 +250,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me
fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
subtitleIndex?.let { subtitleIndex?.let {
firstOrNull { firstOrNull {
it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.type == MediaStreamType.SUBTITLE &&
(it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) &&
it.index == subtitleIndex it.index == subtitleIndex
} }
} }
data class TrackSelectionResult( data class TrackSelectionResult(
val trackSelectionParameters: TrackSelectionParameters,
val audioSelected: Boolean, val audioSelected: Boolean,
val subtitleSelected: Boolean, val subtitleSelected: Boolean,
) { ) {

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.NavigationDrawerScope
@ -30,10 +31,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.NavItem import com.github.damontecres.wholphin.ui.nav.NavItem
import com.github.damontecres.wholphin.ui.playback.PlaybackButton import com.github.damontecres.wholphin.ui.playback.PlaybackButton
@ -43,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,
) )
@ -109,6 +108,7 @@ private fun ThemeExample(theme: AppThemeColors) {
isPlayed = true, isPlayed = true,
unplayedItemCount = 2, unplayedItemCount = 2,
playedPercentage = 50.0, playedPercentage = 50.0,
numberOfVersions = 2,
onClick = { }, onClick = { },
onLongClick = {}, onLongClick = {},
imageHeight = 120.dp, imageHeight = 120.dp,
@ -125,6 +125,7 @@ private fun ThemeExample(theme: AppThemeColors) {
isPlayed = true, isPlayed = true,
unplayedItemCount = 2, unplayedItemCount = 2,
playedPercentage = 0.0, playedPercentage = 0.0,
numberOfVersions = 0,
onClick = { }, onClick = { },
onLongClick = {}, onLongClick = {},
imageHeight = 120.dp, imageHeight = 120.dp,
@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) {
interactionSource = source, interactionSource = source,
) )
} }
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
ListItem(
selected = false,
enabled = true,
headlineContent = { Text("Headline content") },
supportingContent = { Text("Support content") },
onClick = {},
)
ListItem(
selected = true,
enabled = true,
headlineContent = { Text("Headline content") },
supportingContent = { Text("Support content") },
onClick = {},
)
}
} }
} }
} }

View file

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

View file

@ -0,0 +1,171 @@
package com.github.damontecres.wholphin.ui.util
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.languageName
import com.github.damontecres.wholphin.util.profile.Codec
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
/**
* Collection of utility functions for formatting the display of media streams
*/
object StreamFormatting {
fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p"
// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714
fun resolutionString(
width: Int,
height: Int,
interlaced: Boolean,
): String =
if (height > width) {
// Vertical video
resolutionString(height, width, interlaced)
} else {
when {
width <= 256 && height <= 144 -> "144" + interlaced(interlaced)
width <= 426 && height <= 240 -> "240" + interlaced(interlaced)
width <= 640 && height <= 360 -> "360" + interlaced(interlaced)
width <= 682 && height <= 384 -> "384" + interlaced(interlaced)
width <= 720 && height <= 404 -> "404" + interlaced(interlaced)
width <= 854 && height <= 480 -> "480" + interlaced(interlaced)
width <= 960 && height <= 544 -> "540" + interlaced(interlaced)
width <= 1024 && height <= 576 -> "576" + interlaced(interlaced)
width <= 1280 && height <= 962 -> "720" + interlaced(interlaced)
width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced)
width <= 4096 && height <= 3072 -> "4K"
width <= 8192 && height <= 6144 -> "8K"
else -> height.toString() + interlaced(interlaced)
}
}
fun formatVideoRange(
context: Context,
videoRange: VideoRange?,
type: VideoRangeType?,
doviTitle: String?,
): String? =
when (videoRange) {
VideoRange.UNKNOWN,
VideoRange.SDR, null,
-> {
null
}
VideoRange.HDR -> {
if (doviTitle.isNotNullOrBlank()) {
context.getString(R.string.dolby_vision)
} else {
when (type) {
VideoRangeType.UNKNOWN,
VideoRangeType.SDR,
null,
-> null
VideoRangeType.HDR10 -> "HDR10"
VideoRangeType.HDR10_PLUS -> "HDR10+"
VideoRangeType.HLG -> "HLG"
VideoRangeType.DOVI,
VideoRangeType.DOVI_WITH_HDR10,
VideoRangeType.DOVI_WITH_HLG,
VideoRangeType.DOVI_WITH_SDR,
-> context.getString(R.string.dolby_vision)
}
}
}
}
fun formatAudioCodec(
context: Context,
codec: String?,
profile: String?,
): String? =
when {
profile?.contains("Dolby Atmos", true) == true -> {
context.getString(R.string.dolby_atmos)
}
profile?.contains("DTS:X", true) == true -> {
context.getString(R.string.dts_x)
}
profile?.contains("DTS:HD", true) == true -> {
context.getString(R.string.dts_hd)
}
else -> {
when (codec?.lowercase()) {
Codec.Audio.TRUEHD -> context.getString(R.string.truehd)
Codec.Audio.AC3 -> context.getString(R.string.dolby_digital)
Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus)
Codec.Audio.DCA -> context.getString(R.string.dts)
Codec.Audio.OGG,
Codec.Audio.OPUS,
Codec.Audio.VORBIS,
-> codec.replaceFirstChar { it.uppercase() }
null -> null
else -> codec.uppercase()
}
}
}
fun formatSubtitleCodec(codec: String?): String? =
when (codec?.lowercase()) {
Codec.Subtitle.DVBSUB -> "DVB"
Codec.Subtitle.DVDSUB -> "DVD"
Codec.Subtitle.PGSSUB -> "PGS"
Codec.Subtitle.SUBRIP -> "SRT"
null -> null
else -> codec.uppercase()
}
fun String?.concatWithSpace(str: String?): String? =
when {
this != null && str != null -> "$this $str"
this == null -> str
else -> this
}
fun mediaStreamDisplayTitle(
context: Context,
stream: MediaStream,
includeFlags: Boolean,
): String {
val name =
buildList {
add(languageName(stream.language))
if (stream.type == MediaStreamType.AUDIO) {
add(formatAudioCodec(context, stream.codec, stream.profile))
add(stream.channelLayout)
} else if (stream.type == MediaStreamType.SUBTITLE) {
"SDH".takeIf { stream.isHearingImpaired }?.let(::add)
add(formatSubtitleCodec(stream.codec))
}
}.joinToString(" ")
if (includeFlags) {
val flags =
buildList {
if (stream.isDefault) add(stream.localizedDefault)
if (stream.isForced) add(stream.localizedForced)
if (stream.isExternal) add(stream.localizedExternal)
}.joinToString(", ")
if (flags.isNotEmpty()) {
return "$name ($flags)"
}
}
return name
}
}

View file

@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.update import kotlin.concurrent.atomics.update
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/** /**
@ -231,11 +232,6 @@ class MpvPlayer(
override fun prepare() { override fun prepare() {
if (DEBUG) Timber.v("prepare") if (DEBUG) Timber.v("prepare")
playbackState.update {
it.copy(
state = Player.STATE_READY,
)
}
} }
override fun getPlaybackState(): Int { override fun getPlaybackState(): Int {
@ -246,7 +242,8 @@ class MpvPlayer(
override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlayerError(): PlaybackException? { override fun getPlayerError(): PlaybackException? {
TODO("Not yet implemented") // TODO
return null
} }
override fun setPlayWhenReady(playWhenReady: Boolean) { override fun setPlayWhenReady(playWhenReady: Boolean) {
@ -394,8 +391,7 @@ class MpvPlayer(
override fun getCurrentTimeline(): Timeline { override fun getCurrentTimeline(): Timeline {
if (DEBUG) Timber.v("getCurrentTimeline") if (DEBUG) Timber.v("getCurrentTimeline")
// TODO return playbackState.load().timeline
return Timeline.EMPTY
} }
override fun getCurrentPeriodIndex(): Int { override fun getCurrentPeriodIndex(): Int {
@ -527,7 +523,7 @@ class MpvPlayer(
return playbackState.load().videoSize return playbackState.load().videoSize
} }
override fun getSurfaceSize(): Size = throw UnsupportedOperationException() override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN
override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO
@ -654,12 +650,15 @@ class MpvPlayer(
} }
MPV_EVENT_FILE_LOADED -> { MPV_EVENT_FILE_LOADED -> {
playbackState.update {
it.copy(isLoadingFile = false)
}
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
Timber.d("event: MPV_EVENT_FILE_LOADED") Timber.d("event: MPV_EVENT_FILE_LOADED")
internalHandler.post(updatePlaybackState) playbackState.update {
it.copy(
isLoadingFile = false,
)
}
updatePlaybackState.run()
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
playbackState.load().media?.mediaItem?.let { media -> playbackState.load().media?.mediaItem?.let { media ->
media.localConfiguration?.subtitleConfigurations?.forEach { media.localConfiguration?.subtitleConfigurations?.forEach {
val url = it.uri.toString() val url = it.uri.toString()
@ -766,10 +765,63 @@ class MpvPlayer(
private fun loadFile(media: MediaAndPosition) { private fun loadFile(media: MediaAndPosition) {
Timber.v("loadFile: media=$media") Timber.v("loadFile: media=$media")
val timeline =
object : Timeline() {
override fun getWindowCount(): Int = 1
override fun getWindow(
windowIndex: Int,
window: Window,
defaultPositionProjectionUs: Long,
): Window =
window.set(
media.mediaItem.mediaId,
media.mediaItem,
null,
C.TIME_UNSET,
C.TIME_UNSET,
C.TIME_UNSET,
true,
true,
media.mediaItem.liveConfiguration,
0L,
C.TIME_UNSET,
0,
0,
0,
)
override fun getPeriodCount(): Int = 1
override fun getPeriod(
periodIndex: Int,
period: Period,
setIds: Boolean,
): Period =
period.set(
media.mediaItem.mediaId,
media.mediaItem.mediaId,
0,
C.TIME_UNSET,
0,
)
override fun getIndexOfPeriod(uid: Any): Int = 0
override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId
}
playbackState.update { playbackState.update {
it.copy( it.copy(
isLoadingFile = true, isLoadingFile = true,
state = STATE_READY,
media = media, media = media,
timeline = timeline,
)
}
notifyListeners(EVENT_TIMELINE_CHANGED) {
onTimelineChanged(
timeline,
TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
) )
} }
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
@ -837,7 +889,8 @@ class MpvPlayer(
private val updatePlaybackState: Runnable = private val updatePlaybackState: Runnable =
Runnable { Runnable {
if (playbackState.load().media == null) { val state = playbackState.load()
if (state.media == null) {
return@Runnable return@Runnable
} }
val positionMs = val positionMs =
@ -860,6 +913,53 @@ class MpvPlayer(
VideoSize.UNKNOWN VideoSize.UNKNOWN
} }
val mediaItem = state.media.mediaItem
val timeline =
object : Timeline() {
override fun getWindowCount(): Int = 1
override fun getWindow(
windowIndex: Int,
window: Window,
defaultPositionProjectionUs: Long,
): Window =
window.set(
mediaItem.mediaId,
mediaItem,
null,
C.TIME_UNSET,
C.TIME_UNSET,
C.TIME_UNSET,
true,
false,
mediaItem.liveConfiguration,
0L,
if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET,
0,
0,
0,
)
override fun getPeriodCount(): Int = 1
override fun getPeriod(
periodIndex: Int,
period: Period,
setIds: Boolean,
): Period =
period.set(
mediaItem.mediaId,
mediaItem.mediaId,
0,
state.durationMs.milliseconds.inWholeMicroseconds,
0,
)
override fun getIndexOfPeriod(uid: Any): Int = 0
override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId
}
playbackState.update { playbackState.update {
it.copy( it.copy(
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
@ -869,6 +969,13 @@ class MpvPlayer(
speed = speed, speed = speed,
isPaused = paused, isPaused = paused,
videoSize = videoSize, videoSize = videoSize,
timeline = timeline,
)
}
notifyListeners(EVENT_TIMELINE_CHANGED) {
onTimelineChanged(
timeline,
TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
) )
} }
} }
@ -1079,12 +1186,13 @@ private data class PlaybackState(
val videoSize: VideoSize, val videoSize: VideoSize,
@param:Player.State val state: Int, @param:Player.State val state: Int,
val tracks: Tracks, val tracks: Tracks,
val timeline: Timeline,
) { ) {
companion object { companion object {
val EMPTY = val EMPTY =
PlaybackState( PlaybackState(
timestamp = C.TIME_UNSET, timestamp = C.TIME_UNSET,
isLoadingFile = true, isLoadingFile = false,
media = null, media = null,
positionMs = C.TIME_UNSET, positionMs = C.TIME_UNSET,
durationMs = C.TIME_UNSET, durationMs = C.TIME_UNSET,
@ -1095,6 +1203,7 @@ private data class PlaybackState(
videoSize = VideoSize.UNKNOWN, videoSize = VideoSize.UNKNOWN,
state = Player.STATE_IDLE, state = Player.STATE_IDLE,
subtitleDelay = 0.0, subtitleDelay = 0.0,
timeline = Timeline.EMPTY,
) )
} }
} }

View file

@ -75,6 +75,7 @@
<string name="no_scheduled_recordings">No scheduled recordings</string> <string name="no_scheduled_recordings">No scheduled recordings</string>
<string name="no_update_available">No update available</string> <string name="no_update_available">No update available</string>
<string name="none">None</string> <string name="none">None</string>
<string name="only_forced_subtitles">Only Forced Subtitles</string>
<string name="outro">Outro</string> <string name="outro">Outro</string>
<string name="path">Path</string> <string name="path">Path</string>
<string name="people">People</string> <string name="people">People</string>
@ -419,6 +420,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 name="discover">Discover</string> <string name="discover">Discover</string>
<string name="request">Request</string> <string name="request">Request</string>

View file

@ -0,0 +1,9 @@
package android.text;
// Mocks static class for non-instrumented unit tests
public class TextUtils {
public static boolean isEmpty(CharSequence str) {
return str == null || str.length() == 0;
}
}

View file

@ -613,6 +613,132 @@ class TestStreamChoiceServiceMultipleChoices(
} }
} }
/**
* Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED).
* This tests the findForcedTrack function with user subtitle language preference.
*/
@RunWith(Parameterized::class)
class TestStreamChoiceServiceOnlyForcedClientOverride(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
// Test 1: Prefer user's subtitle language preference for forced tracks
TestInput(
expectedIndex = 1, // spa forced track (matches user pref)
userSubtitleMode = null,
userSubtitleLang = "spa",
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 2: User subtitle preference matches Signs track via title (not forced flag)
TestInput(
expectedIndex = 0, // eng signs track via title detection
userSubtitleMode = null,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "jpn", // different from subtitle pref
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 3: Falls back to audio-matching forced when no preference match
TestInput(
expectedIndex = 0, // eng forced (audio match, step 2)
userSubtitleMode = null,
userSubtitleLang = "spa", // no spa forced exists
subtitles =
listOf(
subtitle(0, "eng", forced = true), // matches audio
subtitle(1, "und", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 4: Falls back to audio-matching signs when user pref has no match
TestInput(
expectedIndex = 0, // eng signs (audio match fallback)
userSubtitleMode = null,
userSubtitleLang = "fre", // user prefers French, no French forced exists
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 5: Use audio language for signs/songs when NO subtitle preference
TestInput(
expectedIndex = 0, // eng signs track matching audio
userSubtitleMode = null,
userSubtitleLang = null, // no preference
subtitles =
listOf(
subtitle(0, "eng", forced = false, title = "Signs & Songs"),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 6: Unknown language forced track with no preference
TestInput(
expectedIndex = 0, // unknown forced track
userSubtitleMode = null,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, null, forced = true), // unknown/null language
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 7: Unknown language forced track when no audio match
TestInput(
expectedIndex = 0, // unknown forced track (step 3)
userSubtitleMode = null,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "und", forced = true), // unknown language forced
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng", // no eng tracks exist
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
// Test 8: No matching forced track returns null (not irrelevant language)
TestInput(
expectedIndex = null, // no forced track matches - return null instead of wrong language
userSubtitleMode = null,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "chi", forced = true), // Chinese forced - wrong language
subtitle(1, "spa", forced = true), // Spanish forced - wrong language
),
streamAudioLang = "eng", // audio is English, no English forced
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED),
),
)
}
}
data class TestInput( data class TestInput(
val expectedIndex: Int?, val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?, val userSubtitleMode: SubtitlePlaybackMode?,
@ -674,6 +800,7 @@ fun subtitle(
lang: String?, lang: String?,
default: Boolean = false, default: Boolean = false,
forced: Boolean = false, forced: Boolean = false,
title: String? = null,
): MediaStream = ): MediaStream =
MediaStream( MediaStream(
type = MediaStreamType.SUBTITLE, type = MediaStreamType.SUBTITLE,
@ -686,6 +813,7 @@ fun subtitle(
isExternal = false, isExternal = false,
isTextSubtitleStream = true, isTextSubtitleStream = true,
supportsExternalStream = true, supportsExternalStream = true,
title = title,
) )
private fun itemPlayback( private fun itemPlayback(

View file

@ -0,0 +1,591 @@
package com.github.damontecres.wholphin.test
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.junit.Assert
import org.junit.Test
import java.nio.file.Paths
import kotlin.io.path.readText
class TestTrackSelection {
/**
* Builds the tracks for the `embedded_subs.json` for the given backend
*
* Note: This is manual based on observation & code review of the playback for that file
*/
private fun buildEmbeddedTracks(backend: PlayerBackend): Tracks {
val formats =
if (backend == PlayerBackend.MPV) {
val video =
Format
.Builder()
.setId("0:1")
.setSampleMimeType("video/default")
.build()
val audios =
(1..3).map {
Format
.Builder()
.setId("$it:$it")
.setSampleMimeType("audio/default")
.build()
}
val subtitles =
(1..3).map {
Format
.Builder()
.setId("${it + 3}:$it")
.setSampleMimeType("text/default")
.build()
}
(listOf(video) + audios + subtitles)
} else {
val video =
Format
.Builder()
.setId("1")
.setSampleMimeType("video/default")
.build()
val audios =
(2..4).map {
Format
.Builder()
.setId("$it")
.setSampleMimeType("audio/default")
.build()
}
val subtitles =
(5..7).map {
Format
.Builder()
.setId("$it")
.setSampleMimeType("text/default")
.build()
}
(listOf(video) + audios + subtitles)
}
val groups =
formats
.map { TrackGroup(it) }
.map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) }
return Tracks(groups)
}
/**
* Builds the tracks for the `no_embedded_subs.json` for the given backend
*
* Note: This is manual based on observation & code review of the playback for that file
*/
private fun buildNoEmbeddedTracks(backend: PlayerBackend): Tracks {
val formats =
if (backend == PlayerBackend.MPV) {
val video =
Format
.Builder()
.setId("0:1")
.setSampleMimeType("video/default")
.build()
val audios =
(1..3).map {
Format
.Builder()
.setId("$it:$it")
.setSampleMimeType("audio/default")
.build()
}
val subtitles =
(1..3).map {
Format
.Builder()
.setId("${it + 3}:$it")
.setSampleMimeType("text/default")
.build()
} +
listOf(
Format
.Builder()
.setId("7:e:4")
.setSampleMimeType("text/default")
.build(),
)
(listOf(video) + audios + subtitles)
} else {
// ExoPlayer
val video =
Format
.Builder()
.setId("0:1")
.setSampleMimeType("video/default")
.build()
val audios =
(2..4).map {
Format
.Builder()
.setId("0:$it")
.setSampleMimeType("audio/default")
.build()
}
val subtitles =
(5..7).map {
Format
.Builder()
.setId("0:$it")
.setSampleMimeType("text/default")
.build()
} +
listOf(
Format
.Builder()
.setId("1:e:0")
.setSampleMimeType("text/default")
.build(),
)
(listOf(video) + audios + subtitles)
}
val groups =
formats
.map { TrackGroup(it) }
.map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) }
return Tracks(groups)
}
/**
* Builds the tracks for the `external_subs.json` for the given backend.
*
* Must supply the desired subtitle index because ExoPlayer uses it.
*
* Note: This is manual based on observation & code review of the playback for that file
*/
private fun buildExternalTracks(
backend: PlayerBackend,
selectedIndex: Int,
): Tracks {
val formats =
if (backend == PlayerBackend.MPV) {
val video =
Format
.Builder()
.setId("1:1")
.setSampleMimeType("video/default")
.build()
val audios =
listOf(
Format
.Builder()
.setId("0:1")
.setSampleMimeType("audio/default")
.build(),
)
val subtitles =
listOf(
Format
.Builder()
.setId("2:1")
.setSampleMimeType("text/default")
.build(),
Format
.Builder()
.setId("3:e:2")
.setSampleMimeType("text/default")
.build(),
)
(listOf(video) + audios + subtitles)
} else {
val video =
Format
.Builder()
.setId("0:2")
.setSampleMimeType("video/default")
.build()
val audios =
listOf(
Format
.Builder()
.setId("0:1")
.setSampleMimeType("audio/default")
.build(),
)
val subtitles =
listOf(
Format
.Builder()
.setId("0:3") // Embedded
.setSampleMimeType("text/default")
.build(),
Format
.Builder()
.setId("1:e:$selectedIndex") // External
.setSampleMimeType("text/default")
.build(),
)
(listOf(video) + audios + subtitles)
}
val groups =
formats
.map { TrackGroup(it) }
.map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) }
return Tracks(groups)
}
private fun TrackSelectionParameters.getAudioOverride(): Format? {
this.overrides.forEach { (trackGroup, trackSelectionOverride) ->
if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
return trackGroup.getFormat(trackSelectionOverride.trackIndices.first())
}
}
return null
}
private fun TrackSelectionParameters.getSubtitleOverride(): Format? {
this.overrides.forEach { (trackGroup, trackSelectionOverride) ->
if (trackGroup.type == C.TRACK_TYPE_TEXT) {
return trackGroup.getFormat(trackSelectionOverride.trackIndices.first())
}
}
return null
}
@Test
fun `test MPV embedded`() {
val resource = javaClass.classLoader?.getResource("embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildEmbeddedTracks(PlayerBackend.MPV)
Assert.assertEquals(7, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = 4,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 4,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test ExoPlayer embedded`() {
val resource = javaClass.classLoader?.getResource("embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildEmbeddedTracks(PlayerBackend.EXO_PLAYER)
Assert.assertEquals(7, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = 4,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 4,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 6,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("7", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test MPV no embedded`() {
val resource = javaClass.classLoader?.getResource("no_embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildNoEmbeddedTracks(PlayerBackend.MPV)
Assert.assertEquals(5, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 0,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test ExoPlayer no embedded`() {
val resource = javaClass.classLoader?.getResource("no_embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildNoEmbeddedTracks(PlayerBackend.EXO_PLAYER)
Assert.assertEquals(5, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 0,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test MPV external`() {
val resource = javaClass.classLoader?.getResource("external_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildExternalTracks(PlayerBackend.MPV, 0)
Assert.assertEquals(6, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
).also { result ->
Assert.assertTrue(result.audioSelected)
Assert.assertTrue(result.subtitleSelected)
Assert.assertEquals("0:1", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("3:e:2", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
// Select embedded subtitles
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 5,
source = source,
).also { result ->
Assert.assertTrue(result.audioSelected)
Assert.assertTrue(result.subtitleSelected)
Assert.assertEquals(
"0:1",
result.trackSelectionParameters.getAudioOverride()?.id,
)
Assert.assertEquals(
"2:1",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
}
@Test
fun `test ExoPlayer external`() {
val resource = javaClass.classLoader?.getResource("external_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
buildExternalTracks(PlayerBackend.EXO_PLAYER, 0).also { tracks ->
Assert.assertEquals(6, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
).also { result ->
Assert.assertTrue(result.audioSelected)
Assert.assertTrue(result.subtitleSelected)
Assert.assertEquals(
"0:1",
result.trackSelectionParameters.getAudioOverride()?.id,
)
Assert.assertEquals(
"1:e:0",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
// Select embedded subtitles
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 5,
source = source,
).also { result ->
Assert.assertTrue(result.audioSelected)
Assert.assertTrue(result.subtitleSelected)
Assert.assertEquals(
"0:1",
result.trackSelectionParameters.getAudioOverride()?.id,
)
Assert.assertEquals(
"0:3",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
}
buildExternalTracks(PlayerBackend.EXO_PLAYER, 2).also { tracks ->
Assert.assertEquals(6, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 2,
source = source,
).also { result ->
Assert.assertTrue(result.audioSelected)
Assert.assertTrue(result.subtitleSelected)
Assert.assertEquals(
"0:1",
result.trackSelectionParameters.getAudioOverride()?.id,
)
Assert.assertEquals(
"1:e:2",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
}
}
}

View file

@ -0,0 +1,235 @@
{
"Protocol": "File",
"Id": "",
"Path": "",
"Type": "Default",
"Container": "mkv",
"Size": 651217902,
"Name": "",
"IsRemote": false,
"ETag": "",
"RunTimeTicks": 14919360000,
"ReadAtNativeFramerate": false,
"IgnoreDts": false,
"IgnoreIndex": false,
"GenPtsInput": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"IsInfiniteStream": false,
"UseMostCompatibleTranscodingProfile": false,
"RequiresOpening": false,
"RequiresClosing": false,
"RequiresLooping": false,
"SupportsProbing": true,
"VideoType": "VideoFile",
"MediaStreams": [
{
"Codec": "hevc",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "SDR",
"VideoRangeType": "SDR",
"AudioSpatialFormat": "None",
"DisplayTitle": "1080p - HEVC - SDR",
"IsInterlaced": false,
"IsAVC": false,
"BitRate": 3491934,
"BitDepth": 10,
"RefFrames": 1,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 1080,
"Width": 1448,
"AverageFrameRate": 23.809525,
"RealFrameRate": 23.809525,
"ReferenceFrameRate": 23.809525,
"Profile": "Main 10",
"Type": "Video",
"AspectRatio": "4:3",
"Index": 0,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"PixelFormat": "yuv420p10le",
"Level": 120,
"IsAnamorphic": false
},
{
"Codec": "opus",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "JAP Stereo (Opus 112Kbps)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "JAP Stereo (Opus 112Kbps) - Japanese",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 101618,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 1,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "aac",
"Language": "por",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Portuguese - AAC - Stereo",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 249225,
"Channels": 2,
"SampleRate": 44100,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Profile": "LC",
"Type": "Audio",
"Index": 2,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "eac3",
"Language": "por",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Portuguese - Dolby Digital+ - 5.1",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "5.1",
"BitRate": 640000,
"Channels": 6,
"SampleRate": 48000,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 3,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "ass",
"Language": "por",
"TimeBase": "1/1000",
"Title": "ptBR",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "ptBR - Portuguese - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 4,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "por",
"TimeBase": "1/1000",
"Title": "ptBR FORCED",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "ptBR FORCED - Portuguese - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": true,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 5,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "enUS",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "enUS - English - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 6,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
}
],
"MediaAttachments": [],
"Formats": [],
"Bitrate": 4482777,
"RequiredHttpHeaders": {},
"TranscodingSubProtocol": "http",
"DefaultAudioStreamIndex": 1,
"DefaultSubtitleStreamIndex": 6,
"HasSegments": true
}

View file

@ -0,0 +1,208 @@
{
"Protocol": "File",
"Id": "",
"Path": "",
"Type": "Default",
"Container": "mkv",
"Size": 2147179830,
"Name": "",
"IsRemote": false,
"ETag": "",
"RunTimeTicks": 12793200000,
"ReadAtNativeFramerate": false,
"IgnoreDts": false,
"IgnoreIndex": false,
"GenPtsInput": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"IsInfiniteStream": false,
"UseMostCompatibleTranscodingProfile": false,
"RequiresOpening": false,
"RequiresClosing": false,
"RequiresLooping": false,
"SupportsProbing": true,
"VideoType": "VideoFile",
"MediaStreams": [
{
"Codec": "subrip",
"Language": "eng",
"TimeBase": "1/1000",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English - SUBRIP - External",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 0,
"IsExternal": true,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Path": "",
"Level": 0
},
{
"Codec": "subrip",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "0",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "0 - English - SUBRIP - External",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 1,
"IsExternal": true,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Path": "",
"Level": 0
},
{
"Codec": "subrip",
"Language": "eng",
"TimeBase": "1/1000",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English - SUBRIP - External",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 2,
"IsExternal": true,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Path": "",
"Level": 0
},
{
"Codec": "eac3",
"Language": "eng",
"TimeBase": "1/1000",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "English - Dolby Digital+ - 5.1 - Default",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "5.1",
"BitRate": 640000,
"Channels": 6,
"SampleRate": 48000,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 3,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "h264",
"ColorSpace": "bt709",
"ColorTransfer": "bt709",
"ColorPrimaries": "bt709",
"TimeBase": "1/1000",
"VideoRange": "SDR",
"VideoRangeType": "SDR",
"AudioSpatialFormat": "None",
"DisplayTitle": "1080p H264 SDR",
"NalLengthSize": "4",
"IsInterlaced": false,
"IsAVC": true,
"BitRate": 13427007,
"BitDepth": 8,
"RefFrames": 1,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 1080,
"Width": 1920,
"AverageFrameRate": 23.976025,
"RealFrameRate": 23.976025,
"ReferenceFrameRate": 23.976025,
"Profile": "High",
"Type": "Video",
"AspectRatio": "16:9",
"Index": 4,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"PixelFormat": "yuv420p",
"Level": 40,
"IsAnamorphic": false
},
{
"Codec": "subrip",
"TimeBase": "1/1000",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Undefined - Default - SUBRIP",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 5,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
}
],
"MediaAttachments": [],
"Formats": [],
"Bitrate": 14067007,
"RequiredHttpHeaders": {},
"TranscodingSubProtocol": "http",
"DefaultAudioStreamIndex": 3,
"DefaultSubtitleStreamIndex": 0,
"HasSegments": true
}

View file

@ -0,0 +1,178 @@
{
"Protocol": "File",
"Id": "",
"Path": "",
"Type": "Default",
"Container": "mkv",
"Size": 651217902,
"Name": "",
"IsRemote": false,
"ETag": "",
"RunTimeTicks": 14919360000,
"ReadAtNativeFramerate": false,
"IgnoreDts": false,
"IgnoreIndex": false,
"GenPtsInput": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"IsInfiniteStream": false,
"UseMostCompatibleTranscodingProfile": false,
"RequiresOpening": false,
"RequiresClosing": false,
"RequiresLooping": false,
"SupportsProbing": true,
"VideoType": "VideoFile",
"MediaStreams": [
{
"Codec": "subrip",
"Language": "spa",
"TimeBase": "1/1000",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Spanish - SUBRIP - External",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 0,
"IsExternal": true,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Path": "",
"Level": 0
},
{
"Codec": "hevc",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "SDR",
"VideoRangeType": "SDR",
"AudioSpatialFormat": "None",
"DisplayTitle": "1080p - HEVC - SDR",
"IsInterlaced": false,
"IsAVC": false,
"BitRate": 3491934,
"BitDepth": 10,
"RefFrames": 1,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 1080,
"Width": 1448,
"AverageFrameRate": 23.809525,
"RealFrameRate": 23.809525,
"ReferenceFrameRate": 23.809525,
"Profile": "Main 10",
"Type": "Video",
"AspectRatio": "4:3",
"Index": 1,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"PixelFormat": "yuv420p10le",
"Level": 120,
"IsAnamorphic": false
},
{
"Codec": "opus",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "Stereo (Opus 112Kbps)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Stereo (Opus 112Kbps) - Japanese",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 101618,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 2,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "aac",
"Language": "por",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Portuguese - AAC - Stereo",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 249225,
"Channels": 2,
"SampleRate": 44100,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Profile": "LC",
"Type": "Audio",
"Index": 3,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "eac3",
"Language": "por",
"TimeBase": "1/1000",
"Title": "",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Portuguese - Dolby Digital+ - 5.1",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "5.1",
"BitRate": 640000,
"Channels": 6,
"SampleRate": 48000,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 4,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
}
],
"MediaAttachments": [],
"Formats": [],
"Bitrate": 4482777,
"RequiredHttpHeaders": {},
"TranscodingSubProtocol": "http",
"DefaultAudioStreamIndex": 2,
"HasSegments": true
}

View file

@ -90,6 +90,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" } androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }