From d2e0d527ddabdc4a3412235e17542d02e5bf0d19 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:27:54 -0500 Subject: [PATCH] Fix some track selection bugs (#649) ## Description Fixes track selection issues with MPV. This was especially focused on libraries that disable embedded subtitles, but also fixes issues where the audio streams are listed before video streams. Also includes test media source JSON and unit tests to verify many scenarios. ### Related issues Fixes #494 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../ui/playback/TrackSelectionUtils.kt | 72 ++- app/src/test/java/android/text/TextUtils.java | 9 + .../wholphin/test/TestTrackSelection.kt | 591 ++++++++++++++++++ app/src/test/resources/embedded_subs.json | 235 +++++++ app/src/test/resources/external_subs.json | 208 ++++++ app/src/test/resources/no_embedded_subs.json | 178 ++++++ 7 files changed, 1278 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/android/text/TextUtils.java create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt create mode 100644 app/src/test/resources/embedded_subs.json create mode 100644 app/src/test/resources/external_subs.json create mode 100644 app/src/test/resources/no_embedded_subs.json 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 1e56ce08..d8190c30 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 @@ -494,8 +494,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, @@ -504,6 +505,7 @@ 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 = @@ -696,8 +698,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 }, @@ -705,6 +708,8 @@ class PlaybackViewModel source, ) if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters player.removeListener(this) } viewModelScope.launchIO { loadSubtitleDelay() } 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/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/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 +}