Update track selection logic for missing IDs

This commit is contained in:
Damontecres 2026-02-07 19:57:58 -05:00
parent abf5ec8004
commit 73206c37d0
No known key found for this signature in database
3 changed files with 200 additions and 146 deletions

View file

@ -2,18 +2,17 @@ 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.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 com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import org.jellyfin.sdk.model.api.MediaSourceInfo
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)
@ -26,8 +25,8 @@ object TrackSelectionUtils {
subtitleIndex: Int?,
source: MediaSourceInfo,
): TrackSelectionResult {
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
// This function's implementation assumes that indexes for each MediaStream in the MediaSourceInfo
// will be ordered as: external subtitles, video stream, audio stream, embedded subtitles
val paramsBuilder = trackSelectionParams.buildUpon()
val groups = tracks.groups
@ -35,69 +34,62 @@ object TrackSelectionUtils {
val subtitleSelected =
if (subtitleIndex != null && subtitleIndex >= 0) {
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
if (subtitleIsExternal || supportsDirectPlay) {
val chosenTrack =
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
val chosenTrack =
if (subtitleIsExternal) {
// If external, only one external track should exist, so just find it
val group =
groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
.any {
group.getTrackFormat(0).id?.contains("e:") == true
}
}
} else {
val actualEmbeddedCount =
groups
.filter { group ->
group.type == C.TRACK_TYPE_TEXT &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.none { it.contains("e:") }
}.size
val indexToFind =
calculateIndexToFind(
subtitleIndex,
MediaStreamType.SUBTITLE,
playerBackend,
embeddedSubtitleCount,
externalSubtitleCount,
subtitleIsExternal,
actualEmbeddedCount,
source,
)
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
// subtitleIndex - externalSubtitleCount + 1
groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.filter {
if (subtitleIsExternal) {
Timber.v(
"Chosen external subtitle ($subtitleIndex/) track: %s",
group,
)
group
} else {
// Not external
// Find the first index of a embedded subtitle to use as an offset for desired subtitle index
val firstEmbeddedSubtitleIndex =
source.mediaStreams
.orEmpty()
.indexOfFirstOrNull {
it.type == MediaStreamType.SUBTITLE &&
!(it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal)
}
if (firstEmbeddedSubtitleIndex != null) {
val indexToFind = subtitleIndex - firstEmbeddedSubtitleIndex
val subtitleTracks =
groups.filter { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.none {
group.getTrackFormat(0).id?.contains("e:") == true
} else {
group.getTrackFormat(0).id?.contains("e:") == false
}
}.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
}
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
}
Timber.v(
"Chosen eembedded subtitle ($subtitleIndex/$indexToFind) track: %s",
subtitleTracks.getOrNull(indexToFind),
)
subtitleTracks.getOrNull(indexToFind)
} else {
null
}
}
chosenTrack != null
} else {
false
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
}
chosenTrack != null
} else {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
@ -106,26 +98,24 @@ object TrackSelectionUtils {
}
val audioSelected =
if (audioIndex != null && supportsDirectPlay) {
val indexToFind =
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
embeddedSubtitleCount,
externalSubtitleCount,
false,
null,
source,
)
// Find the first index of an audio stream to use as an offset for desired index
val firstAudioIndex =
source.mediaStreams
.orEmpty()
.indexOfFirstOrNull { it.type == MediaStreamType.AUDIO }
val chosenTrack =
groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
if (firstAudioIndex != null) {
val indexToFind = audioIndex - firstAudioIndex
val audioGroups =
groups.filter { group -> group.type == C.TRACK_TYPE_AUDIO && group.isSupported }
Timber.v(
"Chosen audio ($audioIndex/$indexToFind) track: %s",
audioGroups.getOrNull(indexToFind),
)
audioGroups.getOrNull(indexToFind)
} else {
null
}
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
@ -142,76 +132,8 @@ object TrackSelectionUtils {
}
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
}
/**
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
actualEmbeddedCount: Int?,
source: MediaSourceInfo,
): Int =
when (playerBackend) {
PlayerBackend.EXO_PLAYER,
PlayerBackend.UNRECOGNIZED,
-> {
serverIndex - externalSubtitleCount + 1
}
// TODO MPV could use literal indexes because they are stored in the track format ID
PlayerBackend.PREFER_MPV,
PlayerBackend.MPV,
-> {
when (type) {
MediaStreamType.VIDEO -> {
serverIndex - externalSubtitleCount + 1
}
MediaStreamType.AUDIO -> {
val videoStreamsBeforeAudioCount =
source.mediaStreams
.orEmpty()
.indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount
serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1
}
MediaStreamType.SUBTITLE -> {
if (subtitleIsExternal) {
// 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
}
}
else -> {
throw UnsupportedOperationException("Cannot calculate index for $type")
}
}
}
}
}
val Format.idAsInt: Int?
@OptIn(UnstableApi::class)
get() =
id?.let {
if (it.contains(":")) {
it.split(":").last().toIntOrNull()
} else {
it.toIntOrNull()
}
}
/**
* Returns the number of external subtitle streams there are
*/

View file

@ -7,7 +7,6 @@ import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.ui.playback.idAsInt
import timber.log.Timber
import java.util.Locale
@ -159,7 +158,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> =
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
add(
TrackSupport(
format.id + " (${format.idAsInt})",
format.id,
type,
reason,
it.isSelected,

View file

@ -235,6 +235,77 @@ class TestTrackSelection {
return Tracks(groups)
}
/**
* Builds the tracks for the `no_embedded_subs.json` for the given backend but skips a track ID
*
* Note: This is manual based on observation & code review of the playback for that file
*/
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 TrackSelectionParameters.getAudioOverride(): Format? {
this.overrides.forEach { (trackGroup, trackSelectionOverride) ->
if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
@ -588,4 +659,66 @@ 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,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
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,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
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)
}
}
}