Improve track selection logic (#1654)

## Description
Updates the logic for selecting tracks which should fix several edge
cases

The new logic groups track types together and then applying the user's
selection to the actual tracks in the file. For example, if the user
choosing the audio track which the server reports as absolute `index=2`
(eg the _third_ track of any type in the file) and audio `index=1` (eg
the _second audio_ track in the file), then the track selection logic
will use the _second audio_ track in the direct played file (after
accounting for id sorting).

Some other changes:
- If direct playing and an unsupported track is selected, the app will
now fall back to transcoding
- If the correct audio/subtitle tracks are not found, the app will now
fall back to transcoding instead of blocking any track change attempts

### TODO
Not necessarily in this PR, but some things need further investigation:
- Handling external audio tracks
- Why does ExoPlayer order the tracks differently sometimes, see
[example
here](https://github.com/damontecres/Wholphin/issues/791#issuecomment-4836470813)
where `0:3` is _after_ `0:4`, but the `MediaSourceInfo` lists them `0:3`
then `0:4`

### Related issues
Maybe fixes #791
Should fix
https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175
Related to the parent issue, #1475
Replaces #845

### Testing
Unit tests mostly, some basic tests on emulator

Needs more "real world" testing

#### Try it out?

You can try out this build by following the [instructions
here](https://github.com/damontecres/Wholphin/releases/tag/develop-track-selection).
If you do, please report your results in this PR!

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-10 17:49:47 -04:00 • committed by GitHub
parent d481aea85a
commit 26124a5348
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1014 additions and 498 deletions

View file

@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.test
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionParameters
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.playback.TrackSelected
import com.github.damontecres.wholphin.ui.playback.TrackSelectionResult
import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils
import org.jellyfin.sdk.model.api.MediaStreamType
@ -36,11 +38,9 @@ class TestSelectionTrackExamples {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = testTacks.toTracks(),
playerBackend = playerBackend,
supportsDirectPlay = true,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
source = testTacks.toMediaSourceInfo(),
source = builder.buildMediaSourceInfo(),
).also(onResult)
}
@ -111,4 +111,189 @@ class TestSelectionTrackExamples {
Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test builderVASASS`() {
runTest(
TrackExamples.builderVASASS,
PlayerBackend.EXO_PLAYER,
audioIndex = 1,
subtitleIndex = 2,
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("3", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
runTest(
TrackExamples.builderVASASS,
PlayerBackend.MPV,
audioIndex = 1,
subtitleIndex = 2,
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("2:1", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
runTest(
TrackExamples.builderVASASS,
PlayerBackend.EXO_PLAYER,
audioIndex = 3,
subtitleIndex = 5,
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("4", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("6", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
runTest(
TrackExamples.builderVASASS,
PlayerBackend.MPV,
audioIndex = 3,
subtitleIndex = 5,
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("3:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("5:3", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `Test external subtitles at end`() {
val builder =
TestTracks
.Builder()
.addVideo()
.addAudio(2)
.addSubtitle(2)
.addExternalSubtitle(1)
runTest(
builder,
PlayerBackend.EXO_PLAYER,
audioIndex = 1,
subtitleIndex = 5, // external
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("1:e:5", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test AAVASS in issue 1005`() {
// https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175
val builder =
TestTracks
.Builder()
.addAudio(2)
.addVideo()
.addAudio()
.addSubtitle(42)
runTest(
builder,
PlayerBackend.MPV,
audioIndex = 1,
subtitleIndex = 5,
) { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("1:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals("5:2", result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test unsupported tracks`() {
val builder =
TestTracks
.Builder()
.addVideo()
.addAudio()
.addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_UNSUPPORTED_TYPE)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 1,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.SELECTED, result.audio)
Assert.assertTrue(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 2,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio)
Assert.assertFalse(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 4,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio)
Assert.assertFalse(result.bothSelected)
}
}
@Test
fun `test exceeds capabilities tracks`() {
val builder =
TestTracks
.Builder()
.addVideo()
.addAudio()
.addTrack(1, MediaStreamType.AUDIO, false, C.FORMAT_EXCEEDS_CAPABILITIES)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 1,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.SELECTED, result.audio)
Assert.assertTrue(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 2,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.UNSUPPORTED, result.audio)
Assert.assertFalse(result.bothSelected)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = builder.buildForExoPlayer().toTracks(),
audioIndex = 4,
subtitleIndex = null,
source = builder.buildMediaSourceInfo(),
).let { result ->
Assert.assertEquals(TrackSelected.NOT_FOUND, result.audio)
Assert.assertFalse(result.bothSelected)
}
}
}

