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

@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
@ -614,231 +615,257 @@ class PlaybackViewModel
userInitiated: Boolean, userInitiated: Boolean,
enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
) = withContext(WholphinDispatchers.IO) { ): Unit =
val itemId = item.id withContext(WholphinDispatchers.IO) {
val itemId = item.id
val currentPlayback = state.value.currentPlayback val currentPlayback = state.value.currentPlayback
if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { if (currentPlayback != null &&
val wasSuccessful = currentPlayback.item.id == item.id &&
changeStreamsDirectPlay( currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
currentPlayback = currentPlayback, enableDirectPlay
currentItemPlayback = currentItemPlayback, ) {
audioIndex = audioIndex, val wasSuccessful =
subtitleIndex = subtitleIndex, changeStreamsDirectPlay(
userInitiated = userInitiated, currentPlayback = currentPlayback,
) currentItemPlayback = currentItemPlayback,
if (wasSuccessful) return@withContext audioIndex = audioIndex,
} subtitleIndex = subtitleIndex,
userInitiated = userInitiated,
Timber.d(
"changeStreams: userInitiated=$userInitiated, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex, " +
"enableDirectPlay=$enableDirectPlay, enableDirectStream=$enableDirectStream, positionMs=$positionMs",
)
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by
api.mediaInfoApi
.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile =
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences,
serverRepository.currentServer?.serverVersion,
)
} else {
mpvDeviceProfile
},
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
alwaysBurnInSubtitleWhenTranscoding = false,
maxStreamingBitrate = maxBitrate.toInt(),
enableDirectPlay = enableDirectPlay,
enableDirectStream = enableDirectStream,
allowVideoStreamCopy = enableDirectStream,
allowAudioStreamCopy = enableDirectStream,
enableTranscoding = true,
autoOpenLiveStream = true,
),
)
if (response.errorCode != null) {
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
return@withContext
}
val source = response.mediaSources.firstOrNull()
source?.let { source ->
val mediaUrl =
if (source.supportsDirectPlay) {
if (source.isRemote && source.path.isNotNullOrBlank()) {
Timber.i("Playback is remote for source: %s", source.id)
source.path
} else {
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = source.id,
static = true,
tag = source.eTag,
playSessionId = response.playSessionId,
)
}
} else if (source.supportsDirectStream) {
source.transcodingUrl?.let(api::createUrl)
} else {
source.transcodingUrl?.let(api::createUrl)
}
if (mediaUrl.isNullOrBlank()) {
_state.update {
it.copy(
loading =
LoadingState.Error(
"Unable to get media URL from the server. Do you have permission to view and/or transcode?",
),
) )
} if (wasSuccessful) return@withContext
}
Timber.i(
"changeStreams (%s): userInitiated=%s, audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
itemId,
userInitiated,
audioIndex,
subtitleIndex,
enableDirectPlay,
enableDirectStream,
positionMs,
)
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by
api.mediaInfoApi
.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile =
if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) {
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences,
serverRepository.currentServer?.serverVersion,
)
} else {
mpvDeviceProfile
},
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
mediaSourceId = currentItemPlayback.sourceId?.toServerString(),
alwaysBurnInSubtitleWhenTranscoding = false,
maxStreamingBitrate = maxBitrate.toInt(),
enableDirectPlay = enableDirectPlay,
enableDirectStream = enableDirectStream,
allowVideoStreamCopy = enableDirectStream,
allowAudioStreamCopy = enableDirectStream,
enableTranscoding = true,
autoOpenLiveStream = true,
),
)
if (response.errorCode != null) {
_state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) }
return@withContext return@withContext
} }
val transcodeType = val source = response.mediaSources.firstOrNull()
when { source?.let { source ->
// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY val mediaUrl =
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY if (source.supportsDirectPlay) {
if (source.isRemote && source.path.isNotNullOrBlank()) {
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM Timber.i("Playback is remote for source: %s", source.id)
source.path
source.supportsTranscoding -> PlayMethod.TRANSCODE } else {
api.videosApi.getVideoStreamUrl(
else -> throw Exception("No supported playback method") itemId = itemId,
} mediaSourceId = source.id,
Timber.i("Playback decision for $itemId: $transcodeType") static = true,
tag = source.eTag,
val externalSubtitleCount = source.externalSubtitlesCount playSessionId = response.playSessionId,
)
val externalSubtitle =
source.findExternalSubtitle(subtitleIndex)?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setLabel(it.title)
.setSelectionFlags(flags)
.build()
}
}
Timber.v("subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle")
val mediaItem =
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setMediaMetadata(
item.toMediaMetadata(
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
useSeriesForPrimary = true,
),
),
).setUri(mediaUrl.toUri())
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
.apply {
when (source.container) {
Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8)
Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD)
} }
}.build() } else if (source.supportsDirectStream) {
source.transcodingUrl?.let(api::createUrl)
val playback = } else {
CurrentPlayback( source.transcodingUrl?.let(api::createUrl)
item = item, }
tracks = listOf(), if (mediaUrl.isNullOrBlank()) {
backend = currentPlayer.value!!.backend, _state.update {
playMethod = transcodeType, it.copy(
playSessionId = response.playSessionId, loading =
liveStreamId = source.liveStreamId, LoadingState.Error(
mediaSourceInfo = source, "Unable to get media URL from the server. Do you have permission to view and/or transcode?",
) ),
preferences.appPreferences.playbackPreferences.let { prefs ->
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let { stream ->
refreshRateService.changeRefreshRate(
stream = stream,
switchRefreshRate = prefs.refreshRateSwitching,
switchResolution = prefs.resolutionSwitching,
) )
} }
} return@withContext
withContext(WholphinDispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
} }
val transcodeType =
when {
// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
val playbackItemState = PlaybackItemState(playback, currentItemPlayback) source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
val activityListener =
TrackActivityPlaybackListener(
api = api,
player = player,
getState = { playbackItemState },
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
_state.update { source.supportsTranscoding -> PlayMethod.TRANSCODE
it.copy(
loading = LoadingState.Success, else -> throw Exception("No supported playback method")
currentPlayback = playback, }
) Timber.i("Playback decision for $itemId: $transcodeType")
}
player.setMediaItem( val externalSubtitleCount = source.externalSubtitlesCount
mediaItem,
positionMs, val externalSubtitle =
source.findExternalSubtitle(subtitleIndex)?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("e:${it.index}")
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setLabel(it.title)
.setSelectionFlags(flags)
.build()
}
}
Timber.v(
"subtitleIndex=$subtitleIndex, externalSubtitleCount=$externalSubtitleCount, externalSubtitle=$externalSubtitle",
) )
if (audioIndex != null || subtitleIndex != null) {
val onTracksChangedListener = val mediaItem =
object : Player.Listener { MediaItem
override fun onTracksChanged(tracks: Tracks) { .Builder()
Timber.v("onTracksChanged: $tracks") .setMediaId(itemId.toString())
if (tracks.groups.isNotEmpty()) { .setMediaMetadata(
val result = item.toMediaMetadata(
TrackSelectionUtils.createTrackSelections( imageUrlService.getItemImageUrl(
player.trackSelectionParameters, item,
player.currentTracks, ImageType.PRIMARY,
currentPlayer.value!!.backend, useSeriesForPrimary = true,
source.supportsDirectPlay, ),
audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, ),
subtitleIndex, ).setUri(mediaUrl.toUri())
source, .setSubtitleConfigurations(listOfNotNull(externalSubtitle))
) .apply {
Timber.v("onTracksChanged: %s", result) when (source.container) {
if (result.bothSelected) { Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8)
player.trackSelectionParameters = Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD)
result.trackSelectionParameters }
}.build()
val playback =
CurrentPlayback(
item = item,
tracks = listOf(),
backend = currentPlayer.value!!.backend,
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
mediaSourceInfo = source,
)
preferences.appPreferences.playbackPreferences.let { prefs ->
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let { stream ->
refreshRateService.changeRefreshRate(
stream = stream,
switchRefreshRate = prefs.refreshRateSwitching,
switchResolution = prefs.resolutionSwitching,
)
}
}
withContext(WholphinDispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
}
val playbackItemState = PlaybackItemState(playback, currentItemPlayback)
val activityListener =
TrackActivityPlaybackListener(
api = api,
player = player,
getState = { playbackItemState },
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
_state.update {
it.copy(
loading = LoadingState.Success,
currentPlayback = playback,
)
}
player.setMediaItem(
mediaItem,
positionMs,
)
if (transcodeType == PlayMethod.DIRECT_PLAY && (audioIndex != null || subtitleIndex != null)) {
val onTracksChangedListener =
object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) {
val result =
TrackSelectionUtils.createTrackSelections(
player.trackSelectionParameters,
player.currentTracks,
audioIndex,
subtitleIndex,
source,
)
Timber.v("onTracksChanged: %s", result)
player.removeListener(this) player.removeListener(this)
if (result.bothSelected) {
player.trackSelectionParameters =
result.trackSelectionParameters
} else {
// Fall back to transcoding
Timber.w("Failed to select tracks, falling back to transcoding")
viewModelScope.launchIO {
changeStreams(
item = item,
currentItemPlayback = currentItemPlayback,
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
positionMs = positionMs,
userInitiated = userInitiated,
enableDirectPlay = false,
enableDirectStream = true,
)
}
}
viewModelScope.launchIO { loadSubtitleDelay() }
} }
viewModelScope.launchIO { loadSubtitleDelay() }
} }
} }
} player.addListener(onTracksChangedListener)
player.addListener(onTracksChangedListener) }
} }
} }
} }
}
/** /**
* If direct playing, can try to switch tracks without playback restarting * If direct playing, can try to switch tracks without playback restarting
@ -853,12 +880,11 @@ class PlaybackViewModel
userInitiated: Boolean, userInitiated: Boolean,
): Boolean = ): Boolean =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
// TODO there's probably no reason why we can't add external subtitles?
Timber.v("changeStreams direct play") Timber.v("changeStreams direct play")
// TODO Better way to handle unsupported types in general is needed // TODO Better way to handle unsupported types in general is needed
// This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3
if (preferences.appPreferences.experimentalPreferences.preferAc3Surround && audioIndex != null) { if (preferences.appPreferences.experimentalPreferences.enabled { preferAc3Surround } && audioIndex != null) {
currentPlayback.mediaSourceInfo.mediaStreams currentPlayback.mediaSourceInfo.mediaStreams
.orEmpty() .orEmpty()
.firstOrNull { it.index == audioIndex } .firstOrNull { it.index == audioIndex }
@ -873,15 +899,12 @@ class PlaybackViewModel
val source = currentPlayback.mediaSourceInfo val source = currentPlayback.mediaSourceInfo
val externalSubtitle = source.findExternalSubtitle(subtitleIndex) val externalSubtitle = source.findExternalSubtitle(subtitleIndex)
// TODO there's probably no reason why we can't add external subtitles?
if (externalSubtitle == null) { if (externalSubtitle == null) {
val result = val result =
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
TrackSelectionUtils.createTrackSelections( TrackSelectionUtils.createTrackSelections(
onMain { player.trackSelectionParameters }, onMain { player.trackSelectionParameters },
onMain { player.currentTracks }, onMain { player.currentTracks },
currentPlayer.value!!.backend,
true,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
source, source,
@ -916,7 +939,7 @@ class PlaybackViewModel
it.copy( it.copy(
currentPlayback = currentPlayback =
(it.currentPlayback ?: currentPlayback).copy( (it.currentPlayback ?: currentPlayback).copy(
tracks = checkForSupport(player.currentTracks), tracks = checkForSupport(onMain { player.currentTracks }),
), ),
) )
} }

View file

@ -7,13 +7,11 @@ 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.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 kotlin.math.max
/** /**
* Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player]
@ -23,201 +21,152 @@ object TrackSelectionUtils {
fun createTrackSelections( fun createTrackSelections(
trackSelectionParams: TrackSelectionParameters, trackSelectionParams: TrackSelectionParameters,
tracks: Tracks, tracks: Tracks,
playerBackend: PlayerBackend,
supportsDirectPlay: Boolean,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
source: MediaSourceInfo, source: MediaSourceInfo,
): TrackSelectionResult { ): TrackSelectionResult {
val embeddedSubtitleCount = source.embeddedSubtitleCount
val externalSubtitleCount = source.externalSubtitlesCount
val paramsBuilder = trackSelectionParams.buildUpon() val paramsBuilder = trackSelectionParams.buildUpon()
val groups = tracks.groups
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) { tracks.groups.firstOrNull { group ->
groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isExternal
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.mapNotNull {
group.getTrackFormat(it).id
}.any { it.endsWith("e:$subtitleIndex") }
}
} 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) {
group.getTrackFormat(0).id?.contains("e:") == true
} else {
group.getTrackFormat(0).id?.contains("e:") == false
}
}.map {
group.getTrackFormat(it).idAsInt
}.contains(indexToFind)
}
} }
} else {
Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") val playerIndex =
chosenTrack?.let { getPlayerIndex(subtitleIndex, source, MediaStreamType.SUBTITLE)
if (playerIndex != null) {
tracks.groups
.filter { group ->
group.type == C.TRACK_TYPE_TEXT &&
group.length >= 1 &&
!group.isExternal
}
// TODO why are exoplayer tracks out of order sometimes?
.sortedById()
.getOrNull(playerIndex)
} else {
null
}
}
when {
chosenTrack != null && chosenTrack.isSupported -> {
paramsBuilder paramsBuilder
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType( .setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0),
chosenTrack.mediaTrackGroup,
0,
),
) )
TrackSelected.SELECTED
}
chosenTrack != null && !chosenTrack.isSupported -> {
TrackSelected.UNSUPPORTED
}
else -> {
TrackSelected.NOT_FOUND
} }
chosenTrack != null
} else {
false
} }
} else { } else {
paramsBuilder paramsBuilder
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
TrackSelected.SELECTED
true
} }
val audioSelected = val audioSelected =
if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { if (audioIndex != null && audioIndex >= 0) {
val indexToFind = val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO)
calculateIndexToFind(
audioIndex,
MediaStreamType.AUDIO,
playerBackend,
embeddedSubtitleCount,
externalSubtitleCount,
false,
null,
source,
)
val chosenTrack = val chosenTrack =
groups.firstOrNull { group -> if (playerIndex != null) {
group.type == C.TRACK_TYPE_AUDIO && group.isSupported && tracks.groups
(0..<group.mediaTrackGroup.length) .filter { group ->
.map { group.type == C.TRACK_TYPE_AUDIO && group.length >= 1
group.getTrackFormat(it).idAsInt }
}.contains(indexToFind) // TODO why are exoplayer tracks out of order sometimes?
.sortedById()
.getOrNull(playerIndex)
} else {
null
}
when {
chosenTrack != null && chosenTrack.isSupported -> {
paramsBuilder
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0),
)
TrackSelected.SELECTED
}
chosenTrack != null && !chosenTrack.isSupported -> {
TrackSelected.UNSUPPORTED
}
else -> {
TrackSelected.NOT_FOUND
} }
Timber.v("Chosen audio ($audioIndex/$indexToFind) track: $chosenTrack")
chosenTrack?.let {
paramsBuilder
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
)
} }
chosenTrack != null
} else { } else {
true TrackSelected.SELECTED
} }
return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected)
} }
/** private fun getPlayerIndex(
* Maps the server provided index to the track index based on the [PlayerBackend] and other stream information
*/
private fun calculateIndexToFind(
serverIndex: Int, serverIndex: Int,
type: MediaStreamType,
playerBackend: PlayerBackend,
embeddedSubtitleCount: Int,
externalSubtitleCount: Int,
subtitleIsExternal: Boolean,
actualEmbeddedCount: Int?,
source: MediaSourceInfo, source: MediaSourceInfo,
): Int = type: MediaStreamType,
when (playerBackend) { ): Int? {
PlayerBackend.EXO_PLAYER, val playerIndex =
PlayerBackend.UNRECOGNIZED, source.mediaStreams
-> { .orEmpty()
serverIndex - externalSubtitleCount + 1 .filter { it.type == type }
} .let {
if (type == MediaStreamType.SUBTITLE) {
// TODO MPV could use literal indexes because they are stored in the track format ID it.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS }
PlayerBackend.PREFER_MPV, } else {
PlayerBackend.MPV, it
-> {
when (type) {
MediaStreamType.VIDEO -> {
serverIndex - externalSubtitleCount + 1
} }
}.indexOfFirstOrNull { it.index == serverIndex }
return playerIndex
}
MediaStreamType.AUDIO -> { val Tracks.Group.trackFormats: List<Format>
val videoStreamsBeforeAudioCount = @OptIn(UnstableApi::class)
source.mediaStreams get() =
.orEmpty() (0..<mediaTrackGroup.length)
.indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount .mapNotNull {
serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 getTrackFormat(it)
}
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")
}
} }
private val Tracks.Group.isExternal: Boolean
get() =
trackFormats.any {
it.id?.contains(":e:") == true
} }
PlayerBackend.EXTERNAL_PLAYER -> { private fun Iterable<Tracks.Group>.sortedById(): List<Tracks.Group> =
throw IllegalStateException("Cannot calculate tracks external playback") mapNotNull { track ->
} if (track.isExternal) {
} // Should be filtered out before calling this though
} track to listOf(Int.MAX_VALUE, Int.MAX_VALUE)
val Format.idAsInt: Int?
@OptIn(UnstableApi::class)
get() =
id?.let {
if (it.contains(":")) {
it.split(":").last().toIntOrNull()
} else { } else {
it.toIntOrNull() track.trackFormats[0]
.id
?.split(":")
?.map { it.toInt() }
?.let {
track to it
}
} }
} }.sortedWith(compareBy<Pair<Tracks.Group, List<Int>>> { it.second[0] }.thenBy { it.second[1] })
.map { it.first }
}
/** /**
* Returns the number of external subtitle streams there are * Returns the number of external subtitle streams there are
@ -270,8 +219,18 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
*/ */
data class TrackSelectionResult( data class TrackSelectionResult(
val trackSelectionParameters: TrackSelectionParameters, val trackSelectionParameters: TrackSelectionParameters,
val audioSelected: Boolean, val audio: TrackSelected,
val subtitleSelected: Boolean, val subtitle: TrackSelected,
) { ) {
val bothSelected: Boolean = audioSelected && subtitleSelected val audioSelected: Boolean get() = audio == TrackSelected.SELECTED
val subtitleSelected: Boolean get() = subtitle == TrackSelected.SELECTED
val bothSelected: Boolean get() = audioSelected && subtitleSelected
}
enum class TrackSelected {
SELECTED,
NOT_FOUND,
UNSUPPORTED,
} }

