mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Merge branch 'main' into develop/jellyseerr
This commit is contained in:
commit
d563ebabe0
51 changed files with 2486 additions and 438 deletions
|
|
@ -230,6 +230,7 @@ dependencies {
|
|||
implementation(libs.androidx.hilt.work)
|
||||
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.ui)
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ class ItemPlaybackRepository
|
|||
Timber.v("Saving track selection %s", toSave)
|
||||
toSave = saveItemPlayback(toSave)
|
||||
val seriesId = item.data.seriesId
|
||||
if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) {
|
||||
if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
|
|
@ -147,7 +147,7 @@ class ItemPlaybackRepository
|
|||
subtitlesDisabled = true,
|
||||
)
|
||||
} else {
|
||||
val stream = source.mediaStreams?.first { it.index == trackIndex }
|
||||
val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
|
||||
if (stream?.language != null) {
|
||||
streamChoiceService.updateSubtitles(
|
||||
item.data,
|
||||
|
|
|
|||
|
|
@ -54,4 +54,9 @@ object TrackIndex {
|
|||
* The user has explicitly disabled the tracks (eg turned off subtitles)
|
||||
*/
|
||||
const val DISABLED = -2
|
||||
|
||||
/**
|
||||
* The user wants only forced subtitles (for foreign language dialogue, signs, etc.)
|
||||
*/
|
||||
const val ONLY_FORCED = -3
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
audioStreamLang: String?,
|
||||
candidates: List<MediaStream>,
|
||||
|
|
@ -171,6 +205,14 @@ class StreamChoiceService
|
|||
): MediaStream? {
|
||||
if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
|
||||
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) {
|
||||
return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
|
||||
} 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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ val DefaultItemFields =
|
|||
ItemFields.SORT_NAME,
|
||||
ItemFields.CHAPTERS,
|
||||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -68,6 +69,7 @@ val SlimItemFields =
|
|||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.OVERVIEW,
|
||||
ItemFields.SORT_NAME,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
)
|
||||
|
||||
object Cards {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ fun DiscoverItemCard(
|
|||
watched = false,
|
||||
unwatchedCount = 0,
|
||||
watchedPercent = null,
|
||||
numberOfVersions = -1,
|
||||
useFallbackText = false,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ fun EpisodeCard(
|
|||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ fun ExtrasRow(
|
|||
isPlayed = false,
|
||||
unplayedItemCount = -1,
|
||||
playedPercentage = -1.0,
|
||||
numberOfVersions = -1,
|
||||
aspectRatio = AspectRatios.FOUR_THREE, // TODO
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ fun GridCard(
|
|||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
numberOfVersions = dto?.mediaSourceCount ?: 0,
|
||||
useFallbackText = false,
|
||||
contentScale = imageContentScale,
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fun ItemCardImage(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
useFallbackText: Boolean = true,
|
||||
|
|
@ -86,6 +87,7 @@ fun ItemCardImage(
|
|||
watched = watched,
|
||||
unwatchedCount = unwatchedCount,
|
||||
watchedPercent = watchedPercent,
|
||||
numberOfVersions = numberOfVersions,
|
||||
modifier =
|
||||
modifier.onLayoutRectChanged(
|
||||
throttleMillis = 100,
|
||||
|
|
@ -107,6 +109,7 @@ fun ItemCardImage(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
useFallbackText: Boolean = true,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
|
|
@ -143,6 +146,7 @@ fun ItemCardImage(
|
|||
watched = watched,
|
||||
unwatchedCount = unwatchedCount,
|
||||
watchedPercent = watchedPercent,
|
||||
numberOfVersions = numberOfVersions,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -198,20 +202,45 @@ fun ItemCardImageOverlay(
|
|||
watched: Boolean,
|
||||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (favorite) {
|
||||
Text(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp),
|
||||
color = colorResource(android.R.color.holo_red_light),
|
||||
text = stringResource(R.string.fa_heart),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
if (numberOfVersions > 1) {
|
||||
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(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ fun PersonCard(
|
|||
favorite = item.favorite,
|
||||
watched = false,
|
||||
unwatchedCount = -1,
|
||||
numberOfVersions = -1,
|
||||
watchedPercent = null,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ fun SeasonCard(
|
|||
isPlayed = item?.data?.userData?.played ?: false,
|
||||
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
|
||||
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
|
||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
|
|
@ -112,6 +113,7 @@ fun SeasonCard(
|
|||
isPlayed: Boolean,
|
||||
unplayedItemCount: Int,
|
||||
playedPercentage: Double,
|
||||
numberOfVersions: Int,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -172,6 +174,7 @@ fun SeasonCard(
|
|||
watched = isPlayed,
|
||||
unwatchedCount = unplayedItemCount,
|
||||
watchedPercent = playedPercentage,
|
||||
numberOfVersions = numberOfVersions,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -84,6 +85,7 @@ data class DialogItem(
|
|||
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
|
||||
val trailingContent: @Composable (() -> Unit)? = null,
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
) : DialogItemEntry {
|
||||
constructor(
|
||||
@StringRes text: Int,
|
||||
|
|
@ -266,7 +268,7 @@ fun DialogPopupContent(
|
|||
|
||||
is DialogItem -> {
|
||||
ListItem(
|
||||
selected = false,
|
||||
selected = it.selected,
|
||||
enabled = !waiting && it.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
|
|
@ -502,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int =
|
|||
fun chooseStream(
|
||||
context: Context,
|
||||
streams: List<MediaStream>,
|
||||
currentIndex: Int?,
|
||||
type: MediaStreamType,
|
||||
onClick: (Int) -> Unit,
|
||||
): DialogParams =
|
||||
|
|
@ -513,6 +516,10 @@ fun chooseStream(
|
|||
if (type == MediaStreamType.SUBTITLE) {
|
||||
add(
|
||||
DialogItem(
|
||||
selected = currentIndex == null,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == null)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.none))
|
||||
},
|
||||
|
|
@ -521,15 +528,30 @@ fun chooseStream(
|
|||
onClick = { onClick.invoke(TrackIndex.DISABLED) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
DialogItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||
},
|
||||
supportingContent = {
|
||||
},
|
||||
onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) },
|
||||
),
|
||||
)
|
||||
}
|
||||
addAll(
|
||||
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
||||
val title = stream.displayTitle ?: stream.title ?: ""
|
||||
val simpleStream = SimpleMediaStream.from(context, stream, true)
|
||||
DialogItem(
|
||||
selected = currentIndex == stream.index,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == stream.index)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = title)
|
||||
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
|
||||
},
|
||||
supportingContent = {
|
||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||
},
|
||||
onClick = { onClick.invoke(stream.index) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
|
||||
@Composable
|
||||
fun BoxScope.SelectedLeadingContent(
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (selected) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Center)
|
||||
.background(LocalContentColor.current)
|
||||
.size(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.ui.formatDateTime
|
||||
|
|
@ -42,7 +43,8 @@ fun EpisodeName(
|
|||
text = episodeName ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 2,
|
||||
fontSize = 20.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -31,28 +30,31 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.audioStreamCount
|
||||
import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount
|
||||
import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun VideoStreamDetails(
|
||||
chosenStreams: ChosenStreams?,
|
||||
numberOfVersions: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) = VideoStreamDetails(
|
||||
chosenStreams?.source,
|
||||
chosenStreams?.videoStream,
|
||||
chosenStreams?.audioStream,
|
||||
chosenStreams?.subtitleStream,
|
||||
numberOfVersions,
|
||||
modifier,
|
||||
)
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ fun VideoStreamDetails(
|
|||
videoStream: MediaStream?,
|
||||
audioStream: MediaStream?,
|
||||
subtitleStream: MediaStream?,
|
||||
numberOfVersions: Int = 0,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -86,13 +89,16 @@ fun VideoStreamDetails(
|
|||
null
|
||||
}
|
||||
val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle)
|
||||
listOfNotNull(
|
||||
resName.concatWithSpace(range),
|
||||
it.codec?.uppercase(),
|
||||
)
|
||||
}.orEmpty()
|
||||
resName.concatWithSpace(range)
|
||||
}
|
||||
}
|
||||
video.forEach {
|
||||
video?.let {
|
||||
StreamLabel(
|
||||
text = it,
|
||||
count = numberOfVersions,
|
||||
)
|
||||
}
|
||||
videoStream?.codec?.uppercase()?.let {
|
||||
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
|
||||
fun StreamLabel(
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ import androidx.tv.material3.Text
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.byteRateSuffixes
|
||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||
import com.github.damontecres.wholphin.ui.components.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.ui.formatBytes
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
|
@ -391,7 +391,7 @@ private fun buildAudioStreamInfo(
|
|||
val bitrateLabel = context.getString(R.string.bitrate)
|
||||
val sampleRateLabel = context.getString(R.string.sample_rate)
|
||||
val defaultLabel = context.getString(R.string.default_track)
|
||||
val externalLabel = context.getString(R.string.external_track)
|
||||
val profileLabel = context.getString(R.string.profile)
|
||||
val yesStr = context.getString(R.string.yes)
|
||||
val noStr = context.getString(R.string.no)
|
||||
val sampleRateUnit = context.getString(R.string.sample_rate_unit)
|
||||
|
|
@ -399,11 +399,12 @@ private fun buildAudioStreamInfo(
|
|||
stream.title?.let { add(titleLabel to it) }
|
||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||
stream.codec?.let {
|
||||
val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase()
|
||||
val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)"
|
||||
add(codecLabel to formattedCodec)
|
||||
}
|
||||
stream.channelLayout?.let { add(layoutLabel to it) }
|
||||
stream.channels?.let { add(channelsLabel to it.toString()) }
|
||||
stream.profile?.let { add(profileLabel to it) }
|
||||
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) }
|
||||
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
|
||||
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||
|
|
@ -428,7 +429,7 @@ private fun buildSubtitleStreamInfo(
|
|||
stream.title?.let { add(titleLabel to it) }
|
||||
stream.language?.let { add(languageLabel to languageName(it)) }
|
||||
stream.codec?.let {
|
||||
val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase()
|
||||
val formattedCodec = formatSubtitleCodec(it) + " ($it)"
|
||||
add(codecLabel to formattedCodec)
|
||||
}
|
||||
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
|
||||
|
|
@ -438,7 +439,7 @@ private fun buildSubtitleStreamInfo(
|
|||
add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr)
|
||||
}
|
||||
if (showPath) {
|
||||
stream.path?.let { pathLabel to it }
|
||||
stream.path?.let { add(pathLabel to it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ fun PersonPageContent(
|
|||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
showIfEmpty = false,
|
||||
horizontalPadding = 32.dp,
|
||||
horizontalPadding = 24.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cardContent = { index, item, mod, onClick, onLongClick ->
|
||||
SeasonCard(
|
||||
|
|
|
|||
|
|
@ -444,6 +444,7 @@ fun PlaylistItem(
|
|||
watched = item?.data?.userData?.played ?: false,
|
||||
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = 0.0,
|
||||
numberOfVersions = item?.data?.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.width(160.dp),
|
||||
useFallbackText = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -442,6 +442,7 @@ fun TrailerRow(
|
|||
isPlayed = false,
|
||||
unplayedItemCount = 0,
|
||||
playedPercentage = 0.0,
|
||||
numberOfVersions = -1,
|
||||
onClick = { onClickTrailer.invoke(item) },
|
||||
onLongClick = {},
|
||||
modifier = cardModifier,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
|
|
@ -180,6 +181,12 @@ fun EpisodeDetails(
|
|||
chooseStream(
|
||||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
type = type,
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ fun EpisodeDetailsHeader(
|
|||
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.padding(bottom = padding),
|
||||
)
|
||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
|
|
@ -215,6 +216,7 @@ fun MovieDetails(
|
|||
moreDialog = null
|
||||
},
|
||||
onChooseTracks = { type ->
|
||||
|
||||
viewModel.streamChoiceService
|
||||
.chooseSource(
|
||||
movie.data,
|
||||
|
|
@ -225,6 +227,12 @@ fun MovieDetails(
|
|||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
movie,
|
||||
|
|
@ -510,6 +518,14 @@ fun MovieDetailsContent(
|
|||
}
|
||||
if (similar.isNotEmpty()) {
|
||||
item {
|
||||
val imageHeight =
|
||||
remember(movie.type) {
|
||||
if (movie.type == BaseItemKind.MOVIE) {
|
||||
Cards.height2x3
|
||||
} else {
|
||||
Cards.heightEpisode
|
||||
}
|
||||
}
|
||||
ItemRow(
|
||||
title = stringResource(R.string.more_like_this),
|
||||
items = similar,
|
||||
|
|
@ -528,7 +544,7 @@ fun MovieDetailsContent(
|
|||
onLongClick = onLongClick,
|
||||
modifier = mod,
|
||||
showImageOverlay = true,
|
||||
imageHeight = Cards.height2x3,
|
||||
imageHeight = imageHeight,
|
||||
imageWidth = Dp.Unspecified,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fun MovieDetailsHeader(
|
|||
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = movie.data.mediaSourceCount ?: 0,
|
||||
modifier = Modifier.padding(bottom = padding),
|
||||
)
|
||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ fun FocusedEpisodeHeader(
|
|||
if (dto != null) {
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.PersonKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
|
@ -242,6 +243,12 @@ fun SeriesOverview(
|
|||
context = context,
|
||||
streams = source.mediaStreams.orEmpty(),
|
||||
type = type,
|
||||
currentIndex =
|
||||
if (type == MediaStreamType.AUDIO) {
|
||||
chosenStreams?.audioStream?.index
|
||||
} else {
|
||||
chosenStreams?.subtitleStream?.index
|
||||
},
|
||||
onClick = { trackIndex ->
|
||||
viewModel.saveTrackSelection(
|
||||
ep,
|
||||
|
|
|
|||
|
|
@ -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.DialogParams
|
||||
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.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
|
|
@ -259,6 +260,9 @@ fun HomePageContent(
|
|||
LaunchedEffect(position) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
}
|
||||
LaunchedEffect(onUpdateBackdrop, focusedItem) {
|
||||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomePageHeader(
|
||||
|
|
@ -379,7 +383,7 @@ fun HomePageContent(
|
|||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
position = RowColumn(rowIndex, index)
|
||||
item?.let(onUpdateBackdrop)
|
||||
// item?.let(onUpdateBackdrop)
|
||||
}
|
||||
if (it.isFocused && onFocusPosition != null) {
|
||||
val nonEmptyRowBefore =
|
||||
|
|
@ -442,7 +446,6 @@ fun HomePageHeader(
|
|||
item?.let {
|
||||
val isEpisode = item.type == BaseItemKind.EPISODE
|
||||
val dto = item.data
|
||||
|
||||
HomePageHeader(
|
||||
title = item.title,
|
||||
subtitle = if (isEpisode) dto.name else null,
|
||||
|
|
@ -490,17 +493,8 @@ fun HomePageHeader(
|
|||
.fillMaxWidth(.6f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
// val isEpisode = item.type == BaseItemKind.EPISODE
|
||||
// val subtitle = if (isEpisode) dto.name else null
|
||||
// val overview = dto.overview
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
EpisodeName(it)
|
||||
}
|
||||
quickDetails?.invoke()
|
||||
val overviewModifier =
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
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
|
||||
class ApplicationContentViewModel
|
||||
@Inject
|
||||
|
|
@ -73,6 +77,7 @@ fun ApplicationContent(
|
|||
navigationManager: NavigationManager,
|
||||
preferences: UserPreferences,
|
||||
modifier: Modifier = Modifier,
|
||||
enableTopScrim: Boolean = true,
|
||||
viewModel: ApplicationContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
val backStack: MutableList<NavKey> =
|
||||
|
|
@ -179,6 +184,20 @@ fun ApplicationContent(
|
|||
.alpha(.95f)
|
||||
.drawWithContent {
|
||||
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(
|
||||
brush =
|
||||
Brush.horizontalGradient(
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
|||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||
|
||||
data class CurrentMediaInfo(
|
||||
val audioStreams: List<AudioStream>,
|
||||
val subtitleStreams: List<SubtitleStream>,
|
||||
val audioStreams: List<SimpleMediaStream>,
|
||||
val subtitleStreams: List<SimpleMediaStream>,
|
||||
val chapters: List<Chapter>,
|
||||
val trickPlayInfo: TrickplayInfo?,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
||||
data class SubtitleStream(
|
||||
val index: Int,
|
||||
val language: String?,
|
||||
val title: String?,
|
||||
val codec: String?,
|
||||
val codecTag: String?,
|
||||
val external: Boolean,
|
||||
val forced: Boolean,
|
||||
val default: Boolean,
|
||||
val displayTitle: String?,
|
||||
) {
|
||||
val displayName: String
|
||||
get() =
|
||||
displayTitle ?: listOfNotNull(
|
||||
language,
|
||||
title,
|
||||
codec,
|
||||
).joinToString(" - ")
|
||||
.ifBlank { WholphinApplication.instance.getString(R.string.unknown) }
|
||||
|
||||
companion object {
|
||||
fun from(it: MediaStream): SubtitleStream =
|
||||
SubtitleStream(
|
||||
it.index,
|
||||
it.language,
|
||||
it.title,
|
||||
it.codec,
|
||||
it.codecTag,
|
||||
it.isExternal,
|
||||
it.isForced,
|
||||
it.isDefault,
|
||||
it.displayTitle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioStream(
|
||||
val index: Int,
|
||||
val language: String?,
|
||||
val title: String?,
|
||||
val codec: String?,
|
||||
val codecTag: String?,
|
||||
val channels: Int?,
|
||||
val channelLayout: String?,
|
||||
) {
|
||||
val displayName: String
|
||||
get() =
|
||||
listOfNotNull(
|
||||
language,
|
||||
title,
|
||||
codec,
|
||||
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
|
||||
).joinToString(" - ").ifBlank { "Unknown" }
|
||||
|
||||
companion object {
|
||||
fun from(it: MediaStream): AudioStream =
|
||||
AudioStream(
|
||||
it.index,
|
||||
it.language,
|
||||
it.title,
|
||||
it.codec,
|
||||
it.codecTag,
|
||||
it.channels,
|
||||
it.channelLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem
|
|||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
|
|
@ -462,12 +463,12 @@ fun PlaybackButton(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun BottomDialog(
|
||||
choices: List<String>,
|
||||
fun <T> BottomDialog(
|
||||
choices: List<BottomDialogItem<T>>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelectChoice: (Int, String) -> Unit,
|
||||
onSelectChoice: (Int, BottomDialogItem<T>) -> Unit,
|
||||
gravity: Int,
|
||||
currentChoice: Int? = null,
|
||||
currentChoice: BottomDialogItem<T>? = null,
|
||||
) {
|
||||
// TODO enforcing a width ends up ignore the gravity
|
||||
Dialog(
|
||||
|
|
@ -485,7 +486,10 @@ fun BottomDialog(
|
|||
Modifier
|
||||
.wrapContentSize()
|
||||
.padding(8.dp)
|
||||
.background(Color.DarkGray, shape = RoundedCornerShape(16.dp)),
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
|
|
@ -498,38 +502,27 @@ fun BottomDialog(
|
|||
) {
|
||||
itemsIndexed(choices) { index, choice ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val color =
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.inverseOnSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
ListItem(
|
||||
selected = index == currentChoice,
|
||||
selected = choice == currentChoice,
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onSelectChoice(index, choice)
|
||||
},
|
||||
leadingContent = {
|
||||
if (index == currentChoice) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Center)
|
||||
.background(color)
|
||||
.size(8.dp),
|
||||
)
|
||||
}
|
||||
SelectedLeadingContent(choice == currentChoice)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = choice,
|
||||
color = color,
|
||||
text = choice.headline,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
choice.supporting?.let {
|
||||
Text(
|
||||
text = it,
|
||||
)
|
||||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
|
@ -542,6 +535,12 @@ data class MoreButtonOptions(
|
|||
val options: Map<String, PlaybackAction>,
|
||||
)
|
||||
|
||||
data class BottomDialogItem<T>(
|
||||
val data: T,
|
||||
val headline: String,
|
||||
val supporting: String?,
|
||||
)
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun ButtonPreview() {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback
|
|||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
|
@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import timber.log.Timber
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import kotlin.time.Duration
|
||||
|
||||
enum class PlaybackDialogType {
|
||||
|
|
@ -35,12 +47,13 @@ enum class PlaybackDialogType {
|
|||
data class PlaybackSettings(
|
||||
val showDebugInfo: Boolean,
|
||||
val audioIndex: Int?,
|
||||
val audioStreams: List<AudioStream>,
|
||||
val audioStreams: List<SimpleMediaStream>,
|
||||
val subtitleIndex: Int?,
|
||||
val subtitleStreams: List<SubtitleStream>,
|
||||
val subtitleStreams: List<SimpleMediaStream>,
|
||||
val playbackSpeed: Float,
|
||||
val contentScale: ContentScale,
|
||||
val subtitleDelay: Duration,
|
||||
val hasSubtitleDownloadPermission: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -59,7 +72,13 @@ fun PlaybackDialog(
|
|||
PlaybackDialogType.MORE -> {
|
||||
val options =
|
||||
buildList {
|
||||
add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info))
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = 0,
|
||||
headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info),
|
||||
supporting = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
|
|
@ -75,76 +94,85 @@ fun PlaybackDialog(
|
|||
}
|
||||
|
||||
PlaybackDialogType.CAPTIONS -> {
|
||||
val subtitleStreams = settings.subtitleStreams
|
||||
val options = subtitleStreams.map { it.displayName }
|
||||
Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options")
|
||||
val currentChoice =
|
||||
subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size
|
||||
BottomDialog(
|
||||
choices =
|
||||
options +
|
||||
listOf(
|
||||
stringResource(R.string.none),
|
||||
stringResource(R.string.search_and_download),
|
||||
),
|
||||
currentChoice = currentChoice,
|
||||
SubtitleChoiceBottomDialog(
|
||||
choices = settings.subtitleStreams,
|
||||
currentChoice = settings.subtitleIndex,
|
||||
hasDownloadPermission = settings.hasSubtitleDownloadPermission,
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
// scope.launch {
|
||||
// // TODO this is hacky, but playback changes force refocus and this is a workaround
|
||||
// delay(250L)
|
||||
// captionFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { index, _ ->
|
||||
if (index in subtitleStreams.indices) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index))
|
||||
} else {
|
||||
val idx = index - subtitleStreams.size
|
||||
if (idx == 0) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
|
||||
} else {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
|
||||
}
|
||||
onSelectChoice = { subtitleIndex ->
|
||||
onDismissRequest.invoke()
|
||||
if (subtitleIndex >= 0) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex))
|
||||
} else if (subtitleIndex == TrackIndex.DISABLED) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED))
|
||||
} else if (subtitleIndex == TrackIndex.ONLY_FORCED) {
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED))
|
||||
}
|
||||
},
|
||||
onSelectSearch = {
|
||||
onDismissRequest.invoke()
|
||||
onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions)
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.SETTINGS -> {
|
||||
val currentAudio =
|
||||
remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } }
|
||||
val options =
|
||||
buildList {
|
||||
add(stringResource(R.string.audio))
|
||||
add(stringResource(R.string.playback_speed))
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.AUDIO,
|
||||
headline = stringResource(R.string.audio),
|
||||
supporting = currentAudio?.displayTitle,
|
||||
),
|
||||
)
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.PLAYBACK_SPEED,
|
||||
headline = stringResource(R.string.playback_speed),
|
||||
supporting = settings.playbackSpeed.toString(),
|
||||
),
|
||||
)
|
||||
if (enableVideoScale) {
|
||||
add(stringResource(R.string.video_scale))
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.VIDEO_SCALE,
|
||||
headline = stringResource(R.string.video_scale),
|
||||
supporting = playbackScaleOptions[settings.contentScale],
|
||||
),
|
||||
)
|
||||
}
|
||||
if (enableSubtitleDelay) {
|
||||
add(stringResource(R.string.subtitle_delay))
|
||||
add(
|
||||
BottomDialogItem(
|
||||
data = PlaybackDialogType.SUBTITLE_DELAY,
|
||||
headline = stringResource(R.string.subtitle_delay),
|
||||
supporting = settings.subtitleDelay.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
currentChoice = null,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onSelectChoice = { index, _ ->
|
||||
when (index) {
|
||||
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
|
||||
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
|
||||
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
|
||||
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
|
||||
}
|
||||
onSelectChoice = { _, choice ->
|
||||
onClickPlaybackDialogType(choice.data)
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.AUDIO -> {
|
||||
BottomDialog(
|
||||
choices = settings.audioStreams.map { it.displayName },
|
||||
currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex },
|
||||
StreamChoiceBottomDialog(
|
||||
choices = settings.audioStreams,
|
||||
currentChoice = settings.audioIndex,
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
|
|
@ -153,17 +181,25 @@ fun PlaybackDialog(
|
|||
// settingsFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { index, _ ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index))
|
||||
onSelectChoice = { _, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.PLAYBACK_SPEED -> {
|
||||
val choices =
|
||||
playbackSpeedOptions.map {
|
||||
BottomDialogItem(
|
||||
data = it.toFloat(),
|
||||
headline = it,
|
||||
supporting = null,
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = playbackSpeedOptions,
|
||||
currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()),
|
||||
choices = choices,
|
||||
currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed },
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
|
|
@ -173,16 +209,24 @@ fun PlaybackDialog(
|
|||
// }
|
||||
},
|
||||
onSelectChoice = { _, value ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
|
||||
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.VIDEO_SCALE -> {
|
||||
val choices =
|
||||
playbackScaleOptions.map { (scale, name) ->
|
||||
BottomDialogItem(
|
||||
data = scale,
|
||||
headline = name,
|
||||
supporting = null,
|
||||
)
|
||||
}
|
||||
BottomDialog(
|
||||
choices = playbackScaleOptions.values.toList(),
|
||||
currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale),
|
||||
choices = choices,
|
||||
currentChoice = choices.firstOrNull { it.data == settings.contentScale },
|
||||
onDismissRequest = {
|
||||
onControllerInteraction.invoke()
|
||||
onDismissRequest.invoke()
|
||||
|
|
@ -191,8 +235,8 @@ fun PlaybackDialog(
|
|||
// settingsFocusRequester.tryRequestFocus()
|
||||
// }
|
||||
},
|
||||
onSelectChoice = { index, _ ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
|
||||
onSelectChoice = { _, choice ->
|
||||
onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data))
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
)
|
||||
|
|
@ -225,3 +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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,27 +61,8 @@ class PlaybackKeyHandler(
|
|||
}
|
||||
} else if (isMedia(it)) {
|
||||
when (it.key) {
|
||||
Key.MediaPlay -> {
|
||||
Util.handlePlayButtonAction(player)
|
||||
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.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> {
|
||||
// no-op, MediaSession will handle
|
||||
}
|
||||
|
||||
Key.MediaFastForward, Key.MediaSkipForward -> {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ fun PlaybackPage(
|
|||
|
||||
val player = viewModel.player
|
||||
val mediaInfo by viewModel.currentMediaInfo.observeAsState()
|
||||
val userDto by viewModel.currentUserDto.observeAsState()
|
||||
|
||||
val currentPlayback by viewModel.currentPlayback.collectAsState()
|
||||
val currentItemPlayback by viewModel.currentItemPlayback.observeAsState(
|
||||
|
|
@ -561,6 +562,8 @@ fun PlaybackPage(
|
|||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
hasSubtitleDownloadPermission =
|
||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.media3.exoplayer.DecoderCounters
|
|||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.session.MediaSession
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
|
|
@ -134,6 +135,7 @@ class PlaybackViewModel
|
|||
val player by lazy {
|
||||
playerFactory.createVideoPlayer()
|
||||
}
|
||||
private var mediaSession: MediaSession? = null
|
||||
internal val mutex = Mutex()
|
||||
|
||||
val controllerViewState =
|
||||
|
|
@ -166,6 +168,8 @@ class PlaybackViewModel
|
|||
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||
|
||||
val currentUserDto = serverRepository.currentUserDto
|
||||
|
||||
init {
|
||||
addCloseable {
|
||||
player.removeListener(this@PlaybackViewModel)
|
||||
|
|
@ -177,6 +181,7 @@ class PlaybackViewModel
|
|||
}
|
||||
jobs.forEach { it.cancel() }
|
||||
player.release()
|
||||
mediaSession?.release()
|
||||
}
|
||||
viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() }
|
||||
player.addListener(this)
|
||||
|
|
@ -271,6 +276,18 @@ class PlaybackViewModel
|
|||
} else {
|
||||
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 played =
|
||||
|
|
@ -312,12 +329,15 @@ class PlaybackViewModel
|
|||
withContext(Dispatchers.IO) {
|
||||
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
|
||||
autoSkippedSegments.clear()
|
||||
resetSegmentState()
|
||||
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) {
|
||||
showToast(
|
||||
context,
|
||||
|
|
@ -360,15 +380,18 @@ class PlaybackViewModel
|
|||
val subtitleStreams =
|
||||
mediaSource.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
?.map(SubtitleStream::from)
|
||||
.orEmpty()
|
||||
?.map {
|
||||
SimpleMediaStream.from(context, it, true)
|
||||
}.orEmpty()
|
||||
|
||||
val audioStreams =
|
||||
mediaSource.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.AUDIO }
|
||||
?.map(AudioStream::from)
|
||||
?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||
?.map {
|
||||
SimpleMediaStream.from(context, it, true)
|
||||
}
|
||||
// ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
|
||||
.orEmpty()
|
||||
|
||||
val audioStream =
|
||||
streamChoiceService
|
||||
.chooseAudioStream(
|
||||
|
|
@ -444,7 +467,7 @@ class PlaybackViewModel
|
|||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
listenForSegments()
|
||||
listenForSegments(item.id)
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
|
|
@ -478,8 +501,9 @@ class PlaybackViewModel
|
|||
if (externalSubtitle == null) {
|
||||
val result =
|
||||
withContext(Dispatchers.Main) {
|
||||
TrackSelectionUtils.applyTrackSelections(
|
||||
player,
|
||||
TrackSelectionUtils.createTrackSelections(
|
||||
onMain { player.trackSelectionParameters },
|
||||
onMain { player.currentTracks },
|
||||
playerBackend,
|
||||
true,
|
||||
audioIndex,
|
||||
|
|
@ -488,13 +512,20 @@ class PlaybackViewModel
|
|||
)
|
||||
}
|
||||
if (result.bothSelected) {
|
||||
onMain { player.trackSelectionParameters = result.trackSelectionParameters }
|
||||
// TODO lots of duplicate code in this block
|
||||
Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex")
|
||||
val itemPlayback =
|
||||
currentItemPlayback.copy(
|
||||
sourceId = source.id?.toUUIDOrNull(),
|
||||
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) {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -680,8 +711,9 @@ class PlaybackViewModel
|
|||
Timber.v("onTracksChanged: $tracks")
|
||||
if (tracks.groups.isNotEmpty()) {
|
||||
val result =
|
||||
TrackSelectionUtils.applyTrackSelections(
|
||||
player,
|
||||
TrackSelectionUtils.createTrackSelections(
|
||||
player.trackSelectionParameters,
|
||||
player.currentTracks,
|
||||
playerBackend,
|
||||
source.supportsDirectPlay,
|
||||
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY },
|
||||
|
|
@ -689,6 +721,8 @@ class PlaybackViewModel
|
|||
source,
|
||||
)
|
||||
if (result.bothSelected) {
|
||||
player.trackSelectionParameters =
|
||||
result.trackSelectionParameters
|
||||
player.removeListener(this)
|
||||
}
|
||||
viewModelScope.launchIO { loadSubtitleDelay() }
|
||||
|
|
@ -712,11 +746,27 @@ class PlaybackViewModel
|
|||
type = MediaStreamType.AUDIO,
|
||||
)
|
||||
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(
|
||||
item,
|
||||
itemPlayback,
|
||||
index,
|
||||
itemPlayback.subtitleIndex,
|
||||
resolvedSubtitleIndex,
|
||||
onMain { player.currentPosition },
|
||||
true,
|
||||
)
|
||||
|
|
@ -734,11 +784,27 @@ class PlaybackViewModel
|
|||
type = MediaStreamType.SUBTITLE,
|
||||
)
|
||||
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(
|
||||
item,
|
||||
itemPlayback,
|
||||
itemPlayback.audioIndex,
|
||||
index,
|
||||
resolvedIndex,
|
||||
onMain { player.currentPosition },
|
||||
true,
|
||||
)
|
||||
|
|
@ -796,10 +862,19 @@ class PlaybackViewModel
|
|||
|
||||
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)
|
||||
*/
|
||||
private fun listenForSegments() {
|
||||
private fun listenForSegments(itemId: UUID) {
|
||||
segmentJob?.cancel()
|
||||
segmentJob =
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -815,7 +890,10 @@ class PlaybackViewModel
|
|||
.firstOrNull {
|
||||
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(
|
||||
"Found media segment for %s: %s, %s",
|
||||
currentSegment.itemId,
|
||||
|
|
@ -1025,6 +1103,7 @@ class PlaybackViewModel
|
|||
Timber.v("release")
|
||||
activityListener?.release()
|
||||
player.release()
|
||||
mediaSession?.release()
|
||||
activityListener = null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
|
||||
data class SimpleMediaStream(
|
||||
val index: Int,
|
||||
val streamTitle: String?,
|
||||
val displayTitle: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
context: Context,
|
||||
mediaStream: MediaStream,
|
||||
includeFlags: Boolean = true,
|
||||
): SimpleMediaStream =
|
||||
SimpleMediaStream(
|
||||
index = mediaStream.index,
|
||||
streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() },
|
||||
displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.extensions.subtitleApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||
import timber.log.Timber
|
||||
|
|
@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
itemId = it.sourceId ?: it.itemId,
|
||||
subtitleId = subtitleId,
|
||||
)
|
||||
val currentSource =
|
||||
this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo
|
||||
val currentSubtitleStreams =
|
||||
this@downloadAndSwitchSubtitles
|
||||
.currentMediaInfo.value
|
||||
?.subtitleStreams
|
||||
currentSource
|
||||
?.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.orEmpty()
|
||||
val externalPaths = currentSubtitleStreams.map { it.path }
|
||||
|
||||
val subtitleCount = currentSubtitleStreams.size
|
||||
var newCount = subtitleCount
|
||||
var maxAttempts = 4
|
||||
var newStreams: List<SubtitleStream> = listOf()
|
||||
|
||||
var mediaSource: MediaSourceInfo? = null
|
||||
// The server triggers a refresh in the background, so query periodically for the item until its updated
|
||||
while (maxAttempts > 0 && subtitleCount == newCount) {
|
||||
maxAttempts--
|
||||
|
|
@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
api.userLibraryApi.getItem(itemId = it.itemId).content,
|
||||
api,
|
||||
)
|
||||
val mediaSource = streamChoiceService.chooseSource(item.data, it)
|
||||
mediaSource = streamChoiceService.chooseSource(item.data, it)
|
||||
if (mediaSource == null) {
|
||||
// This shouldn't happen, but just in case
|
||||
showToast(
|
||||
|
|
@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
.orEmpty()
|
||||
newCount = subtitleStreams.size
|
||||
|
||||
if (subtitleCount != newCount) {
|
||||
newStreams =
|
||||
subtitleStreams.map {
|
||||
SubtitleStream(
|
||||
it.index,
|
||||
it.language,
|
||||
it.title,
|
||||
it.codec,
|
||||
it.codecTag,
|
||||
it.isExternal,
|
||||
it.isForced,
|
||||
it.isDefault,
|
||||
it.displayTitle,
|
||||
)
|
||||
}
|
||||
updateCurrentMedia {
|
||||
it.copy(subtitleStreams = newStreams)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxAttempts == 0) {
|
||||
showToast(
|
||||
|
|
@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
)
|
||||
} else {
|
||||
// Find the new subtitle stream
|
||||
val subtitlesStreams =
|
||||
mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
val newStream =
|
||||
newStreams
|
||||
.toMutableList()
|
||||
.apply {
|
||||
removeAll(currentSubtitleStreams)
|
||||
}.firstOrNull { it.external }
|
||||
subtitlesStreams?.firstOrNull { stream ->
|
||||
stream.isExternal && stream.path !in externalPaths
|
||||
}
|
||||
if (newStream != null) {
|
||||
var audioIndex = currentItemPlayback.value?.audioIndex
|
||||
if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) {
|
||||
|
|
@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
|||
Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1")
|
||||
audioIndex += 1
|
||||
}
|
||||
updateCurrentMedia {
|
||||
it.copy(
|
||||
subtitleStreams =
|
||||
subtitlesStreams.map {
|
||||
SimpleMediaStream.from(context, it, true)
|
||||
},
|
||||
)
|
||||
}
|
||||
this@downloadAndSwitchSubtitles.changeStreams(
|
||||
item,
|
||||
currentItemPlayback.value!!,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ package com.github.damontecres.wholphin.ui.playback
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
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.SubtitleDeliveryMethod
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
|
||||
object TrackSelectionUtils {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun applyTrackSelections(
|
||||
player: Player,
|
||||
fun createTrackSelections(
|
||||
trackSelectionParams: TrackSelectionParameters,
|
||||
tracks: Tracks,
|
||||
playerBackend: PlayerBackend,
|
||||
supportsDirectPlay: Boolean,
|
||||
audioIndex: Int?,
|
||||
subtitleIndex: Int?,
|
||||
source: MediaSourceInfo,
|
||||
): TrackSelectionResult {
|
||||
val videoStreamCount = source.videoStreamCount
|
||||
val audioStreamCount = source.audioStreamCount
|
||||
val embeddedSubtitleCount = source.embeddedSubtitleCount
|
||||
val externalSubtitleCount = source.externalSubtitlesCount
|
||||
|
||||
val paramsBuilder = player.trackSelectionParameters.buildUpon()
|
||||
val tracks = player.currentTracks.groups
|
||||
val paramsBuilder = trackSelectionParams.buildUpon()
|
||||
val groups = tracks.groups
|
||||
|
||||
val subtitleSelected =
|
||||
if (subtitleIndex != null && subtitleIndex >= 0) {
|
||||
|
|
@ -37,7 +38,7 @@ object TrackSelectionUtils {
|
|||
if (subtitleIsExternal || supportsDirectPlay) {
|
||||
val chosenTrack =
|
||||
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
|
||||
tracks.firstOrNull { group ->
|
||||
groups.firstOrNull { group ->
|
||||
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
||||
(0..<group.mediaTrackGroup.length)
|
||||
.mapNotNull {
|
||||
|
|
@ -45,23 +46,38 @@ object TrackSelectionUtils {
|
|||
}.any { it.endsWith("e:$subtitleIndex") }
|
||||
}
|
||||
} 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 =
|
||||
calculateIndexToFind(
|
||||
subtitleIndex,
|
||||
MediaStreamType.SUBTITLE,
|
||||
playerBackend,
|
||||
videoStreamCount,
|
||||
audioStreamCount,
|
||||
embeddedSubtitleCount,
|
||||
externalSubtitleCount,
|
||||
subtitleIsExternal,
|
||||
actualEmbeddedCount,
|
||||
source,
|
||||
)
|
||||
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
|
||||
// subtitleIndex - externalSubtitleCount + 1
|
||||
tracks.firstOrNull { group ->
|
||||
groups.firstOrNull { group ->
|
||||
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
||||
(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
|
||||
}.contains(indexToFind)
|
||||
}
|
||||
|
|
@ -95,14 +111,14 @@ object TrackSelectionUtils {
|
|||
audioIndex,
|
||||
MediaStreamType.AUDIO,
|
||||
playerBackend,
|
||||
videoStreamCount,
|
||||
audioStreamCount,
|
||||
embeddedSubtitleCount,
|
||||
externalSubtitleCount,
|
||||
false,
|
||||
null,
|
||||
source,
|
||||
)
|
||||
val chosenTrack =
|
||||
tracks.firstOrNull { group ->
|
||||
groups.firstOrNull { group ->
|
||||
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
|
||||
(0..<group.mediaTrackGroup.length)
|
||||
.map {
|
||||
|
|
@ -124,10 +140,7 @@ object TrackSelectionUtils {
|
|||
} else {
|
||||
audioIndex == null
|
||||
}
|
||||
if (audioSelected && subtitleSelected) {
|
||||
player.trackSelectionParameters = paramsBuilder.build()
|
||||
}
|
||||
return TrackSelectionResult(audioSelected, subtitleSelected)
|
||||
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,11 +150,11 @@ object TrackSelectionUtils {
|
|||
serverIndex: Int,
|
||||
type: MediaStreamType,
|
||||
playerBackend: PlayerBackend,
|
||||
videoStreamCount: Int,
|
||||
audioStreamCount: Int,
|
||||
embeddedSubtitleCount: Int,
|
||||
externalSubtitleCount: Int,
|
||||
subtitleIsExternal: Boolean,
|
||||
actualEmbeddedCount: Int?,
|
||||
source: MediaSourceInfo,
|
||||
): Int =
|
||||
when (playerBackend) {
|
||||
PlayerBackend.EXO_PLAYER,
|
||||
|
|
@ -158,13 +171,22 @@ object TrackSelectionUtils {
|
|||
}
|
||||
|
||||
MediaStreamType.AUDIO -> {
|
||||
serverIndex - externalSubtitleCount - videoStreamCount + 1
|
||||
val videoStreamsBeforeAudioCount =
|
||||
source.mediaStreams
|
||||
.orEmpty()
|
||||
.indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount
|
||||
serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1
|
||||
}
|
||||
|
||||
MediaStreamType.SUBTITLE -> {
|
||||
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 {
|
||||
val videoStreamCount = source.videoStreamCount
|
||||
val audioStreamCount = source.audioStreamCount
|
||||
serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1
|
||||
}
|
||||
}
|
||||
|
|
@ -228,12 +250,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me
|
|||
fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
|
||||
subtitleIndex?.let {
|
||||
firstOrNull {
|
||||
it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL &&
|
||||
it.type == MediaStreamType.SUBTITLE &&
|
||||
(it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) &&
|
||||
it.index == subtitleIndex
|
||||
}
|
||||
}
|
||||
|
||||
data class TrackSelectionResult(
|
||||
val trackSelectionParameters: TrackSelectionParameters,
|
||||
val audioSelected: Boolean,
|
||||
val subtitleSelected: Boolean,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.NavigationDrawerScope
|
||||
|
|
@ -30,10 +31,8 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppThemeColors
|
||||
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.WatchedIcon
|
||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.NavItem
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackButton
|
||||
|
|
@ -43,7 +42,7 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@Preview(
|
||||
device = "spec:width=1200dp,height=2000dp",
|
||||
device = "spec:width=1200dp,height=2500dp",
|
||||
backgroundColor = 0xFF383535,
|
||||
uiMode = UI_MODE_TYPE_TELEVISION,
|
||||
)
|
||||
|
|
@ -109,6 +108,7 @@ private fun ThemeExample(theme: AppThemeColors) {
|
|||
isPlayed = true,
|
||||
unplayedItemCount = 2,
|
||||
playedPercentage = 50.0,
|
||||
numberOfVersions = 2,
|
||||
onClick = { },
|
||||
onLongClick = {},
|
||||
imageHeight = 120.dp,
|
||||
|
|
@ -125,6 +125,7 @@ private fun ThemeExample(theme: AppThemeColors) {
|
|||
isPlayed = true,
|
||||
unplayedItemCount = 2,
|
||||
playedPercentage = 0.0,
|
||||
numberOfVersions = 0,
|
||||
onClick = { },
|
||||
onLongClick = {},
|
||||
imageHeight = 120.dp,
|
||||
|
|
@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) {
|
|||
interactionSource = source,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
ListItem(
|
||||
selected = false,
|
||||
enabled = true,
|
||||
headlineContent = { Text("Headline content") },
|
||||
supportingContent = { Text("Support content") },
|
||||
onClick = {},
|
||||
)
|
||||
ListItem(
|
||||
selected = true,
|
||||
enabled = true,
|
||||
headlineContent = { Text("Headline content") },
|
||||
supportingContent = { Text("Support content") },
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ val PurpleThemeColors =
|
|||
val secondaryDark = Color(0xFFD2BCFF)
|
||||
val onSecondaryDark = Color(0xFF3B167D)
|
||||
val secondaryContainerDark = Color(0xFF48415B)
|
||||
val onSecondaryContainerDark = Color(0xFFC2A6FF)
|
||||
val onSecondaryContainerDark = Color(0xFFDFD3F8)
|
||||
val tertiaryDark = Color(0xFFA071F8)
|
||||
val onTertiaryDark = Color(0xFF5E0052)
|
||||
val tertiaryContainerDark = Color(0xFFB800A3)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
package com.github.damontecres.wholphin.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.util.languageName
|
||||
import com.github.damontecres.wholphin.util.profile.Codec
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
|
||||
/**
|
||||
* Collection of utility functions for formatting the display of media streams
|
||||
*/
|
||||
object StreamFormatting {
|
||||
fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p"
|
||||
|
||||
// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714
|
||||
fun resolutionString(
|
||||
width: Int,
|
||||
height: Int,
|
||||
interlaced: Boolean,
|
||||
): String =
|
||||
if (height > width) {
|
||||
// Vertical video
|
||||
resolutionString(height, width, interlaced)
|
||||
} else {
|
||||
when {
|
||||
width <= 256 && height <= 144 -> "144" + interlaced(interlaced)
|
||||
width <= 426 && height <= 240 -> "240" + interlaced(interlaced)
|
||||
width <= 640 && height <= 360 -> "360" + interlaced(interlaced)
|
||||
width <= 682 && height <= 384 -> "384" + interlaced(interlaced)
|
||||
width <= 720 && height <= 404 -> "404" + interlaced(interlaced)
|
||||
width <= 854 && height <= 480 -> "480" + interlaced(interlaced)
|
||||
width <= 960 && height <= 544 -> "540" + interlaced(interlaced)
|
||||
width <= 1024 && height <= 576 -> "576" + interlaced(interlaced)
|
||||
width <= 1280 && height <= 962 -> "720" + interlaced(interlaced)
|
||||
width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced)
|
||||
width <= 4096 && height <= 3072 -> "4K"
|
||||
width <= 8192 && height <= 6144 -> "8K"
|
||||
else -> height.toString() + interlaced(interlaced)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatVideoRange(
|
||||
context: Context,
|
||||
videoRange: VideoRange?,
|
||||
type: VideoRangeType?,
|
||||
doviTitle: String?,
|
||||
): String? =
|
||||
when (videoRange) {
|
||||
VideoRange.UNKNOWN,
|
||||
VideoRange.SDR, null,
|
||||
-> {
|
||||
null
|
||||
}
|
||||
|
||||
VideoRange.HDR -> {
|
||||
if (doviTitle.isNotNullOrBlank()) {
|
||||
context.getString(R.string.dolby_vision)
|
||||
} else {
|
||||
when (type) {
|
||||
VideoRangeType.UNKNOWN,
|
||||
VideoRangeType.SDR,
|
||||
null,
|
||||
-> null
|
||||
|
||||
VideoRangeType.HDR10 -> "HDR10"
|
||||
|
||||
VideoRangeType.HDR10_PLUS -> "HDR10+"
|
||||
|
||||
VideoRangeType.HLG -> "HLG"
|
||||
|
||||
VideoRangeType.DOVI,
|
||||
VideoRangeType.DOVI_WITH_HDR10,
|
||||
VideoRangeType.DOVI_WITH_HLG,
|
||||
VideoRangeType.DOVI_WITH_SDR,
|
||||
-> context.getString(R.string.dolby_vision)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatAudioCodec(
|
||||
context: Context,
|
||||
codec: String?,
|
||||
profile: String?,
|
||||
): String? =
|
||||
when {
|
||||
profile?.contains("Dolby Atmos", true) == true -> {
|
||||
context.getString(R.string.dolby_atmos)
|
||||
}
|
||||
|
||||
profile?.contains("DTS:X", true) == true -> {
|
||||
context.getString(R.string.dts_x)
|
||||
}
|
||||
|
||||
profile?.contains("DTS:HD", true) == true -> {
|
||||
context.getString(R.string.dts_hd)
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (codec?.lowercase()) {
|
||||
Codec.Audio.TRUEHD -> context.getString(R.string.truehd)
|
||||
|
||||
Codec.Audio.AC3 -> context.getString(R.string.dolby_digital)
|
||||
|
||||
Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus)
|
||||
|
||||
Codec.Audio.DCA -> context.getString(R.string.dts)
|
||||
|
||||
Codec.Audio.OGG,
|
||||
Codec.Audio.OPUS,
|
||||
Codec.Audio.VORBIS,
|
||||
-> codec.replaceFirstChar { it.uppercase() }
|
||||
|
||||
null -> null
|
||||
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatSubtitleCodec(codec: String?): String? =
|
||||
when (codec?.lowercase()) {
|
||||
Codec.Subtitle.DVBSUB -> "DVB"
|
||||
Codec.Subtitle.DVDSUB -> "DVD"
|
||||
Codec.Subtitle.PGSSUB -> "PGS"
|
||||
Codec.Subtitle.SUBRIP -> "SRT"
|
||||
null -> null
|
||||
else -> codec.uppercase()
|
||||
}
|
||||
|
||||
fun String?.concatWithSpace(str: String?): String? =
|
||||
when {
|
||||
this != null && str != null -> "$this $str"
|
||||
this == null -> str
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun mediaStreamDisplayTitle(
|
||||
context: Context,
|
||||
stream: MediaStream,
|
||||
includeFlags: Boolean,
|
||||
): String {
|
||||
val name =
|
||||
buildList {
|
||||
add(languageName(stream.language))
|
||||
if (stream.type == MediaStreamType.AUDIO) {
|
||||
add(formatAudioCodec(context, stream.codec, stream.profile))
|
||||
add(stream.channelLayout)
|
||||
} else if (stream.type == MediaStreamType.SUBTITLE) {
|
||||
"SDH".takeIf { stream.isHearingImpaired }?.let(::add)
|
||||
add(formatSubtitleCodec(stream.codec))
|
||||
}
|
||||
}.joinToString(" ")
|
||||
if (includeFlags) {
|
||||
val flags =
|
||||
buildList {
|
||||
if (stream.isDefault) add(stream.localizedDefault)
|
||||
if (stream.isForced) add(stream.localizedForced)
|
||||
if (stream.isExternal) add(stream.localizedExternal)
|
||||
}.joinToString(", ")
|
||||
if (flags.isNotEmpty()) {
|
||||
return "$name ($flags)"
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference
|
|||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.concurrent.atomics.update
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
|
|
@ -231,11 +232,6 @@ class MpvPlayer(
|
|||
|
||||
override fun prepare() {
|
||||
if (DEBUG) Timber.v("prepare")
|
||||
playbackState.update {
|
||||
it.copy(
|
||||
state = Player.STATE_READY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPlaybackState(): Int {
|
||||
|
|
@ -246,7 +242,8 @@ class MpvPlayer(
|
|||
override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE
|
||||
|
||||
override fun getPlayerError(): PlaybackException? {
|
||||
TODO("Not yet implemented")
|
||||
// TODO
|
||||
return null
|
||||
}
|
||||
|
||||
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
||||
|
|
@ -394,8 +391,7 @@ class MpvPlayer(
|
|||
|
||||
override fun getCurrentTimeline(): Timeline {
|
||||
if (DEBUG) Timber.v("getCurrentTimeline")
|
||||
// TODO
|
||||
return Timeline.EMPTY
|
||||
return playbackState.load().timeline
|
||||
}
|
||||
|
||||
override fun getCurrentPeriodIndex(): Int {
|
||||
|
|
@ -527,7 +523,7 @@ class MpvPlayer(
|
|||
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
|
||||
|
||||
|
|
@ -654,12 +650,15 @@ class MpvPlayer(
|
|||
}
|
||||
|
||||
MPV_EVENT_FILE_LOADED -> {
|
||||
playbackState.update {
|
||||
it.copy(isLoadingFile = false)
|
||||
}
|
||||
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) }
|
||||
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 ->
|
||||
media.localConfiguration?.subtitleConfigurations?.forEach {
|
||||
val url = it.uri.toString()
|
||||
|
|
@ -766,10 +765,63 @@ class MpvPlayer(
|
|||
|
||||
private fun loadFile(media: MediaAndPosition) {
|
||||
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 {
|
||||
it.copy(
|
||||
isLoadingFile = true,
|
||||
state = STATE_READY,
|
||||
media = media,
|
||||
timeline = timeline,
|
||||
)
|
||||
}
|
||||
notifyListeners(EVENT_TIMELINE_CHANGED) {
|
||||
onTimelineChanged(
|
||||
timeline,
|
||||
TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
|
||||
)
|
||||
}
|
||||
notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) }
|
||||
|
|
@ -837,7 +889,8 @@ class MpvPlayer(
|
|||
|
||||
private val updatePlaybackState: Runnable =
|
||||
Runnable {
|
||||
if (playbackState.load().media == null) {
|
||||
val state = playbackState.load()
|
||||
if (state.media == null) {
|
||||
return@Runnable
|
||||
}
|
||||
val positionMs =
|
||||
|
|
@ -860,6 +913,53 @@ class MpvPlayer(
|
|||
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 {
|
||||
it.copy(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
|
|
@ -869,6 +969,13 @@ class MpvPlayer(
|
|||
speed = speed,
|
||||
isPaused = paused,
|
||||
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,
|
||||
@param:Player.State val state: Int,
|
||||
val tracks: Tracks,
|
||||
val timeline: Timeline,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
PlaybackState(
|
||||
timestamp = C.TIME_UNSET,
|
||||
isLoadingFile = true,
|
||||
isLoadingFile = false,
|
||||
media = null,
|
||||
positionMs = C.TIME_UNSET,
|
||||
durationMs = C.TIME_UNSET,
|
||||
|
|
@ -1095,6 +1203,7 @@ private data class PlaybackState(
|
|||
videoSize = VideoSize.UNKNOWN,
|
||||
state = Player.STATE_IDLE,
|
||||
subtitleDelay = 0.0,
|
||||
timeline = Timeline.EMPTY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
<string name="no_scheduled_recordings">No scheduled recordings</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
<string name="none">None</string>
|
||||
<string name="only_forced_subtitles">Only Forced Subtitles</string>
|
||||
<string name="outro">Outro</string>
|
||||
<string name="path">Path</string>
|
||||
<string name="people">People</string>
|
||||
|
|
@ -419,6 +420,12 @@
|
|||
<string name="play_trailer">Play trailer</string>
|
||||
<string name="no_trailers">No trailers</string>
|
||||
<string name="clear_track_choices">Clear track choices</string>
|
||||
<string name="dts_x">DTS:X</string>
|
||||
<string name="dts_hd">DTS:HD</string>
|
||||
<string name="truehd">TrueHD</string>
|
||||
<string name="dolby_digital">DD</string>
|
||||
<string name="dolby_digital_plus">DD+</string>
|
||||
<string name="dts">DTS</string>
|
||||
|
||||
<string name="discover">Discover</string>
|
||||
<string name="request">Request</string>
|
||||
|
|
|
|||
9
app/src/test/java/android/text/TextUtils.java
Normal file
9
app/src/test/java/android/text/TextUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
val expectedIndex: Int?,
|
||||
val userSubtitleMode: SubtitlePlaybackMode?,
|
||||
|
|
@ -674,6 +800,7 @@ fun subtitle(
|
|||
lang: String?,
|
||||
default: Boolean = false,
|
||||
forced: Boolean = false,
|
||||
title: String? = null,
|
||||
): MediaStream =
|
||||
MediaStream(
|
||||
type = MediaStreamType.SUBTITLE,
|
||||
|
|
@ -686,6 +813,7 @@ fun subtitle(
|
|||
isExternal = false,
|
||||
isTextSubtitleStream = true,
|
||||
supportsExternalStream = true,
|
||||
title = title,
|
||||
)
|
||||
|
||||
private fun itemPlayback(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
app/src/test/resources/embedded_subs.json
Normal file
235
app/src/test/resources/embedded_subs.json
Normal 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
|
||||
}
|
||||
208
app/src/test/resources/external_subs.json
Normal file
208
app/src/test/resources/external_subs.json
Normal 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
|
||||
}
|
||||
178
app/src/test/resources/no_embedded_subs.json
Normal file
178
app/src/test/resources/no_embedded_subs.json
Normal 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
|
||||
}
|
||||
|
|
@ -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-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-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue