mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-10-26 16:45:26 +00:00
Rewrite queue management and play options handling
Caching media sources in the queue was unnecessary and overly complex, and could cause issues if playback options changed in between (i.e. a new bitrate). Additionally, there was some duplication between the cached play options and the current media source, which caused more confusion and issues.
This commit is contained in:
1 parent
79b75882ec
commit
381d9f36c7
7 files changed
+157
-151
No files matched your search
@@ -21,6 +21,7 @@ import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import com.google.android.exoplayer2.util.Clock
|
||||
import com.google.android.exoplayer2.util.EventLogger
|
||||
@@ -88,9 +89,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
// Media source handling
|
||||
private val trackSelector = DefaultTrackSelector(getApplication())
|
||||
val trackSelectionHelper = TrackSelectionHelper(this, trackSelector)
|
||||
val mediaQueueManager = QueueManager(this)
|
||||
val queueManager = QueueManager(this)
|
||||
val mediaSourceOrNull: JellyfinMediaSource?
|
||||
get() = mediaQueueManager.mediaQueue.value?.jellyfinMediaSource
|
||||
get() = queueManager.currentMediaSourceOrNull
|
||||
|
||||
// ExoPlayer
|
||||
private val _player = MutableLiveData<ExoPlayer?>()
|
||||
@@ -239,22 +240,22 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
_player.value = null
|
||||
}
|
||||
|
||||
fun load(queueItem: QueueManager.QueueItem.Loaded, playWhenReady: Boolean) {
|
||||
fun load(jellyfinMediaSource: JellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) {
|
||||
val player = playerOrNull ?: return
|
||||
|
||||
player.setMediaSource(queueItem.exoMediaSource)
|
||||
player.setMediaSource(exoMediaSource)
|
||||
player.prepare()
|
||||
|
||||
initialTracksSelected.set(false)
|
||||
|
||||
val startTime = queueItem.jellyfinMediaSource.startTimeMs
|
||||
val startTime = jellyfinMediaSource.startTimeMs
|
||||
if (startTime > 0) player.seekTo(startTime)
|
||||
player.playWhenReady = playWhenReady
|
||||
|
||||
mediaSession.setMetadata(queueItem.jellyfinMediaSource.toMediaMetadata())
|
||||
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
|
||||
|
||||
viewModelScope.launch {
|
||||
player.reportPlaybackStart(queueItem.jellyfinMediaSource)
|
||||
player.reportPlaybackStart(jellyfinMediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +287,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
analyticsCollector = buildAnalyticsCollector()
|
||||
setupPlayer()
|
||||
mediaQueueManager.mediaQueue.value?.jellyfinMediaSource?.startTimeMs = playedTime
|
||||
mediaQueueManager.tryRestartPlayback()
|
||||
queueManager.currentMediaSourceOrNull?.startTimeMs = playedTime
|
||||
queueManager.tryRestartPlayback()
|
||||
}
|
||||
|
||||
private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) {
|
||||
@@ -409,7 +410,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
// Skip to previous element
|
||||
player.currentPosition <= Constants.MAX_SKIP_TO_PREV_MS -> viewModelScope.launch {
|
||||
pause()
|
||||
if (!mediaQueueManager.previous()) {
|
||||
if (!queueManager.previous()) {
|
||||
// Skip to previous failed, go to start of video anyway
|
||||
playerOrNull?.seekTo(0)
|
||||
play()
|
||||
@@ -422,7 +423,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
|
||||
fun skipToNext() {
|
||||
viewModelScope.launch {
|
||||
mediaQueueManager.next()
|
||||
queueManager.next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +442,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
|
||||
suspend fun changeBitrate(bitrate: Int?): Boolean {
|
||||
return mediaQueueManager.changeBitrate(bitrate)
|
||||
return queueManager.changeBitrate(bitrate)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,9 +500,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
|
||||
// Update media session
|
||||
var playbackActions = SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
|
||||
mediaQueueManager.mediaQueue.value?.let { queueItem ->
|
||||
if (queueItem.hasPrevious()) playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
if (queueItem.hasNext()) playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
if (queueManager.hasPrevious()) {
|
||||
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
}
|
||||
if (queueManager.hasNext()) {
|
||||
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
}
|
||||
mediaSession.setPlaybackState(player, playbackActions)
|
||||
|
||||
@@ -513,7 +516,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
reportPlaybackStop()
|
||||
if (!mediaQueueManager.next()) {
|
||||
if (!queueManager.next()) {
|
||||
releasePlayer()
|
||||
}
|
||||
}
|
||||
@@ -530,7 +533,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
fallbackPreferExtensionRenderers = true
|
||||
setupPlayer()
|
||||
mediaQueueManager.tryRestartPlayback()
|
||||
queueManager.tryRestartPlayback()
|
||||
} else {
|
||||
_error.postValue(error.localizedMessage.orEmpty())
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class TrackSelectionHelper(
|
||||
|
||||
// For transcoding and external streams, we need to restart playback
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE || selectedMediaStream.isExternal) {
|
||||
return viewModel.mediaQueueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream)
|
||||
return viewModel.queueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream)
|
||||
}
|
||||
|
||||
return selectPlayerAudioTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
|
||||
@@ -95,7 +95,7 @@ class TrackSelectionHelper(
|
||||
selectedMediaStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE ||
|
||||
mediaSource.selectedSubtitleStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE
|
||||
) {
|
||||
return viewModel.mediaQueueManager.selectSubtitleStreamAndRestartPlayback(selectedMediaStream)
|
||||
return viewModel.queueManager.selectSubtitleStreamAndRestartPlayback(selectedMediaStream)
|
||||
}
|
||||
|
||||
return selectSubtitleTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
|
||||
|
||||
+8
-7
@@ -66,8 +66,9 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
|
||||
fun postNotification() {
|
||||
val nm = notificationManager ?: return
|
||||
val player = viewModel.playerOrNull ?: return
|
||||
val queueItem = viewModel.mediaQueueManager.mediaQueue.value ?: return
|
||||
val mediaSource = queueItem.jellyfinMediaSource
|
||||
val currentMediaSource = viewModel.queueManager.currentMediaSourceOrNull ?: return
|
||||
val hasPrevious = viewModel.queueManager.hasPrevious()
|
||||
val hasNext = viewModel.queueManager.hasNext()
|
||||
val playbackState = player.playbackState
|
||||
if (playbackState != Player.STATE_READY && playbackState != Player.STATE_BUFFERING) return
|
||||
|
||||
@@ -76,7 +77,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
|
||||
|
||||
viewModel.viewModelScope.launch {
|
||||
val mediaIcon: Bitmap? = withContext(Dispatchers.IO) {
|
||||
loadImage(mediaSource)
|
||||
loadImage(currentMediaSource)
|
||||
}
|
||||
|
||||
val style = Notification.MediaStyle().apply {
|
||||
@@ -93,12 +94,12 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
|
||||
}
|
||||
setSmallIcon(R.drawable.ic_notification)
|
||||
mediaIcon?.let(::setLargeIcon)
|
||||
setContentTitle(mediaSource.name)
|
||||
mediaSource.item?.artists?.joinToString()?.let(::setContentText)
|
||||
setContentTitle(currentMediaSource.name)
|
||||
currentMediaSource.item?.artists?.joinToString()?.let(::setContentText)
|
||||
setStyle(style)
|
||||
setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
when {
|
||||
queueItem.hasPrevious() -> addAction(generateAction(PlayerNotificationAction.PREVIOUS))
|
||||
hasPrevious -> addAction(generateAction(PlayerNotificationAction.PREVIOUS))
|
||||
else -> addAction(generateAction(PlayerNotificationAction.REWIND))
|
||||
}
|
||||
val playbackAction = when {
|
||||
@@ -107,7 +108,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom
|
||||
}
|
||||
addAction(generateAction(playbackAction))
|
||||
when {
|
||||
queueItem.hasNext() -> addAction(generateAction(PlayerNotificationAction.NEXT))
|
||||
hasNext -> addAction(generateAction(PlayerNotificationAction.NEXT))
|
||||
else -> addAction(generateAction(PlayerNotificationAction.FAST_FORWARD))
|
||||
}
|
||||
setContentIntent(buildContentIntent())
|
||||
|
||||
@@ -39,13 +39,16 @@ class QueueManager(
|
||||
private val mediaSourceResolver: MediaSourceResolver by inject()
|
||||
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
|
||||
private val deviceProfile = deviceProfileBuilder.getDeviceProfile()
|
||||
private val _mediaQueue: MutableLiveData<QueueItem.Loaded> = MutableLiveData()
|
||||
val mediaQueue: LiveData<QueueItem.Loaded> get() = _mediaQueue
|
||||
|
||||
private val currentMediaSource: JellyfinMediaSource?
|
||||
get() = _mediaQueue.value?.jellyfinMediaSource
|
||||
private var currentQueue: List<UUID> = emptyList()
|
||||
private var currentQueueIndex: Int = 0
|
||||
|
||||
private var currentPlayOptions: PlayOptions? = null
|
||||
private val _currentMediaSource: MutableLiveData<JellyfinMediaSource> = MutableLiveData()
|
||||
val currentMediaSource: LiveData<JellyfinMediaSource>
|
||||
get() = _currentMediaSource
|
||||
|
||||
inline val currentMediaSourceOrNull: JellyfinMediaSource?
|
||||
get() = currentMediaSource.value
|
||||
|
||||
/**
|
||||
* Handle initial playback options from fragment.
|
||||
@@ -53,36 +56,63 @@ class QueueManager(
|
||||
*
|
||||
* @return an error of type [PlayerException] or null on success.
|
||||
*/
|
||||
suspend fun startPlayback(playOptions: PlayOptions, playWhenReady: Boolean): PlayerException? {
|
||||
if (playOptions != currentPlayOptions) {
|
||||
val itemId = playOptions.run {
|
||||
ids.getOrNull(startIndex) ?: mediaSourceId?.toUUIDOrNull() // fallback if ids is empty
|
||||
} ?: return PlayerException.InvalidPlayOptions()
|
||||
suspend fun initializePlaybackQueue(playOptions: PlayOptions): PlayerException? {
|
||||
currentQueue = playOptions.ids
|
||||
currentQueueIndex = playOptions.startIndex
|
||||
|
||||
mediaSourceResolver.resolveMediaSource(
|
||||
itemId = itemId,
|
||||
mediaSourceId = playOptions.mediaSourceId,
|
||||
deviceProfile = deviceProfile,
|
||||
maxStreamingBitrate = playOptions.maxBitrate,
|
||||
startTimeTicks = playOptions.startPositionTicks,
|
||||
audioStreamIndex = playOptions.audioStreamIndex,
|
||||
subtitleStreamIndex = playOptions.subtitleStreamIndex,
|
||||
).onSuccess { jellyfinMediaSource ->
|
||||
// Ensure transcoding of the current element is stopped
|
||||
currentMediaSource?.let { oldMediaSource ->
|
||||
viewModel.stopTranscoding(oldMediaSource)
|
||||
}
|
||||
val itemId = when {
|
||||
currentQueue.isNotEmpty() -> currentQueue[currentQueueIndex]
|
||||
else -> playOptions.mediaSourceId?.toUUIDOrNull()
|
||||
} ?: return PlayerException.InvalidPlayOptions()
|
||||
|
||||
// Apply new queue
|
||||
val previous = QueueItem.Stub(playOptions.ids.take(playOptions.startIndex))
|
||||
val next = QueueItem.Stub(playOptions.ids.drop(playOptions.startIndex + 1))
|
||||
val new = createQueueItem(jellyfinMediaSource, previous, next)
|
||||
currentPlayOptions = playOptions
|
||||
new.play(playWhenReady)
|
||||
}.onFailure { error ->
|
||||
// Should always be of this type, other errors are silently dropped
|
||||
return error as? PlayerException
|
||||
startPlayback(
|
||||
itemId = itemId,
|
||||
mediaSourceId = playOptions.mediaSourceId,
|
||||
maxStreamingBitrate = playOptions.maxBitrate,
|
||||
startTimeTicks = playOptions.startPositionTicks,
|
||||
audioStreamIndex = playOptions.audioStreamIndex,
|
||||
subtitleStreamIndex = playOptions.subtitleStreamIndex,
|
||||
playWhenReady = true,
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a specific media item specified by [itemId] and [mediaSourceId].
|
||||
*
|
||||
* @return an error of type [PlayerException] or null on success.
|
||||
*/
|
||||
private suspend fun startPlayback(
|
||||
itemId: UUID,
|
||||
mediaSourceId: String?,
|
||||
maxStreamingBitrate: Int?,
|
||||
startTimeTicks: Long? = null,
|
||||
audioStreamIndex: Int? = null,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
playWhenReady: Boolean = true,
|
||||
): PlayerException? {
|
||||
mediaSourceResolver.resolveMediaSource(
|
||||
itemId = itemId,
|
||||
mediaSourceId = mediaSourceId,
|
||||
deviceProfile = deviceProfile,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
startTimeTicks = startTimeTicks,
|
||||
audioStreamIndex = audioStreamIndex,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
).onSuccess { jellyfinMediaSource ->
|
||||
// Ensure transcoding of the current element is stopped
|
||||
currentMediaSourceOrNull?.let { oldMediaSource ->
|
||||
viewModel.stopTranscoding(oldMediaSource)
|
||||
}
|
||||
|
||||
_currentMediaSource.value = jellyfinMediaSource
|
||||
|
||||
// Load new media source
|
||||
viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
|
||||
}.onFailure { error ->
|
||||
// Should always be of this type, other errors are silently dropped
|
||||
return error as? PlayerException
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -91,70 +121,60 @@ class QueueManager(
|
||||
* Reinitialize current media source without changing settings
|
||||
*/
|
||||
fun tryRestartPlayback() {
|
||||
_mediaQueue.value?.play()
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return
|
||||
|
||||
viewModel.load(currentMediaSource, prepareStreams(currentMediaSource), playWhenReady = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the maximum bitrate to the specified value.
|
||||
*/
|
||||
suspend fun changeBitrate(bitrate: Int?): Boolean {
|
||||
val currentPlayOptions = currentPlayOptions ?: return false
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
// Bitrate didn't change, ignore
|
||||
if (currentPlayOptions.maxBitrate == bitrate) return true
|
||||
if (currentMediaSource.maxStreamingBitrate == bitrate) return true
|
||||
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
val playOptions = currentPlayOptions.copy(
|
||||
startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
maxBitrate = bitrate,
|
||||
)
|
||||
return startPlayback(playOptions, currentPlayState.playWhenReady) == null
|
||||
return startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = bitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
|
||||
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
) == null
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
private fun createQueueItem(jellyfinMediaSource: JellyfinMediaSource, previous: QueueItem, next: QueueItem): QueueItem.Loaded {
|
||||
val exoMediaSource = prepareStreams(jellyfinMediaSource)
|
||||
return QueueItem.Loaded(jellyfinMediaSource, exoMediaSource, previous, next)
|
||||
}
|
||||
fun hasPrevious(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex > 0
|
||||
|
||||
fun hasNext(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex < currentQueue.lastIndex
|
||||
|
||||
suspend fun previous(): Boolean {
|
||||
val current = _mediaQueue.value ?: return false
|
||||
when (val previous = current.previous) {
|
||||
is QueueItem.Loaded -> {
|
||||
previous.play()
|
||||
}
|
||||
is QueueItem.Stub -> {
|
||||
val previousId = previous.ids.lastOrNull() ?: return false
|
||||
val jellyfinMediaSource = mediaSourceResolver.resolveMediaSource(
|
||||
itemId = previousId,
|
||||
deviceProfile = deviceProfile,
|
||||
).getOrNull() ?: return false
|
||||
if (!hasPrevious()) return false
|
||||
|
||||
val previousPrevious = QueueItem.Stub(previous.ids.dropLast(1))
|
||||
createQueueItem(jellyfinMediaSource, previousPrevious, current).play()
|
||||
}
|
||||
}
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentQueue[--currentQueueIndex],
|
||||
mediaSourceId = null,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun next(): Boolean {
|
||||
val current = _mediaQueue.value ?: return false
|
||||
when (val next = current.next) {
|
||||
is QueueItem.Loaded -> {
|
||||
next.play()
|
||||
}
|
||||
is QueueItem.Stub -> {
|
||||
val nextId = next.ids.firstOrNull() ?: return false
|
||||
val jellyfinMediaSource = mediaSourceResolver.resolveMediaSource(
|
||||
itemId = nextId,
|
||||
deviceProfile = deviceProfile,
|
||||
).getOrNull() ?: return false
|
||||
if (!hasNext()) return false
|
||||
|
||||
val nextNext = QueueItem.Stub(next.ids.drop(1))
|
||||
createQueueItem(jellyfinMediaSource, current, nextNext).play()
|
||||
}
|
||||
}
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentQueue[++currentQueueIndex],
|
||||
mediaSourceId = null,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -268,15 +288,18 @@ class QueueManager(
|
||||
*/
|
||||
suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean {
|
||||
require(stream.type == MediaStreamType.AUDIO)
|
||||
val currentPlayOptions = currentPlayOptions ?: return false
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
val playOptions = currentPlayOptions.copy(
|
||||
startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = stream.index,
|
||||
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
)
|
||||
|
||||
startPlayback(playOptions, currentPlayState.playWhenReady)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -289,43 +312,18 @@ class QueueManager(
|
||||
*/
|
||||
suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean {
|
||||
require(stream == null || stream.type == MediaStreamType.SUBTITLE)
|
||||
val currentPlayOptions = currentPlayOptions ?: return false
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
val playOptions = currentPlayOptions.copy(
|
||||
startPositionTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
|
||||
subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
)
|
||||
|
||||
startPlayback(playOptions, currentPlayState.playWhenReady)
|
||||
return true
|
||||
}
|
||||
|
||||
sealed class QueueItem {
|
||||
class Loaded(
|
||||
val jellyfinMediaSource: JellyfinMediaSource,
|
||||
val exoMediaSource: MediaSource,
|
||||
val previous: QueueItem,
|
||||
val next: QueueItem,
|
||||
) : QueueItem() {
|
||||
fun hasPrevious(): Boolean = when (previous) {
|
||||
is Loaded -> true
|
||||
is Stub -> previous.ids.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasNext(): Boolean = when (next) {
|
||||
is Loaded -> true
|
||||
is Stub -> next.ids.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
class Stub(
|
||||
val ids: List<UUID>,
|
||||
) : QueueItem()
|
||||
}
|
||||
|
||||
private fun QueueItem.Loaded.play(playWhenReady: Boolean = true) {
|
||||
_mediaQueue.value = this
|
||||
viewModel.load(this, playWhenReady)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,13 @@ class JellyfinMediaSource(
|
||||
var selectedSubtitleStream: MediaStream? = null
|
||||
private set
|
||||
|
||||
val selectedAudioStreamIndex: Int?
|
||||
get() = selectedAudioStream?.index
|
||||
val selectedSubtitleStreamIndex: Int
|
||||
// -1 disables subtitles, null would select the default subtitle
|
||||
// If the default should be played, it would be explicitly set above
|
||||
get() = selectedSubtitleStream?.index ?: -1
|
||||
|
||||
init {
|
||||
// Classify MediaStreams
|
||||
val audio = ArrayList<MediaStream>()
|
||||
|
||||
@@ -101,10 +101,8 @@ class PlayerFragment : Fragment() {
|
||||
val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) }
|
||||
requireContext().toast(safeMessage)
|
||||
}
|
||||
viewModel.mediaQueueManager.mediaQueue.observe(this) { queueItem ->
|
||||
val jellyfinMediaSource = queueItem.jellyfinMediaSource
|
||||
|
||||
if (jellyfinMediaSource.selectedVideoStream?.isLandscape == false) {
|
||||
viewModel.queueManager.currentMediaSource.observe(this) { mediaSource ->
|
||||
if (mediaSource.selectedVideoStream?.isLandscape == false) {
|
||||
// For portrait videos, immediately enable fullscreen
|
||||
playerFullscreenHelper.enableFullscreen()
|
||||
} else if (appPreferences.exoPlayerStartLandscapeVideoInLandscape) {
|
||||
@@ -113,8 +111,8 @@ class PlayerFragment : Fragment() {
|
||||
}
|
||||
|
||||
// Update title and player menus
|
||||
toolbar.title = jellyfinMediaSource.name
|
||||
playerMenus?.onQueueItemChanged(queueItem)
|
||||
toolbar.title = mediaSource.name
|
||||
playerMenus?.onQueueItemChanged(mediaSource, viewModel.queueManager.hasNext())
|
||||
}
|
||||
|
||||
// Handle fragment arguments, extract playback options and start playback
|
||||
@@ -125,7 +123,7 @@ class PlayerFragment : Fragment() {
|
||||
context.toast(R.string.player_error_invalid_play_options)
|
||||
return@launch
|
||||
}
|
||||
when (viewModel.mediaQueueManager.startPlayback(playOptions, playWhenReady = true)) {
|
||||
when (viewModel.queueManager.initializePlaybackQueue(playOptions)) {
|
||||
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
|
||||
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
|
||||
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.queue.QueueManager
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@@ -101,10 +101,9 @@ class PlayerMenus(
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueueItemChanged(queueItem: QueueManager.QueueItem.Loaded) {
|
||||
nextButton.isEnabled = queueItem.hasNext()
|
||||
|
||||
val mediaSource = queueItem.jellyfinMediaSource
|
||||
fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) {
|
||||
// previousButton is always enabled and will rewind if at the start of the queue
|
||||
nextButton.isEnabled = hasNext
|
||||
|
||||
val videoStream = mediaSource.selectedVideoStream
|
||||
|
||||
|
||||
Reference in new issue
Block a user