diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt index 1baf1849..4259b46d 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt @@ -30,7 +30,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.model.api.DeviceProfile -import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.json.JSONArray @@ -159,11 +158,10 @@ class ExternalPlayer( } // Select correct subtitle - val selectedSubtitleIndex = source.subtitleStreams.binarySearchBy( - playOptions.subtitleStreamIndex, - selector = MediaStream::index, - ) - source.selectSubtitleStream(selectedSubtitleIndex) + val selectedSubtitleStream = playOptions.subtitleStreamIndex?.let { index -> + source.mediaStreams.getOrNull(index) + } + source.selectSubtitleStream(selectedSubtitleStream) // Build playback intent val playerIntent = Intent(Intent.ACTION_VIEW).apply { @@ -176,7 +174,7 @@ class ExternalPlayer( putExtra("return_result", true) putExtra("secure_uri", true) - val externalSubs = source.getExternalSubtitleStreams() + val externalSubs = source.externalSubtitleStreams val enabledSubUrl = when { source.selectedSubtitleStream != null -> { externalSubs.find { stream -> stream.index == source.selectedSubtitleStream?.index }?.let { sub -> diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 3b2b5cf8..26dd8251 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -87,7 +87,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), // Media source handling private val trackSelector = DefaultTrackSelector(getApplication()) - val mediaQueueManager = QueueManager(this, trackSelector) + val trackSelectionHelper = TrackSelectionHelper(this, trackSelector) + val mediaQueueManager = QueueManager(this) val mediaSourceOrNull: JellyfinMediaSource? get() = mediaQueueManager.mediaQueue.value?.jellyfinMediaSource @@ -435,22 +436,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), return PlayState(playWhenReady, position) } - /** - * @see QueueManager.selectAudioTrack - */ - suspend fun selectAudioTrack(streamIndex: Int): Boolean { - return mediaQueueManager.selectAudioTrack(streamIndex).also { success -> - if (success) playerOrNull?.logTracks(analyticsCollector) - } - } - - /** - * @see QueueManager.selectSubtitle - */ - suspend fun selectSubtitle(streamIndex: Int): Boolean { - return mediaQueueManager.selectSubtitle(streamIndex).also { success -> - if (success) playerOrNull?.logTracks(analyticsCollector) - } + fun logTracks() { + playerOrNull?.logTracks(analyticsCollector) } suspend fun changeBitrate(bitrate: Int?): Boolean { @@ -497,7 +484,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application), // Initialise various components if (playbackState == Player.STATE_READY) { if (!initialTracksSelected.getAndSet(true)) { - mediaQueueManager.selectInitialTracks() + trackSelectionHelper.selectInitialTracks() } mediaSession.isActive = true notificationHelper.postNotification() diff --git a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt new file mode 100644 index 00000000..1ab55080 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt @@ -0,0 +1,152 @@ +package org.jellyfin.mobile.player + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import org.jellyfin.mobile.player.source.ExternalSubtitleStream +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType +import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod + +class TrackSelectionHelper( + private val viewModel: PlayerViewModel, + private val trackSelector: DefaultTrackSelector, +) { + private val mediaSourceOrNull: JellyfinMediaSource? + get() = viewModel.mediaSourceOrNull + + fun selectInitialTracks() { + val mediaSource = mediaSourceOrNull ?: return + + mediaSource.selectedAudioStream?.let { stream -> + selectPlayerAudioTrack(mediaSource, stream, initial = true) + } + selectSubtitleTrack(mediaSource, mediaSource.selectedSubtitleStream, initial = true) + } + + /** + * Select an audio track in the media source and apply changes to the current player, if necessary and possible. + * + * @param mediaStreamIndex the [MediaStream.index] that should be selected + * @return true if the audio track was changed + */ + suspend fun selectAudioTrack(mediaStreamIndex: Int): Boolean { + val mediaSource = mediaSourceOrNull ?: return false + val selectedMediaStream = mediaSource.mediaStreams[mediaStreamIndex] + require(selectedMediaStream.type == MediaStreamType.AUDIO) + + // For transcoding and external streams, we need to restart playback + if (mediaSource.playMethod == PlayMethod.TRANSCODE || selectedMediaStream.isExternal) { + return viewModel.mediaQueueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream) + } + + return selectPlayerAudioTrack(mediaSource, selectedMediaStream, initial = false).also { success -> + if (success) viewModel.logTracks() + } + } + + /** + * Select the audio track in the player. + * + * @param initial whether this is an initial selection and checks for re-selection should be skipped. + * @see selectPlayerAudioTrack + */ + @Suppress("ReturnCount") + private fun selectPlayerAudioTrack(mediaSource: JellyfinMediaSource, audioStream: MediaStream, initial: Boolean): Boolean { + if (mediaSource.playMethod == PlayMethod.TRANSCODE) { + // Transcoding does not require explicit audio selection + return true + } + + when { + // Fast-pass: Skip execution on subsequent calls with the correct selection or if only one track exists + mediaSource.audioStreams.size == 1 || !initial && audioStream === mediaSource.selectedAudioStream -> return true + // Apply selection in media source, abort on failure + !mediaSource.selectAudioStream(audioStream) -> return false + } + + val player = viewModel.playerOrNull ?: return false + val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(audioStream) + val audioGroup = player.currentTracks.groups.getOrNull(embeddedStreamIndex)?.mediaTrackGroup ?: return false + + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, audioGroup) + } + + /** + * Select a subtitle track in the media source and apply changes to the current player, if necessary. + * + * @param mediaStreamIndex the [MediaStream.index] that should be selected, or -1 to disable subtitles + * @return true if the subtitle was changed + */ + suspend fun selectSubtitleTrack(mediaStreamIndex: Int): Boolean { + val mediaSource = viewModel.mediaSourceOrNull ?: return false + val selectedMediaStream = mediaSource.mediaStreams.getOrNull(mediaStreamIndex) + require(selectedMediaStream == null || selectedMediaStream.type == MediaStreamType.SUBTITLE) + + return selectSubtitleTrack(mediaSource, selectedMediaStream, initial = false).also { success -> + if (success) viewModel.logTracks() + } + } + + /** + * Select the subtitle track in the player. + * + * @param initial whether this is an initial selection and checks for re-selection should be skipped. + * @see selectSubtitleTrack + */ + @Suppress("ReturnCount") + private fun selectSubtitleTrack(mediaSource: JellyfinMediaSource, subtitleStream: MediaStream?, initial: Boolean): Boolean { + when { + // Fast-pass: Skip execution on subsequent calls with the same selection + !initial && subtitleStream === mediaSource.selectedSubtitleStream -> return true + // Apply selection in media source, abort on failure + !mediaSource.selectSubtitleStream(subtitleStream) -> return false + } + + // Apply selection in player + if (subtitleStream == null) { + // If no subtitle is selected, simply clear the selection and disable the subtitle renderer + trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT) + return true + } + + val player = viewModel.playerOrNull ?: return false + when (subtitleStream.deliveryMethod) { + SubtitleDeliveryMethod.EMBED -> { + // For embedded subtitles, we can match by the index of this stream in all embedded streams. + val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(subtitleStream) + val subtitleGroup = player.currentTracks.groups.getOrNull(embeddedStreamIndex)?.mediaTrackGroup ?: return false + + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, subtitleGroup) + } + SubtitleDeliveryMethod.EXTERNAL -> { + // For external subtitles, we can simply match the ID that we set when creating the player media source. + for (group in player.currentTracks.groups) { + if (group.getTrackFormat(0).id == "${ExternalSubtitleStream.ID_PREFIX}${subtitleStream.index}") { + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, group.mediaTrackGroup) + } + } + return false + } + else -> return false + } + } + + /** + * Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple. + * + * @return true if subtitles are enabled now, false if not + */ + suspend fun toggleSubtitles(): Boolean { + val mediaSource = mediaSourceOrNull ?: return false + val newSubtitleIndex = when (mediaSource.selectedSubtitleStream) { + null -> mediaSource.subtitleStreams.firstOrNull()?.index ?: -1 + else -> -1 + } + selectSubtitleTrack(newSubtitleIndex) + return mediaSource.selectedSubtitleStream != null + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index 542e2a9e..9df21dec 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -4,28 +4,26 @@ import android.net.Uri import androidx.annotation.CheckResult import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.android.exoplayer2.C import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MergingMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.SingleSampleMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import org.jellyfin.mobile.player.PlayerException import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.mobile.player.source.ExternalSubtitleStream import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType -import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.model.api.MediaProtocol import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.koin.core.component.KoinComponent @@ -35,7 +33,6 @@ import java.util.UUID class QueueManager( private val viewModel: PlayerViewModel, - private val trackSelector: DefaultTrackSelector, ) : KoinComponent { private val apiClient: ApiClient = get() private val videosApi: VideosApi = apiClient.videosApi @@ -171,7 +168,7 @@ class QueueManager( @CheckResult private fun prepareStreams(source: JellyfinMediaSource): MediaSource { val videoSource = createVideoMediaSource(source) - val subtitleSources = createSubtitleMediaSources(source) + val subtitleSources = createExternalSubtitleMediaSources(source) return when { subtitleSources.isNotEmpty() -> MergingMediaSource(videoSource, *subtitleSources) else -> videoSource @@ -248,150 +245,39 @@ class QueueManager( * @return The parsed MediaSources for the subtitles. */ @CheckResult - private fun createSubtitleMediaSources( + private fun createExternalSubtitleMediaSources( source: JellyfinMediaSource, ): Array { val factory = get() - return source.getExternalSubtitleStreams().map { stream -> + return source.externalSubtitleStreams.map { stream -> val uri = Uri.parse(apiClient.createUrl(stream.deliveryUrl)) val mediaItem = MediaItem.SubtitleConfiguration.Builder(uri).apply { - setId(stream.index.toString()) + setId("${ExternalSubtitleStream.ID_PREFIX}${stream.index}") setLabel(stream.displayTitle) setMimeType(stream.mimeType) setLanguage(stream.language) - setSelectionFlags(C.SELECTION_FLAG_AUTOSELECT) }.build() factory.createMediaSource(mediaItem, source.runTimeMs) }.toTypedArray() } - fun selectInitialTracks() { - val mediaSource = currentMediaSource ?: return - selectAudioTrack(mediaSource, mediaSource.selectedAudioStream?.index ?: -1, initial = true) - selectSubtitle(mediaSource, mediaSource.selectedSubtitleStream?.index ?: -1, initial = true) - } - /** - * Select an audio track in the media source and apply changes to the current player. + * Switch to the specified [audio stream][stream] and restart playback, for example while transcoding. * - * @param streamIndex the [MediaStream.index] that should be selected - * @return true if the audio track was changed + * @return true if playback was restarted with the new selection. */ - suspend fun selectAudioTrack(streamIndex: Int): Boolean { - val mediaSource = currentMediaSource ?: return false + suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean { + require(stream.type == MediaStreamType.AUDIO) + val currentPlayOptions = currentPlayOptions ?: return false + val currentPlayState = viewModel.getStateAndPause() ?: return false - return when (mediaSource.playMethod) { - // For transcoding playback, special handling is required - PlayMethod.TRANSCODE -> { - val currentPlayOptions = currentPlayOptions ?: return false - val currentPlayState = viewModel.getStateAndPause() ?: return false + val playOptions = currentPlayOptions.copy( + startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, + audioStreamIndex = stream.index, + ) - val playOptions = currentPlayOptions.copy( - startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, - audioStreamIndex = streamIndex, - ) - startPlayback(playOptions, currentPlayState.playWhenReady) - - // Player menus will be updated after playback started - false - } - else -> selectAudioTrack(mediaSource, streamIndex, initial = false) - } - } - - /** - * @param initial whether this is an initial selection and checks for re-selection should be skipped. - * @see selectAudioTrack - */ - @Suppress("ReturnCount") - private fun selectAudioTrack(mediaSource: JellyfinMediaSource, streamIndex: Int, initial: Boolean): Boolean { - if (mediaSource.playMethod == PlayMethod.TRANSCODE) { - return when { - initial -> true - else -> error("Selecting audio tracks in ExoPlayer isn't supported while transcoding") - } - } - val sourceIndex = mediaSource.audioStreams.indexOfFirst { stream -> stream.index == streamIndex } - - when { - // Fast-pass: Skip execution on subsequent calls with the correct selection or if only one track exists - mediaSource.audioStreams.size == 1 || !initial && streamIndex == mediaSource.selectedAudioStream?.index -> return true - // Apply selection in media source, abort on failure - !mediaSource.selectAudioStream(sourceIndex) -> return false - } - - return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, sourceIndex) - } - - /** - * Select a subtitle track in the media source and apply changes to the current player. - * - * @param streamIndex the [MediaStream.index] that should be selected - * @return true if the subtitle was changed - */ - suspend fun selectSubtitle(streamIndex: Int): Boolean { - val mediaSource = currentMediaSource ?: return false - - return when (mediaSource.playMethod) { - // For transcoding playback, special handling is required - PlayMethod.TRANSCODE -> { - val currentPlayOptions = currentPlayOptions ?: return false - val currentPlayState = viewModel.getStateAndPause() ?: return false - - val playOptions = currentPlayOptions.copy( - startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, - subtitleStreamIndex = streamIndex, - ) - startPlayback(playOptions, currentPlayState.playWhenReady) - - // Player menus will be updated after playback started - false - } - else -> selectSubtitle(mediaSource, streamIndex, initial = false) - } - } - - /** - * @param initial whether this is an initial selection and checks for re-selection should be skipped. - * @see selectSubtitle - */ - private fun selectSubtitle(mediaSource: JellyfinMediaSource, streamIndex: Int, initial: Boolean): Boolean { - if (mediaSource.playMethod == PlayMethod.TRANSCODE) { - return when { - initial -> true - else -> error("Selecting subtitle tracks in ExoPlayer isn't supported while transcoding") - } - } - val sourceIndex = mediaSource.subtitleStreams.indexOfFirst { stream -> stream.index == streamIndex } - - when { - // Fast-pass: Skip execution on subsequent calls with the correct selection - !initial && streamIndex == mediaSource.selectedSubtitleStream?.index -> return true - // Apply selection in media source, abort on failure - !mediaSource.selectSubtitleStream(sourceIndex) -> return false - } - - return when { - // Select new subtitle with suitable renderer - sourceIndex >= 0 -> trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, sourceIndex) - // No subtitle selected, clear selection overrides and disable all subtitle renderers - else -> trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT) - } - } - - /** - * Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple. - * - * @return true if subtitles are enabled now, false if not - */ - suspend fun toggleSubtitles(): Boolean { - val mediaSource = currentMediaSource ?: return false - val newSubtitleIndex = when (mediaSource.selectedSubtitleStream) { - null -> mediaSource.subtitleStreams.firstOrNull()?.index ?: -1 - else -> -1 - } - selectSubtitle(newSubtitleIndex) - return mediaSource.selectedSubtitleStream != null + startPlayback(playOptions, currentPlayState.playWhenReady) + return true } sealed class QueueItem { diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt b/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt index 79bc83fe..ea5e8197 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt @@ -6,4 +6,8 @@ data class ExternalSubtitleStream( val mimeType: String, val displayTitle: String, val language: String, -) +) { + companion object { + const val ID_PREFIX = "external:" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt index dfbc0f28..773c618f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt @@ -7,6 +7,7 @@ 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.PlayMethod +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import java.util.UUID class JellyfinMediaSource( @@ -37,10 +38,10 @@ class JellyfinMediaSource( val runTimeTicks: Long = sourceInfo.runTimeTicks ?: 0 val runTimeMs: Long = runTimeTicks / Constants.TICKS_PER_MILLISECOND - private val mediaStreams: List = sourceInfo.mediaStreams.orEmpty() - val videoStreams: List + val mediaStreams: List = sourceInfo.mediaStreams.orEmpty() val audioStreams: List val subtitleStreams: List + val externalSubtitleStreams: List var selectedVideoStream: MediaStream? = null private set @@ -51,71 +52,106 @@ class JellyfinMediaSource( init { // Classify MediaStreams - val video = ArrayList() val audio = ArrayList() - val subtitle = ArrayList() + val subtitles = ArrayList() + val externalSubtitles = ArrayList() for (mediaStream in mediaStreams) { when (mediaStream.type) { - MediaStreamType.VIDEO -> video += mediaStream + MediaStreamType.VIDEO -> { + // Always select the first available video stream + if (selectedVideoStream == null) { + selectedVideoStream = mediaStream + } + } MediaStreamType.AUDIO -> { audio += mediaStream - if (mediaStream.index == audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex) { + if (mediaStream.index == (audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) { selectedAudioStream = mediaStream } } MediaStreamType.SUBTITLE -> { - subtitle += mediaStream - if (mediaStream.index == subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex) { + subtitles += mediaStream + if (mediaStream.index == (subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) { selectedSubtitleStream = mediaStream } + + // External subtitles as specified by the deliveryMethod. + // It is set to external either for external subtitle files or when transcoding. + // In the latter case, subtitles are extracted from the source file by the server. + if (mediaStream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) { + val deliveryUrl = mediaStream.deliveryUrl + val mimeType = CodecHelpers.getSubtitleMimeType(mediaStream.codec) + if (deliveryUrl != null && mimeType != null) { + externalSubtitles += ExternalSubtitleStream( + index = mediaStream.index, + deliveryUrl = deliveryUrl, + mimeType = mimeType, + displayTitle = mediaStream.displayTitle.orEmpty(), + language = mediaStream.language ?: Constants.LANGUAGE_UNDEFINED, + ) + } + } } - MediaStreamType.EMBEDDED_IMAGE, MediaStreamType.DATA, + MediaStreamType.EMBEDDED_IMAGE, -> Unit // ignore } } - // Sort results - video.sortBy(MediaStream::index) - audio.sortBy(MediaStream::index) - subtitle.sortBy(MediaStream::index) - - // Apply - videoStreams = video audioStreams = audio - subtitleStreams = subtitle - - selectedVideoStream = videoStreams.firstOrNull() + subtitleStreams = subtitles + externalSubtitleStreams = externalSubtitles } - fun selectAudioStream(sourceIndex: Int): Boolean { - // Ensure selected index exists in audio streams - if (sourceIndex !in audioStreams.indices) { + /** + * Select the specified [audio stream][stream] in the source. + * + * @param stream The stream to select. + * @return true if the stream was found and selected, false otherwise. + */ + fun selectAudioStream(stream: MediaStream): Boolean { + require(stream.type == MediaStreamType.AUDIO) + if (mediaStreams[stream.index] !== stream) { return false } - selectedAudioStream = audioStreams[sourceIndex] + selectedAudioStream = stream return true } - fun selectSubtitleStream(sourceIndex: Int): Boolean { - // "Illegal" selections disable subtitles - selectedSubtitleStream = subtitleStreams.getOrNull(sourceIndex) - return true - } - - fun getExternalSubtitleStreams(): List = subtitleStreams.mapNotNull { stream -> - val mimeType = CodecHelpers.getSubtitleMimeType(stream.codec) - if (stream.isExternal && stream.deliveryUrl != null && mimeType != null) { - ExternalSubtitleStream( - index = stream.index, - deliveryUrl = stream.deliveryUrl!!, - mimeType = mimeType, - displayTitle = stream.displayTitle.orEmpty(), - language = stream.language ?: Constants.LANGUAGE_UNDEFINED, - ) - } else { - null + /** + * Select the specified [subtitle stream][stream] in the source. + * + * @param stream The stream to select, or null to disable subtitles. + * @return true if the stream was found and selected, false otherwise. + */ + fun selectSubtitleStream(stream: MediaStream?): Boolean { + if (stream == null) { + selectedSubtitleStream = null + return true } + + require(stream.type == MediaStreamType.SUBTITLE) + if (mediaStreams[stream.index] !== stream) { + return false + } + + selectedSubtitleStream = stream + return true + } + + /** + * Returns the index of the media stream within the embedded streams. + * Useful for handling track selection in ExoPlayer, where embedded streams are mapped first. + */ + fun getEmbeddedStreamIndex(mediaStream: MediaStream): Int { + var index = 0 + for (stream in mediaStreams) { + when { + stream === mediaStream -> return index + !stream.isExternal -> index++ + } + } + throw IllegalArgumentException("Invalid media stream") } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index 4e9cf1f0..46bce7ab 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -256,7 +256,7 @@ class PlayerFragment : Fragment() { * @param callback called if track selection was successful and UI needs to be updated */ fun onAudioTrackSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch { - if (viewModel.selectAudioTrack(index)) { + if (viewModel.trackSelectionHelper.selectAudioTrack(index)) { callback.onTrackSelected(true) } } @@ -265,7 +265,7 @@ class PlayerFragment : Fragment() { * @param callback called if track selection was successful and UI needs to be updated */ fun onSubtitleSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch { - if (viewModel.selectSubtitle(index)) { + if (viewModel.trackSelectionHelper.selectSubtitleTrack(index)) { callback.onTrackSelected(true) } } @@ -276,7 +276,7 @@ class PlayerFragment : Fragment() { * @return true if subtitles are enabled now, false if not */ fun toggleSubtitles(callback: TrackSelectionCallback) = lifecycleScope.launch { - callback.onTrackSelected(viewModel.mediaQueueManager.toggleSubtitles()) + callback.onTrackSelected(viewModel.trackSelectionHelper.toggleSubtitles()) } fun onBitrateChanged(bitrate: Int?, callback: TrackSelectionCallback) = lifecycleScope.launch { diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt index dde65faf..b10c3c13 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt @@ -105,27 +105,33 @@ class PlayerMenus( nextButton.isEnabled = queueItem.hasNext() val mediaSource = queueItem.jellyfinMediaSource + + val videoStream = mediaSource.selectedVideoStream + + val audioStreams = mediaSource.audioStreams + buildMenuItems( + audioStreamsMenu.menu, + AUDIO_MENU_GROUP, + audioStreams, + mediaSource.selectedAudioStream, + ) + + val subtitleStreams = mediaSource.subtitleStreams val selectedSubtitleStream = mediaSource.selectedSubtitleStream buildMenuItems( subtitlesMenu.menu, SUBTITLES_MENU_GROUP, - mediaSource.subtitleStreams, + subtitleStreams, selectedSubtitleStream, true, ) - buildMenuItems( - audioStreamsMenu.menu, - AUDIO_MENU_GROUP, - mediaSource.audioStreams, - mediaSource.selectedAudioStream, - ) - subtitleCount = mediaSource.subtitleStreams.size + subtitleCount = subtitleStreams.size subtitlesEnabled = selectedSubtitleStream != null updateSubtitlesButton() - val height = mediaSource.selectedVideoStream?.height - val width = mediaSource.selectedVideoStream?.width + val height = videoStream?.height + val width = videoStream?.width if (height != null && width != null) { buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height) } else { @@ -134,7 +140,7 @@ class PlayerMenus( val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod) val videoTracksInfo = buildMediaStreamsInfo( - mediaStreams = mediaSource.videoStreams, + mediaStreams = listOfNotNull(videoStream), prefix = R.string.playback_info_video_streams, maxStreams = MAX_VIDEO_STREAMS_DISPLAY, streamSuffix = { stream -> @@ -142,7 +148,7 @@ class PlayerMenus( }, ) val audioTracksInfo = buildMediaStreamsInfo( - mediaStreams = mediaSource.audioStreams, + mediaStreams = audioStreams, prefix = R.string.playback_info_audio_streams, maxStreams = MAX_AUDIO_STREAMS_DISPLAY, streamSuffix = { stream -> @@ -277,20 +283,18 @@ class PlayerMenus( showNone -> menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none)) else -> null } + var selectedItem: MenuItem? = itemNone val menuItems = mediaStreams.map { mediaStream -> - menu.add(groupId, mediaStream.index, Menu.NONE, mediaStream.displayTitle) + val title = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})" + menu.add(groupId, mediaStream.index, Menu.NONE, title).also { item -> + if (mediaStream === selectedStream) { + selectedItem = item + } + } } menu.setGroupCheckable(groupId, true, true) - val selected = when { - selectedStream != null -> mediaStreams.indexOfFirst { stream -> stream.index == selectedStream.index } - else -> -1 - } - if (selected >= 0) { - menuItems[selected].isChecked = true - } else { - // No selection, check "none" or first item if possible - (itemNone ?: menuItems.firstOrNull())?.isChecked = true - } + // Check selected item or first item if possible + (selectedItem ?: menuItems.firstOrNull())?.isChecked = true } private fun updateSubtitlesButton() { diff --git a/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt index 6d89ddca..76aa9db2 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt @@ -1,48 +1,23 @@ package org.jellyfin.mobile.utils import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.source.TrackGroup import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.trackselection.MappingTrackSelector +import com.google.android.exoplayer2.trackselection.TrackSelectionOverride /** - * Get the index of the first renderer of the specified [type] supporting the track with the specified [groupIndex]. + * Select the [trackGroup] of the specified [type] and ensure the type is enabled. * * @param type One of the TRACK_TYPE_* constants defined in [C]. - * @param groupIndex The index of the track group to which the track belongs. + * @param trackGroup the [TrackGroup] to select. */ -fun MappingTrackSelector.MappedTrackInfo.getBestRendererIndex(type: Int, groupIndex: Int): Int? { - if (groupIndex < 0) return null - - for (i in 0 until rendererCount) { - if (getRendererType(i) != type || groupIndex >= getTrackGroups(i).length) continue - if (getTrackSupport(i, groupIndex, 0) == C.FORMAT_HANDLED) return i - } - - // No perfect match found, try again with less strict requirements - for (i in 0 until rendererCount) { - if (getRendererType(i) != type || groupIndex >= getTrackGroups(i).length) continue - if (getTrackSupport(i, groupIndex, 0) == C.FORMAT_EXCEEDS_CAPABILITIES) return i - } - - return null -} - -/** - * Select the track of the specified [type] and [groupIndex] and enable it on the first supported renderer. - * - * @param type One of the TRACK_TYPE_* constants defined in [C]. - * @param groupIndex The index of the track group to which the track belongs. - */ -fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, groupIndex: Int): Boolean { - with(currentMappedTrackInfo ?: return false) { - val parameters = buildUponParameters() - val rendererIndex = getBestRendererIndex(type, groupIndex) ?: return false - val selection = DefaultTrackSelector.SelectionOverride(groupIndex, 0) - parameters.clearSelectionOverrides(rendererIndex) - parameters.setSelectionOverride(rendererIndex, getTrackGroups(rendererIndex), selection) - parameters.setRendererDisabled(rendererIndex, false) - setParameters(parameters) +fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, trackGroup: TrackGroup): Boolean { + val parameters = with(buildUponParameters()) { + clearOverridesOfType(type) + addOverride(TrackSelectionOverride(trackGroup, 0)) + setTrackTypeDisabled(type, false) } + setParameters(parameters) return true } @@ -52,15 +27,10 @@ fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, groupIndex: Int): * @param type One of the TRACK_TYPE_* constants defined in [C]. */ fun DefaultTrackSelector.clearSelectionAndDisableRendererByType(type: Int): Boolean { - with(currentMappedTrackInfo ?: return false) { - val parameters = buildUponParameters() - for (i in 0 until rendererCount) { - if (getRendererType(i) == type) { - parameters.clearSelectionOverrides(i) - parameters.setRendererDisabled(i, true) - } - } - setParameters(parameters) + val parameters = with(buildUponParameters()) { + clearOverridesOfType(type) + setTrackTypeDisabled(type, true) } + setParameters(parameters) return true }