View file

@ -235,6 +235,155 @@ class TestTrackSelection {
return Tracks(groups)
}
private fun buildMissingIdTracks(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 {
// ExoPlayer
val video =
Format
.Builder()
.setId("0:1")
.setSampleMimeType("video/default")
.build()
val audios =
(3..5).map {
Format
.Builder()
.setId("0:$it")
.setSampleMimeType("audio/default")
.build()
}
val subtitles =
(6..8).map {
Format
.Builder()
.setId("0:$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)
}
private fun buildDifferentOrderedTracks(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 {
// ExoPlayer
val video =
Format
.Builder()
.setId("0:1")
.setSampleMimeType("video/default")
.build()
val audios =
listOf(
Format
.Builder()
.setId("0:2")
.setSampleMimeType("audio/default")
.build(),
Format
.Builder()
.setId("0:4")
.setSampleMimeType("audio/default")
.build(),
Format
.Builder()
.setId("0:3")
.setSampleMimeType("audio/default")
.build(),
)
val subtitles =
(5..8).map {
Format
.Builder()
.setId("0:$it")
.setSampleMimeType("text/default")
.build()
} +
listOf(
Format
.Builder()
.setId("1:e:8") // 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)
}
@Test
fun `test MPV embedded`() {
val resource = javaClass.classLoader?.getResource("embedded_subs.json")
@ -250,8 +399,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = 4,
source = source,
@ -265,8 +412,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 4,
source = source,
@ -280,8 +425,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED,
source = source,
@ -307,8 +450,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = 4,
source = source,
@ -322,8 +463,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 4,
source = source,
@ -337,8 +476,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 6,
source = source,
@ -352,8 +489,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED,
source = source,
@ -379,8 +514,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 0,
source = source,
@ -394,8 +527,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
@ -421,8 +552,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2,
subtitleIndex = 0,
source = source,
@ -448,8 +577,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
@ -465,8 +592,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 5,
source = source,
@ -500,8 +625,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 0,
source = source,
@ -523,8 +646,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 5,
source = source,
@ -551,8 +672,6 @@ class TestTrackSelection {
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3,
subtitleIndex = 2,
source = source,
@ -570,6 +689,113 @@ class TestTrackSelection {
}
}
}
@Test
fun `test ExoPlayer missing ids`() {
val resource = javaClass.classLoader?.getResource("no_embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildMissingIdTracks(PlayerBackend.EXO_PLAYER)
val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } }
Assert.assertTrue("0:1" in ids)
Assert.assertTrue("0:2" !in ids)
Assert.assertTrue("0:3" in ids)
Assert.assertEquals(5, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
audioIndex = 3,
subtitleIndex = TrackIndex.DISABLED,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test MPV missing ids`() {
val resource = javaClass.classLoader?.getResource("no_embedded_subs.json")
Assert.assertNotNull(resource)
val fileContents = Paths.get(resource!!.toURI()).readText()
val source = Json.decodeFromString<MediaSourceInfo>(fileContents)
val tracks = buildMissingIdTracks(PlayerBackend.MPV)
val ids = tracks.groups.flatMap { g -> (0..<g.length).map { g.getTrackFormat(it).id } }
println(ids)
Assert.assertEquals(5, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
audioIndex = 3,
subtitleIndex = TrackIndex.DISABLED,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id)
}
}
@Test
fun `test ExoPlayer different order`() {
val source =
TestTracks
.Builder()
.addVideo()
.addAudio(3)
.addSubtitle(4)
.addExternalSubtitle(1)
.buildMediaSourceInfo()
val tracks = buildDifferentOrderedTracks(PlayerBackend.EXO_PLAYER)
Assert.assertEquals(9, source.mediaStreams?.size)
val trackSelectionParameters = TrackSelectionParameters.Builder().build()
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
audioIndex = 1,
subtitleIndex = 8,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(
"1:e:8",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
TrackSelectionUtils
.createTrackSelections(
trackSelectionParams = trackSelectionParameters,
tracks = tracks,
audioIndex = 3,
subtitleIndex = 8,
source = source,
).also { result ->
Assert.assertTrue(result.bothSelected)
Assert.assertEquals("0:4", result.trackSelectionParameters.getAudioOverride()?.id)
Assert.assertEquals(
"1:e:8",
result.trackSelectionParameters.getSubtitleOverride()?.id,
)
}
}
}
internal fun TrackSelectionParameters.getAudioOverride(): Format? {

View file

@ -10,6 +10,7 @@ import org.jellyfin.sdk.model.api.MediaSourceType
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.junit.Assert
import org.junit.Test
@ -18,7 +19,7 @@ import org.junit.Test
*
* @see Builder
*/
class TestTracks private constructor(
class TestTracks(
val tracks: List<TestTrack> = emptyList(),
) {
/**
@ -27,6 +28,7 @@ class TestTracks private constructor(
fun toTracks(): Tracks {
val groups =
tracks
.sortedBy { it.external }
.map {
val mimeType =
when (it.type) {
@ -35,55 +37,21 @@ class TestTracks private constructor(
MediaStreamType.SUBTITLE -> "text/sample"
else -> throw UnsupportedOperationException("${it.type}")
}
Format
.Builder()
.setId(it.id)
.setSampleMimeType(mimeType)
.build()
}.map { TrackGroup(it) }
.map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) }
return Tracks(groups)
}
val format =
Format
.Builder()
.setId(it.id)
.setSampleMimeType(mimeType)
.build()
Tracks.Group(
TrackGroup(format),
false,
intArrayOf(it.support),
booleanArrayOf(false),
)
}
fun toMediaSourceInfo(): MediaSourceInfo {
val streams =
tracks.mapIndexed { index, track ->
MediaStream(
index = index,
type = track.type,
isExternal = track.external,
codec = null,
language = null,
isInterlaced = false,
isDefault = false,
isForced = false,
isHearingImpaired = false,
isTextSubtitleStream = false,
supportsExternalStream = false,
)
}
val source =
MediaSourceInfo(
mediaStreams = streams,
protocol = MediaProtocol.HTTP,
type = MediaSourceType.DEFAULT,
isRemote = false,
readAtNativeFramerate = false,
ignoreDts = false,
ignoreIndex = false,
genPtsInput = false,
supportsTranscoding = true,
supportsDirectStream = true,
supportsDirectPlay = true,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = false,
transcodingSubProtocol = MediaStreamProtocol.HLS,
hasSegments = false,
)
return source
return Tracks(groups)
}
/**
@ -92,28 +60,24 @@ class TestTracks private constructor(
class Builder {
private val tracks = mutableListOf<TestTrackBuilder>()
fun addVideo(): Builder {
tracks.add(TestTrackBuilder(MediaStreamType.VIDEO))
return this
}
fun addVideo(): Builder = addTrack(1, MediaStreamType.VIDEO, false, C.FORMAT_HANDLED)
fun addAudio(count: Int = 1): Builder {
repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.AUDIO))
}
return this
}
fun addAudio(count: Int = 1): Builder = addTrack(count, MediaStreamType.AUDIO, false, C.FORMAT_HANDLED)
fun addSubtitle(count: Int = 1): Builder {
repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, false))
}
return this
}
fun addSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, false, C.FORMAT_HANDLED)
fun addExternalSubtitle(count: Int = 1): Builder {
fun addExternalSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, true, C.FORMAT_HANDLED)
fun addEmpty(count: Int = 1): Builder = addTrack(count, null, false, C.FORMAT_UNSUPPORTED_TYPE)
fun addTrack(
count: Int,
type: MediaStreamType?,
external: Boolean,
@C.FormatSupport support: Int,
): Builder {
repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, true))
tracks.add(TestTrackBuilder(type, external, support))
}
return this
}
@ -129,7 +93,10 @@ class TestTracks private constructor(
var externalSubCount = 0
val t =
tracks
.mapIndexed { index, track ->
.mapIndexedNotNull { index, track ->
if (track.type == null) {
return@mapIndexedNotNull null
}
if (track.external) {
externalSubCount++
TestTrack(
@ -137,14 +104,27 @@ class TestTracks private constructor(
index,
track.type,
track.external,
track.support,
)
} else if (hasExternal) {
val idx = index + 1 - externalSubCount
// If there's a sidecar file, the actual file
TestTrack("0:$idx", index, track.type, track.external)
TestTrack(
"0:$idx",
index,
track.type,
track.external,
track.support,
)
} else {
val idx = index + 1
TestTrack("$idx", index, track.type, track.external)
TestTrack(
"$idx",
index,
track.type,
track.external,
track.support,
)
}
}
addAll(t)
@ -161,20 +141,29 @@ class TestTracks private constructor(
tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external }
val externalSubCount =
tracks.count { it.type == MediaStreamType.SUBTITLE && it.external }
// val firstNonExternal = tracks.indexOfFirst { it.type != MediaStreamType.SUBTITLE }
val firstExternal =
tracks.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.external }
val externalSubtitleAreFirst = externalSubCount > 0 && firstExternal == 0
var videoCount = 0
var audioCount = 0
var subtitleCount = 0
val testTracks =
tracks.mapIndexed { index, track ->
tracks.mapIndexedNotNull { index, track ->
if (track.type == null) {
return@mapIndexedNotNull null
}
val id =
if (track.external) {
// MPV places external subtitles last
val idx = embeddedCount + index
val idx = index + if (externalSubtitleAreFirst) embeddedCount else 0
subtitleCount++
val count = embeddedSubtitleCount + subtitleCount
val count =
subtitleCount + if (externalSubtitleAreFirst) embeddedSubtitleCount else 0
"$idx:e:$count"
} else {
val idx = index - externalSubCount
val idx =
if (externalSubtitleAreFirst) index - externalSubCount else index
when (track.type) {
MediaStreamType.AUDIO -> {
audioCount++
@ -188,7 +177,8 @@ class TestTracks private constructor(
MediaStreamType.SUBTITLE -> {
subtitleCount++
val count = subtitleCount - externalSubCount
val count =
subtitleCount - (if (externalSubtitleAreFirst) externalSubCount else 0)
"$idx:$count"
}
@ -201,6 +191,55 @@ class TestTracks private constructor(
}
return TestTracks(testTracks)
}
fun buildMediaSourceInfo(): MediaSourceInfo {
val streams =
tracks
.filter { it.type != null }
.mapIndexed { index, track ->
MediaStream(
index = index,
type = track.type!!,
isExternal = track.external,
deliveryMethod =
if (track.type == MediaStreamType.SUBTITLE && track.external) {
SubtitleDeliveryMethod.EXTERNAL
} else {
SubtitleDeliveryMethod.EMBED
},
codec = null,
language = null,
isInterlaced = false,
isDefault = false,
isForced = false,
isHearingImpaired = false,
isTextSubtitleStream = false,
supportsExternalStream = false,
)
}
val source =
MediaSourceInfo(
mediaStreams = streams,
protocol = MediaProtocol.HTTP,
type = MediaSourceType.DEFAULT,
isRemote = false,
readAtNativeFramerate = false,
ignoreDts = false,
ignoreIndex = false,
genPtsInput = false,
supportsTranscoding = true,
supportsDirectStream = true,
supportsDirectPlay = true,
isInfiniteStream = false,
requiresOpening = false,
requiresClosing = false,
requiresLooping = false,
supportsProbing = false,
transcodingSubProtocol = MediaStreamProtocol.HLS,
hasSegments = false,
)
return source
}
}
companion object {
@ -223,11 +262,13 @@ data class TestTrack(
val index: Int,
val type: MediaStreamType,
val external: Boolean,
@param:C.FormatSupport val support: Int = C.FORMAT_HANDLED,
)
class TestTrackBuilder(
val type: MediaStreamType,
val type: MediaStreamType?,
val external: Boolean = false,
@param:C.FormatSupport val support: Int = C.FORMAT_HANDLED,
)
fun assertIdType(
@ -473,4 +514,80 @@ class TestTracksTests {
assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5])
}
}
@Test
fun `Test external subtitles at end`() {
val builder =
TestTracks
.Builder()
.addVideo()
.addAudio(3)
.addSubtitle(4)
.addExternalSubtitle()
builder.buildForExoPlayer().tracks.let { tracks ->
Assert.assertEquals(9, tracks.size)
Assert.assertEquals("0:1", tracks[0].id)
Assert.assertEquals("0:2", tracks[1].id)
Assert.assertEquals("0:3", tracks[2].id)
Assert.assertEquals("0:4", tracks[3].id)
Assert.assertEquals("0:5", tracks[4].id)
Assert.assertEquals("0:6", tracks[5].id)
Assert.assertEquals("0:7", tracks[6].id)
Assert.assertEquals("0:8", tracks[7].id)
Assert.assertEquals("1:e:8", tracks[8].id)
}
builder.buildForMpv().tracks.let { tracks ->
Assert.assertEquals(9, tracks.size)
Assert.assertEquals("0:1", tracks[0].id)
Assert.assertEquals("1:1", tracks[1].id)
Assert.assertEquals("2:2", tracks[2].id)
Assert.assertEquals("3:3", tracks[3].id)
Assert.assertEquals("4:1", tracks[4].id)
Assert.assertEquals("5:2", tracks[5].id)
Assert.assertEquals("6:3", tracks[6].id)
Assert.assertEquals("7:4", tracks[7].id)
Assert.assertEquals("8:e:5", tracks[8].id)
}
builder.buildMediaSourceInfo().let { source ->
val index =
source.mediaStreams?.indexOfFirst { it.type == MediaStreamType.SUBTITLE && it.isExternal }
Assert.assertEquals(8, index)
}
}
@Test
fun `test AAVASS in issue 1005`() {
// https://github.com/damontecres/Wholphin/issues/1005#issuecomment-4085440175
val builder =
TestTracks
.Builder()
.addAudio(2)
.addVideo()
.addAudio()
.addSubtitle(42)
builder.buildForExoPlayer().tracks.let { exo ->
Assert.assertEquals(46, exo.size)
assertIdType("1", MediaStreamType.AUDIO, exo[0])
assertIdType("2", MediaStreamType.AUDIO, exo[1])
assertIdType("3", MediaStreamType.VIDEO, exo[2])
assertIdType("4", MediaStreamType.AUDIO, exo[3])
assertIdType("5", MediaStreamType.SUBTITLE, exo[4])
assertIdType("6", MediaStreamType.SUBTITLE, exo[5])
}
builder.buildForMpv().tracks.let { mpv ->
Assert.assertEquals(46, mpv.size)
assertIdType("0:1", MediaStreamType.AUDIO, mpv[0])
assertIdType("1:2", MediaStreamType.AUDIO, mpv[1])
assertIdType("2:1", MediaStreamType.VIDEO, mpv[2])
assertIdType("3:3", MediaStreamType.AUDIO, mpv[3])
assertIdType("4:1", MediaStreamType.SUBTITLE, mpv[4])
assertIdType("5:2", MediaStreamType.SUBTITLE, mpv[5])
assertIdType("6:3", MediaStreamType.SUBTITLE, mpv[6])
}
}
}

View file

@ -165,8 +165,7 @@ class PlaybackViewModelTests {
.addVideo()
.addAudio()
.addSubtitle()
.buildForExoPlayer()
.toMediaSourceInfo()
.buildMediaSourceInfo()
private val playSessionId = "playsessionid12345"
private val videoStreamUrl = "http://localhost:8096/video/stream"