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