mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Update track selection logic for missing IDs
This commit is contained in:
parent
abf5ec8004
commit
73206c37d0
3 changed files with 200 additions and 146 deletions
|
|
@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.TrackSelectionParameters
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
object TrackSelectionUtils {
|
object TrackSelectionUtils {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|
@ -26,8 +25,8 @@ object TrackSelectionUtils {
|
||||||
subtitleIndex: Int?,
|
subtitleIndex: Int?,
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
): TrackSelectionResult {
|
): TrackSelectionResult {
|
||||||
val embeddedSubtitleCount = source.embeddedSubtitleCount
|
// This function's implementation assumes that indexes for each MediaStream in the MediaSourceInfo
|
||||||
val externalSubtitleCount = source.externalSubtitlesCount
|
// will be ordered as: external subtitles, video stream, audio stream, embedded subtitles
|
||||||
|
|
||||||
val paramsBuilder = trackSelectionParams.buildUpon()
|
val paramsBuilder = trackSelectionParams.buildUpon()
|
||||||
val groups = tracks.groups
|
val groups = tracks.groups
|
||||||
|
|
@ -35,69 +34,62 @@ object TrackSelectionUtils {
|
||||||
val subtitleSelected =
|
val subtitleSelected =
|
||||||
if (subtitleIndex != null && subtitleIndex >= 0) {
|
if (subtitleIndex != null && subtitleIndex >= 0) {
|
||||||
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
|
val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null
|
||||||
if (subtitleIsExternal || supportsDirectPlay) {
|
val chosenTrack =
|
||||||
val chosenTrack =
|
if (subtitleIsExternal) {
|
||||||
if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) {
|
// If external, only one external track should exist, so just find it
|
||||||
|
val group =
|
||||||
groups.firstOrNull { group ->
|
groups.firstOrNull { group ->
|
||||||
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
||||||
(0..<group.mediaTrackGroup.length)
|
(0..<group.mediaTrackGroup.length)
|
||||||
.mapNotNull {
|
.any {
|
||||||
group.getTrackFormat(it).id
|
group.getTrackFormat(0).id?.contains("e:") == true
|
||||||
}.any { it.endsWith("e:$subtitleIndex") }
|
}
|
||||||
}
|
}
|
||||||
} else {
|
Timber.v(
|
||||||
val actualEmbeddedCount =
|
"Chosen external subtitle ($subtitleIndex/) track: %s",
|
||||||
groups
|
group,
|
||||||
.filter { group ->
|
)
|
||||||
group.type == C.TRACK_TYPE_TEXT &&
|
group
|
||||||
(0..<group.mediaTrackGroup.length)
|
} else {
|
||||||
.mapNotNull {
|
// Not external
|
||||||
group.getTrackFormat(it).id
|
// Find the first index of a embedded subtitle to use as an offset for desired subtitle index
|
||||||
}.none { it.contains("e:") }
|
val firstEmbeddedSubtitleIndex =
|
||||||
}.size
|
source.mediaStreams
|
||||||
val indexToFind =
|
.orEmpty()
|
||||||
calculateIndexToFind(
|
.indexOfFirstOrNull {
|
||||||
subtitleIndex,
|
it.type == MediaStreamType.SUBTITLE &&
|
||||||
MediaStreamType.SUBTITLE,
|
!(it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal)
|
||||||
playerBackend,
|
}
|
||||||
embeddedSubtitleCount,
|
if (firstEmbeddedSubtitleIndex != null) {
|
||||||
externalSubtitleCount,
|
val indexToFind = subtitleIndex - firstEmbeddedSubtitleIndex
|
||||||
subtitleIsExternal,
|
val subtitleTracks =
|
||||||
actualEmbeddedCount,
|
groups.filter { group ->
|
||||||
source,
|
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
||||||
)
|
(0..<group.mediaTrackGroup.length)
|
||||||
Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track")
|
.none {
|
||||||
// subtitleIndex - externalSubtitleCount + 1
|
|
||||||
groups.firstOrNull { group ->
|
|
||||||
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
|
|
||||||
(0..<group.mediaTrackGroup.length)
|
|
||||||
.filter {
|
|
||||||
if (subtitleIsExternal) {
|
|
||||||
group.getTrackFormat(0).id?.contains("e:") == true
|
group.getTrackFormat(0).id?.contains("e:") == true
|
||||||
} else {
|
|
||||||
group.getTrackFormat(0).id?.contains("e:") == false
|
|
||||||
}
|
}
|
||||||
}.map {
|
}
|
||||||
group.getTrackFormat(it).idAsInt
|
Timber.v(
|
||||||
}.contains(indexToFind)
|
"Chosen eembedded subtitle ($subtitleIndex/$indexToFind) track: %s",
|
||||||
}
|
subtitleTracks.getOrNull(indexToFind),
|
||||||
}
|
|
||||||
|
|
||||||
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack")
|
|
||||||
chosenTrack?.let {
|
|
||||||
paramsBuilder
|
|
||||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
|
||||||
.setOverrideForType(
|
|
||||||
TrackSelectionOverride(
|
|
||||||
chosenTrack.mediaTrackGroup,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
subtitleTracks.getOrNull(indexToFind)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
chosenTrack != null
|
chosenTrack?.let {
|
||||||
} else {
|
paramsBuilder
|
||||||
false
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.setOverrideForType(
|
||||||
|
TrackSelectionOverride(
|
||||||
|
chosenTrack.mediaTrackGroup,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
chosenTrack != null
|
||||||
} else {
|
} else {
|
||||||
paramsBuilder
|
paramsBuilder
|
||||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||||
|
|
@ -106,26 +98,24 @@ object TrackSelectionUtils {
|
||||||
}
|
}
|
||||||
val audioSelected =
|
val audioSelected =
|
||||||
if (audioIndex != null && supportsDirectPlay) {
|
if (audioIndex != null && supportsDirectPlay) {
|
||||||
val indexToFind =
|
// Find the first index of an audio stream to use as an offset for desired index
|
||||||
calculateIndexToFind(
|
val firstAudioIndex =
|
||||||
audioIndex,
|
source.mediaStreams
|
||||||
MediaStreamType.AUDIO,
|
.orEmpty()
|
||||||
playerBackend,
|
.indexOfFirstOrNull { it.type == MediaStreamType.AUDIO }
|
||||||
embeddedSubtitleCount,
|
|
||||||
externalSubtitleCount,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
val chosenTrack =
|
val chosenTrack =
|
||||||
groups.firstOrNull { group ->
|
if (firstAudioIndex != null) {
|
||||||
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
|
val indexToFind = audioIndex - firstAudioIndex
|
||||||
(0..<group.mediaTrackGroup.length)
|
val audioGroups =
|
||||||
.map {
|
groups.filter { group -> group.type == C.TRACK_TYPE_AUDIO && group.isSupported }
|
||||||
group.getTrackFormat(it).idAsInt
|
Timber.v(
|
||||||
}.contains(indexToFind)
|
"Chosen audio ($audioIndex/$indexToFind) track: %s",
|
||||||
|
audioGroups.getOrNull(indexToFind),
|
||||||
|
)
|
||||||
|
audioGroups.getOrNull(indexToFind)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
|
|
||||||
chosenTrack?.let {
|
chosenTrack?.let {
|
||||||
paramsBuilder
|
paramsBuilder
|
||||||
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||||
|
|
@ -142,76 +132,8 @@ object TrackSelectionUtils {
|
||||||
}
|
}
|
||||||
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
|
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
|
* Returns the number of external subtitle streams there are
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.github.damontecres.wholphin.ui.playback.idAsInt
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -159,7 +158,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> =
|
||||||
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
val reason = TrackSupportReason.fromInt(it.getTrackSupport(i))
|
||||||
add(
|
add(
|
||||||
TrackSupport(
|
TrackSupport(
|
||||||
format.id + " (${format.idAsInt})",
|
format.id,
|
||||||
type,
|
type,
|
||||||
reason,
|
reason,
|
||||||
it.isSelected,
|
it.isSelected,
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,77 @@ class TestTrackSelection {
|
||||||
return Tracks(groups)
|
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? {
|
private fun TrackSelectionParameters.getAudioOverride(): Format? {
|
||||||
this.overrides.forEach { (trackGroup, trackSelectionOverride) ->
|
this.overrides.forEach { (trackGroup, trackSelectionOverride) ->
|
||||||
if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue