mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-11-15 10:33:01 +00:00
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:
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
|
||||
}
|
||||
Reference in new issue
Block a user