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