Rework track handling and selection

Add proper support for external audio streams, rework audio and subtitle selection, and support extracted subtitles during transcoding playback.

Also extracts some code to a new TrackSelectionHelper class.
This commit is contained in:
Maxr1998 authored and Niels van Velzen committed 2023-04-04 17:16:34 +02:00
1 parent 4bc96f5dbc
commit 2f57f91dc3
9 files changed
+306 -269

No files matched your search

@@ -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 ->
@@ -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()
@@ -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
}
}
@@ -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<MediaSource> {
val factory = get<SingleSampleMediaSource.Factory>()
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 {
@@ -6,4 +6,8 @@ data class ExternalSubtitleStream(
val mimeType: String,
val displayTitle: String,
val language: String,
)
) {
companion object {
const val ID_PREFIX = "external:"
}
}
@@ -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<MediaStream> = sourceInfo.mediaStreams.orEmpty()
val videoStreams: List<MediaStream>
val mediaStreams: List<MediaStream> = sourceInfo.mediaStreams.orEmpty()
val audioStreams: List<MediaStream>
val subtitleStreams: List<MediaStream>
val externalSubtitleStreams: List<ExternalSubtitleStream>
var selectedVideoStream: MediaStream? = null
private set
@@ -51,71 +52,106 @@ class JellyfinMediaSource(
init {
// Classify MediaStreams
val video = ArrayList<MediaStream>()
val audio = ArrayList<MediaStream>()
val subtitle = ArrayList<MediaStream>()
val subtitles = ArrayList<MediaStream>()
val externalSubtitles = ArrayList<ExternalSubtitleStream>()
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<ExternalSubtitleStream> = 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")
}
}
@@ -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 {
@@ -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() {
@@ -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
}