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