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:
Maxr1998 authored and Niels van Velzen committed 2023-05-05 16:29:38 +02:00
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 ->
@@ -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