View file

@ -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,

View file

@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.test package com.github.damontecres.wholphin.test
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.TrackSelectionParameters
import com.github.damontecres.wholphin.preferences.PlayerBackend 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.TrackSelectionResult
import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
@ -36,11 +38,9 @@ class TestSelectionTrackExamples {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = testTacks.toTracks(), tracks = testTacks.toTracks(),
playerBackend = playerBackend,
supportsDirectPlay = true,
audioIndex = audioIndex, audioIndex = audioIndex,
subtitleIndex = subtitleIndex, subtitleIndex = subtitleIndex,
source = testTacks.toMediaSourceInfo(), source = builder.buildMediaSourceInfo(),
).also(onResult) ).also(onResult)
} }
@ -111,4 +111,189 @@ class TestSelectionTrackExamples {
Assert.assertEquals("6:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) 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) 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 @Test
fun `test MPV embedded`() { fun `test MPV embedded`() {
val resource = javaClass.classLoader?.getResource("embedded_subs.json") val resource = javaClass.classLoader?.getResource("embedded_subs.json")
@ -250,8 +399,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1, audioIndex = 1,
subtitleIndex = 4, subtitleIndex = 4,
source = source, source = source,
@ -265,8 +412,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2, audioIndex = 2,
subtitleIndex = 4, subtitleIndex = 4,
source = source, source = source,
@ -280,8 +425,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 1, audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED, subtitleIndex = TrackIndex.DISABLED,
source = source, source = source,
@ -307,8 +450,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1, audioIndex = 1,
subtitleIndex = 4, subtitleIndex = 4,
source = source, source = source,
@ -322,8 +463,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2, audioIndex = 2,
subtitleIndex = 4, subtitleIndex = 4,
source = source, source = source,
@ -337,8 +476,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2, audioIndex = 2,
subtitleIndex = 6, subtitleIndex = 6,
source = source, source = source,
@ -352,8 +489,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 1, audioIndex = 1,
subtitleIndex = TrackIndex.DISABLED, subtitleIndex = TrackIndex.DISABLED,
source = source, source = source,
@ -379,8 +514,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 2, audioIndex = 2,
subtitleIndex = 0, subtitleIndex = 0,
source = source, source = source,
@ -394,8 +527,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 0, subtitleIndex = 0,
source = source, source = source,
@ -421,8 +552,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 2, audioIndex = 2,
subtitleIndex = 0, subtitleIndex = 0,
source = source, source = source,
@ -448,8 +577,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 0, subtitleIndex = 0,
source = source, source = source,
@ -465,8 +592,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.MPV,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 5, subtitleIndex = 5,
source = source, source = source,
@ -500,8 +625,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 0, subtitleIndex = 0,
source = source, source = source,
@ -523,8 +646,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 5, subtitleIndex = 5,
source = source, source = source,
@ -551,8 +672,6 @@ class TestTrackSelection {
.createTrackSelections( .createTrackSelections(
trackSelectionParams = trackSelectionParameters, trackSelectionParams = trackSelectionParameters,
tracks = tracks, tracks = tracks,
playerBackend = PlayerBackend.EXO_PLAYER,
supportsDirectPlay = true,
audioIndex = 3, audioIndex = 3,
subtitleIndex = 2, subtitleIndex = 2,
source = source, 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? { 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.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
@ -18,7 +19,7 @@ import org.junit.Test
* *
* @see Builder * @see Builder
*/ */
class TestTracks private constructor( class TestTracks(
val tracks: List<TestTrack> = emptyList(), val tracks: List<TestTrack> = emptyList(),
) { ) {
/** /**
@ -27,6 +28,7 @@ class TestTracks private constructor(
fun toTracks(): Tracks { fun toTracks(): Tracks {
val groups = val groups =
tracks tracks
.sortedBy { it.external }
.map { .map {
val mimeType = val mimeType =
when (it.type) { when (it.type) {
@ -35,55 +37,21 @@ class TestTracks private constructor(
MediaStreamType.SUBTITLE -> "text/sample" MediaStreamType.SUBTITLE -> "text/sample"
else -> throw UnsupportedOperationException("${it.type}") else -> throw UnsupportedOperationException("${it.type}")
} }
Format val format =
.Builder() Format
.setId(it.id) .Builder()
.setSampleMimeType(mimeType) .setId(it.id)
.build() .setSampleMimeType(mimeType)
}.map { TrackGroup(it) } .build()
.map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } Tracks.Group(
return Tracks(groups) TrackGroup(format),
} false,
intArrayOf(it.support),
booleanArrayOf(false),
)
}
fun toMediaSourceInfo(): MediaSourceInfo { return Tracks(groups)
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
} }
/** /**
@ -92,28 +60,24 @@ class TestTracks private constructor(
class Builder { class Builder {
private val tracks = mutableListOf<TestTrackBuilder>() private val tracks = mutableListOf<TestTrackBuilder>()
fun addVideo(): Builder { fun addVideo(): Builder = addTrack(1, MediaStreamType.VIDEO, false, C.FORMAT_HANDLED)
tracks.add(TestTrackBuilder(MediaStreamType.VIDEO))
return this
}
fun addAudio(count: Int = 1): Builder { fun addAudio(count: Int = 1): Builder = addTrack(count, MediaStreamType.AUDIO, false, C.FORMAT_HANDLED)
repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.AUDIO))
}
return this
}
fun addSubtitle(count: Int = 1): Builder { fun addSubtitle(count: Int = 1): Builder = addTrack(count, MediaStreamType.SUBTITLE, false, C.FORMAT_HANDLED)
repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, false))
}
return this
}
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) { repeat(count) {
tracks.add(TestTrackBuilder(MediaStreamType.SUBTITLE, true)) tracks.add(TestTrackBuilder(type, external, support))
} }
return this return this
} }
@ -129,7 +93,10 @@ class TestTracks private constructor(
var externalSubCount = 0 var externalSubCount = 0
val t = val t =
tracks tracks
.mapIndexed { index, track -> .mapIndexedNotNull { index, track ->
if (track.type == null) {
return@mapIndexedNotNull null
}
if (track.external) { if (track.external) {
externalSubCount++ externalSubCount++
TestTrack( TestTrack(
@ -137,14 +104,27 @@ class TestTracks private constructor(
index, index,
track.type, track.type,
track.external, track.external,
track.support,
) )
} else if (hasExternal) { } else if (hasExternal) {
val idx = index + 1 - externalSubCount val idx = index + 1 - externalSubCount
// If there's a sidecar file, the actual file // 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 { } else {
val idx = index + 1 val idx = index + 1
TestTrack("$idx", index, track.type, track.external) TestTrack(
"$idx",
index,
track.type,
track.external,
track.support,
)
} }
} }
addAll(t) addAll(t)
@ -161,20 +141,29 @@ class TestTracks private constructor(
tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external } tracks.count { it.type == MediaStreamType.SUBTITLE && !it.external }
val externalSubCount = val externalSubCount =
tracks.count { it.type == MediaStreamType.SUBTITLE && it.external } 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 videoCount = 0
var audioCount = 0 var audioCount = 0
var subtitleCount = 0 var subtitleCount = 0
val testTracks = val testTracks =
tracks.mapIndexed { index, track -> tracks.mapIndexedNotNull { index, track ->
if (track.type == null) {
return@mapIndexedNotNull null
}
val id = val id =
if (track.external) { if (track.external) {
// MPV places external subtitles last // MPV places external subtitles last
val idx = embeddedCount + index val idx = index + if (externalSubtitleAreFirst) embeddedCount else 0
subtitleCount++ subtitleCount++
val count = embeddedSubtitleCount + subtitleCount val count =
subtitleCount + if (externalSubtitleAreFirst) embeddedSubtitleCount else 0
"$idx:e:$count" "$idx:e:$count"
} else { } else {
val idx = index - externalSubCount val idx =
if (externalSubtitleAreFirst) index - externalSubCount else index
when (track.type) { when (track.type) {
MediaStreamType.AUDIO -> { MediaStreamType.AUDIO -> {
audioCount++ audioCount++
@ -188,7 +177,8 @@ class TestTracks private constructor(
MediaStreamType.SUBTITLE -> { MediaStreamType.SUBTITLE -> {
subtitleCount++ subtitleCount++
val count = subtitleCount - externalSubCount val count =
subtitleCount - (if (externalSubtitleAreFirst) externalSubCount else 0)
"$idx:$count" "$idx:$count"
} }
@ -201,6 +191,55 @@ class TestTracks private constructor(
} }
return TestTracks(testTracks) 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 { companion object {
@ -223,11 +262,13 @@ data class TestTrack(
val index: Int, val index: Int,
val type: MediaStreamType, val type: MediaStreamType,
val external: Boolean, val external: Boolean,
@param:C.FormatSupport val support: Int = C.FORMAT_HANDLED,
) )
class TestTrackBuilder( class TestTrackBuilder(
val type: MediaStreamType, val type: MediaStreamType?,
val external: Boolean = false, val external: Boolean = false,
@param:C.FormatSupport val support: Int = C.FORMAT_HANDLED,
) )
fun assertIdType( fun assertIdType(
@ -473,4 +514,80 @@ class TestTracksTests {
assertIdType("5:3", MediaStreamType.SUBTITLE, mpv[5]) 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() .addVideo()
.addAudio() .addAudio()
.addSubtitle() .addSubtitle()
.buildForExoPlayer() .buildMediaSourceInfo()
.toMediaSourceInfo()
private val playSessionId = "playsessionid12345" private val playSessionId = "playsessionid12345"
private val videoStreamUrl = "http://localhost:8096/video/stream" private val videoStreamUrl = "http://localhost:8096/video/stream"

View file

@ -165,7 +165,8 @@
"IsExternal": false, "IsExternal": false,
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Level": 0 "Level": 0,
"DeliveryMethod": "Embed"
}, },
{ {
"Codec": "ass", "Codec": "ass",
@ -193,7 +194,8 @@
"IsExternal": false, "IsExternal": false,
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Level": 0 "Level": 0,
"DeliveryMethod": "Embed"
}, },
{ {
"Codec": "ass", "Codec": "ass",
@ -221,7 +223,8 @@
"IsExternal": false, "IsExternal": false,
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Level": 0 "Level": 0,
"DeliveryMethod": "Embed"
} }
], ],
"MediaAttachments": [], "MediaAttachments": [],

View file

@ -50,7 +50,8 @@
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Path": "", "Path": "",
"Level": 0 "Level": 0,
"DeliveryMethod": "External"
}, },
{ {
"Codec": "subrip", "Codec": "subrip",
@ -79,7 +80,8 @@
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Path": "", "Path": "",
"Level": 0 "Level": 0,
"DeliveryMethod": "External"
}, },
{ {
"Codec": "subrip", "Codec": "subrip",
@ -107,7 +109,8 @@
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Path": "", "Path": "",
"Level": 0 "Level": 0,
"DeliveryMethod": "External"
}, },
{ {
"Codec": "eac3", "Codec": "eac3",
@ -194,7 +197,8 @@
"IsExternal": false, "IsExternal": false,
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Level": 0 "Level": 0,
"DeliveryMethod": "Embed"
} }
], ],
"MediaAttachments": [], "MediaAttachments": [],

View file

@ -50,7 +50,8 @@
"IsTextSubtitleStream": true, "IsTextSubtitleStream": true,
"SupportsExternalStream": true, "SupportsExternalStream": true,
"Path": "", "Path": "",
"Level": 0 "Level": 0,
"DeliveryMethod": "External"
}, },
{ {
"Codec": "hevc", "Codec": "hevc",