From 26124a534828bbcac6f4875ee8aeabed7ad3eef6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 10 Jul 2026 17:49:47 -0400 Subject: [PATCH] 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 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 449 +++++++++--------- .../ui/playback/TrackSelectionUtils.kt | 287 +++++------ .../damontecres/wholphin/util/TrackSupport.kt | 3 +- .../test/TestSelectionTrackExamples.kt | 191 +++++++- .../wholphin/test/TestTrackSelection.kt | 286 +++++++++-- .../damontecres/wholphin/test/TestTracks.kt | 269 ++++++++--- .../ui/playback/PlaybackViewModelTests.kt | 3 +- app/src/test/resources/embedded_subs.json | 9 +- app/src/test/resources/external_subs.json | 12 +- app/src/test/resources/no_embedded_subs.json | 3 +- 10 files changed, 1014 insertions(+), 498 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 94529bd6..677200e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ShowNextUpWhen import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior 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.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService @@ -614,231 +615,257 @@ class PlaybackViewModel userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - ) = withContext(WholphinDispatchers.IO) { - val itemId = item.id + ): Unit = + withContext(WholphinDispatchers.IO) { + val itemId = item.id - val currentPlayback = state.value.currentPlayback - if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - currentItemPlayback = currentItemPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - userInitiated = userInitiated, - ) - if (wasSuccessful) return@withContext - } - - 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?", - ), + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null && + currentPlayback.item.id == item.id && + currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && + enableDirectPlay + ) { + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + userInitiated = userInitiated, ) - } + 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 } - val transcodeType = - when { -// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY - source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - - source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - - source.supportsTranscoding -> PlayMethod.TRANSCODE - - else -> throw Exception("No supported playback method") - } - Timber.i("Playback decision for $itemId: $transcodeType") - - val externalSubtitleCount = source.externalSubtitlesCount - - 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) + 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, + ) } - }.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, + } 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?", + ), ) } - } - withContext(WholphinDispatchers.Main) { - // TODO, don't need to release & recreate when switching streams - this@PlaybackViewModel.activityListener?.let { - it.release() - player.removeListener(it) + return@withContext } + val transcodeType = + when { +// playerBackend == PlayerBackend.MPV -> PlayMethod.DIRECT_PLAY + source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY - val playbackItemState = PlaybackItemState(playback, currentItemPlayback) - val activityListener = - TrackActivityPlaybackListener( - api = api, - player = player, - getState = { playbackItemState }, - ) - player.addListener(activityListener) - this@PlaybackViewModel.activityListener = activityListener + source.supportsDirectStream -> PlayMethod.DIRECT_STREAM - _state.update { - it.copy( - loading = LoadingState.Success, - currentPlayback = playback, - ) - } - player.setMediaItem( - mediaItem, - positionMs, + source.supportsTranscoding -> PlayMethod.TRANSCODE + + else -> throw Exception("No supported playback method") + } + Timber.i("Playback decision for $itemId: $transcodeType") + + val externalSubtitleCount = source.externalSubtitlesCount + + 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 = - object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - Timber.v("onTracksChanged: $tracks") - if (tracks.groups.isNotEmpty()) { - val result = - TrackSelectionUtils.createTrackSelections( - player.trackSelectionParameters, - player.currentTracks, - currentPlayer.value!!.backend, - source.supportsDirectPlay, - audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, - subtitleIndex, - source, - ) - Timber.v("onTracksChanged: %s", result) - if (result.bothSelected) { - player.trackSelectionParameters = - result.trackSelectionParameters + + 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() + + 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) + 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 @@ -853,12 +880,11 @@ class PlaybackViewModel userInitiated: Boolean, ): Boolean = withContext(WholphinDispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") // 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 - if (preferences.appPreferences.experimentalPreferences.preferAc3Surround && audioIndex != null) { + if (preferences.appPreferences.experimentalPreferences.enabled { preferAc3Surround } && audioIndex != null) { currentPlayback.mediaSourceInfo.mediaStreams .orEmpty() .firstOrNull { it.index == audioIndex } @@ -873,15 +899,12 @@ class PlaybackViewModel val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) - // TODO there's probably no reason why we can't add external subtitles? if (externalSubtitle == null) { val result = withContext(WholphinDispatchers.Main) { TrackSelectionUtils.createTrackSelections( onMain { player.trackSelectionParameters }, onMain { player.currentTracks }, - currentPlayer.value!!.backend, - true, audioIndex, subtitleIndex, source, @@ -916,7 +939,7 @@ class PlaybackViewModel it.copy( currentPlayback = (it.currentPlayback ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), + tracks = checkForSupport(onMain { player.currentTracks }), ), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index cfbbe6b7..c9a16e79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -7,13 +7,11 @@ 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 /** * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] @@ -23,201 +21,152 @@ object TrackSelectionUtils { fun createTrackSelections( trackSelectionParams: TrackSelectionParameters, tracks: Tracks, - playerBackend: PlayerBackend, - supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val embeddedSubtitleCount = source.embeddedSubtitleCount - val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = trackSelectionParams.buildUpon() - val groups = tracks.groups - val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null - if (subtitleIsExternal || supportsDirectPlay) { - val chosenTrack = - if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0.. - group.type == C.TRACK_TYPE_TEXT && - (0.. - group.type == C.TRACK_TYPE_TEXT && group.isSupported && - (0.. + group.type == C.TRACK_TYPE_TEXT && group.isExternal } - - Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") - chosenTrack?.let { + } else { + val playerIndex = + 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 + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) .setOverrideForType( - TrackSelectionOverride( - chosenTrack.mediaTrackGroup, - 0, - ), + TrackSelectionOverride(chosenTrack.mediaTrackGroup, 0), ) + TrackSelected.SELECTED + } + + chosenTrack != null && !chosenTrack.isSupported -> { + TrackSelected.UNSUPPORTED + } + + else -> { + TrackSelected.NOT_FOUND } - chosenTrack != null - } else { - false } } else { paramsBuilder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) - - true + TrackSelected.SELECTED } + val audioSelected = - if (audioIndex != null && audioIndex >= 0 && supportsDirectPlay) { - val indexToFind = - calculateIndexToFind( - audioIndex, - MediaStreamType.AUDIO, - playerBackend, - embeddedSubtitleCount, - externalSubtitleCount, - false, - null, - source, - ) + if (audioIndex != null && audioIndex >= 0) { + val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO) val chosenTrack = - groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_AUDIO && group.isSupported && - (0.. + group.type == C.TRACK_TYPE_AUDIO && group.length >= 1 + } + // 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 { - true + TrackSelected.SELECTED } 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( + private fun getPlayerIndex( 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 + type: MediaStreamType, + ): Int? { + val playerIndex = + source.mediaStreams + .orEmpty() + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE) { + it.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS } + } else { + it } + }.indexOfFirstOrNull { it.index == serverIndex } + return playerIndex + } - 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 Tracks.Group.trackFormats: List + @OptIn(UnstableApi::class) + get() = + (0.. { - throw IllegalStateException("Cannot calculate tracks external playback") - } - } -} - -val Format.idAsInt: Int? - @OptIn(UnstableApi::class) - get() = - id?.let { - if (it.contains(":")) { - it.split(":").last().toIntOrNull() + private fun Iterable.sortedById(): List = + mapNotNull { track -> + if (track.isExternal) { + // Should be filtered out before calling this though + track to listOf(Int.MAX_VALUE, Int.MAX_VALUE) } else { - it.toIntOrNull() + track.trackFormats[0] + .id + ?.split(":") + ?.map { it.toInt() } + ?.let { + track to it + } } - } + }.sortedWith(compareBy>> { it.second[0] }.thenBy { it.second[1] }) + .map { it.first } +} /** * Returns the number of external subtitle streams there are @@ -270,8 +219,18 @@ fun List.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = */ data class TrackSelectionResult( val trackSelectionParameters: TrackSelectionParameters, - val audioSelected: Boolean, - val subtitleSelected: Boolean, + val audio: TrackSelected, + 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, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt index 86b0f47e..f3ac966d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackSupport.kt @@ -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 = val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id + " (${format.idAsInt})", + format.id, type, reason, it.isSelected, diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt index e1bf9fc8..564ab7a9 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt @@ -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) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt index 956ddcd4..41db76aa 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -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(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.EXO_PLAYER) + val ids = tracks.groups.flatMap { g -> (0.. + 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(fileContents) + val tracks = buildMissingIdTracks(PlayerBackend.MPV) + val ids = tracks.groups.flatMap { g -> (0.. + 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? { diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt index ba8d0d53..f9b1eab2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt @@ -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 = 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() - 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]) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index d2d824db..33e7973d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -165,8 +165,7 @@ class PlaybackViewModelTests { .addVideo() .addAudio() .addSubtitle() - .buildForExoPlayer() - .toMediaSourceInfo() + .buildMediaSourceInfo() private val playSessionId = "playsessionid12345" private val videoStreamUrl = "http://localhost:8096/video/stream" diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json index f24aeefc..4a3c7666 100644 --- a/app/src/test/resources/embedded_subs.json +++ b/app/src/test/resources/embedded_subs.json @@ -165,7 +165,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -193,7 +194,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" }, { "Codec": "ass", @@ -221,7 +223,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json index 9f6a4b05..1ef202d4 100644 --- a/app/src/test/resources/external_subs.json +++ b/app/src/test/resources/external_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -79,7 +80,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "subrip", @@ -107,7 +109,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "eac3", @@ -194,7 +197,8 @@ "IsExternal": false, "IsTextSubtitleStream": true, "SupportsExternalStream": true, - "Level": 0 + "Level": 0, + "DeliveryMethod": "Embed" } ], "MediaAttachments": [], diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json index be4e3051..d746913e 100644 --- a/app/src/test/resources/no_embedded_subs.json +++ b/app/src/test/resources/no_embedded_subs.json @@ -50,7 +50,8 @@ "IsTextSubtitleStream": true, "SupportsExternalStream": true, "Path": "", - "Level": 0 + "Level": 0, + "DeliveryMethod": "External" }, { "Codec": "hevc",