Retry playback with fallback on source errors

**Changes**
When Exoplayer is used on the Android app it lets the server decide
which mode to use (direct play, direct stream, or transcode) based on
the device and content. If the content contains formats incompatible
with ExoPlayer (like compressed PGS subtitles), it will fail to load the
source. On the Android TV app it shows a similar source error in this
case, but still plays successfully. Checking the server shows that the
content is being remuxed without the subtitles, resolving the issue.
Similarly, enabling subtitles in the player forces a transcode that
allows the content to play.

This change adds similar fallback behavior, but it attempts to play the
highest quality stream on transient errors (like network flakiness).
This approach is based on
https://github.com/jellyfin/jellyfin-androidtv/pull/3916 and the
follow-up fix https://github.com/jellyfin/jellyfin-androidtv/pull/4999.

The retry workflow:
1. Try again without restrictions: it might have been a transient error
2. Try disabling direct play: maybe the container was an issue
3. Finally try transcoding when nothing else worked

The last error time is captured to reset the retry count after 30
seconds have elapsed. This should hopefully be enough time to avoid
falling back unnecessarily for transient issues. Retries will always
start with unrestricted again to maximize the quality and minimize the
stress on the server. Intentional track changes (audio or subtitle) also
reset the retry budget, since those are user-initiated reloads rather
than errors.

There's a race condition between retries in which the queue could try to
change the source. This is handled via coroutine cancellation. There's
still a small race which could cause the media from the current retry to
load briefly before the source change starts. This should be
self-correcting in practice.

**Issues**
Fixes #1931
This commit is contained in:
Kyle Farnung
2026-06-27 11:23:25 +02:00
committed by Niels van Velzen
parent b7b14db731
commit b4d4837fda
3 changed files with 88 additions and 1 deletions
@@ -141,6 +141,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
private var progressUpdateJob: Job? = null
private var chapterMarkingUpdateJob: Job? = null
private var skipMediaSegmentUpdateJob: Job? = null
private var fallbackRetryJob: Job? = null
/**
* Returns the current ExoPlayer instance or null
@@ -780,10 +781,23 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application),
setupPlayer()
queueManager.tryRestartPlayback()
} else {
_error.postValue(error.localizedMessage.orEmpty())
Timber.w(error, "Playback error, attempting fallback")
val startPosition = (playerOrNull?.currentPosition ?: 0L).milliseconds
fallbackRetryJob?.cancel()
fallbackRetryJob = viewModelScope.launch {
val retried = queueManager.restartPlaybackWithFallback(startPosition)
if (!retried) {
_error.postValue(error.localizedMessage.orEmpty())
}
}
}
}
fun cancelFallbackRetry() {
fallbackRetryJob?.cancel()
fallbackRetryJob = null
}
override fun onCleared() {
reportPlaybackStop()
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
@@ -35,6 +35,7 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@@ -51,6 +52,9 @@ class QueueManager(
private var currentQueue: List<UUID> = emptyList()
private var currentQueueIndex: Int = 0
private var playbackRetries = 0
private var lastPlaybackError = 0L
private val _currentMediaSource: MutableLiveData<JellyfinMediaSource> = MutableLiveData()
val currentMediaSource: LiveData<JellyfinMediaSource>
get() = _currentMediaSource
@@ -66,6 +70,7 @@ class QueueManager(
suspend fun initializePlaybackQueue(playOptions: PlayOptions): PlayerException? {
currentQueue = playOptions.ids
currentQueueIndex = playOptions.startIndex
resetPlaybackFallback()
val itemId = when {
currentQueue.isNotEmpty() -> currentQueue[currentQueueIndex]
@@ -143,6 +148,8 @@ class QueueManager(
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
playWhenReady: Boolean = true,
enableDirectPlay: Boolean? = null,
enableDirectStream: Boolean? = null,
): PlayerException? {
mediaSourceResolver.resolveMediaSource(
itemId = itemId,
@@ -152,6 +159,8 @@ class QueueManager(
startTime = startTime,
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
enableDirectPlay = enableDirectPlay,
enableDirectStream = enableDirectStream,
).onSuccess { jellyfinMediaSource ->
// Ensure transcoding of the current element is stopped
getCurrentMediaSourceOrNull()?.let { oldMediaSource ->
@@ -184,6 +193,55 @@ class QueueManager(
}
}
private fun resetPlaybackFallback() {
playbackRetries = 0
lastPlaybackError = 0L
viewModel.cancelFallbackRetry()
}
/**
* Retry playback with progressively degraded settings to work around source errors.
*
* Retry 1: no constraints — lets the server decide freely.
* Retry 2: disable direct play, allowing the server to fall back to direct stream.
* Retry 3: disable direct stream too, forcing the server to transcode.
*
* @param startPosition The position at which to resume playback after the retry.
* @return true if a retry was initiated, false if retries are exhausted or not applicable.
*/
suspend fun restartPlaybackWithFallback(startPosition: Duration): Boolean {
val currentMediaSource = getCurrentMediaSourceOrNull() as? RemoteJellyfinMediaSource ?: return false
val now = System.currentTimeMillis()
if (playbackRetries > 0 && now - lastPlaybackError > PLAYBACK_RETRY_RESET_MS) {
Timber.i("Playback stabilized, resetting retry count from %d", playbackRetries)
playbackRetries = 0
}
playbackRetries++
lastPlaybackError = now
if (playbackRetries > MAX_PLAYBACK_RETRIES) return false
Timber.i("Retrying playback (attempt %d of %d)", playbackRetries, MAX_PLAYBACK_RETRIES)
// If already transcoding, only retry once (for transient errors); flags are null on
// retry 1 anyway, so no special-casing needed in the startRemotePlayback call.
if (currentMediaSource.playMethod == PlayMethod.TRANSCODE && playbackRetries > 1) return false
return startRemotePlayback(
itemId = currentMediaSource.itemId,
mediaSourceId = currentMediaSource.id,
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
startTime = startPosition,
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
playWhenReady = true,
enableDirectPlay = if (playbackRetries > 1) false else null,
enableDirectStream = if (playbackRetries > 2) false else null,
) == null
}
/**
* Change the maximum bitrate to the specified value.
*/
@@ -215,6 +273,8 @@ class QueueManager(
val currentMediaSource = getCurrentMediaSourceOrNull() as? RemoteJellyfinMediaSource ?: return false
resetPlaybackFallback()
startRemotePlayback(
itemId = currentQueue[--currentQueueIndex],
mediaSourceId = null,
@@ -226,6 +286,8 @@ class QueueManager(
suspend fun next(): Boolean {
if (!hasNext()) return false
resetPlaybackFallback()
when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
itemId = currentQueue[++currentQueueIndex],
@@ -369,6 +431,7 @@ class QueueManager(
suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean {
require(stream.type == MediaStreamType.AUDIO)
val currentPlayState = viewModel.getStateAndPause() ?: return false
resetPlaybackFallback()
when (val currentMediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
@@ -402,6 +465,7 @@ class QueueManager(
suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean {
require(stream == null || stream.type == MediaStreamType.SUBTITLE)
val currentPlayState = viewModel.getStateAndPause() ?: return false
resetPlaybackFallback()
when (val mediaSource = getCurrentMediaSourceOrNull()) {
is LocalJellyfinMediaSource -> startDownloadPlayback(
@@ -424,4 +488,9 @@ class QueueManager(
}
return true
}
companion object {
private const val MAX_PLAYBACK_RETRIES = 3
private const val PLAYBACK_RETRY_RESET_MS = 30_000L
}
}
@@ -32,6 +32,8 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
audioStreamIndex: Int? = null,
subtitleStreamIndex: Int? = null,
autoOpenLiveStream: Boolean = true,
enableDirectPlay: Boolean? = null,
enableDirectStream: Boolean? = null,
): Result<RemoteJellyfinMediaSource> {
// Load media source info
val playSessionId: String
@@ -50,6 +52,8 @@ class MediaSourceResolver(private val apiClient: ApiClient) {
audioStreamIndex = audioStreamIndex,
subtitleStreamIndex = subtitleStreamIndex,
autoOpenLiveStream = autoOpenLiveStream,
enableDirectPlay = enableDirectPlay,
enableDirectStream = enableDirectStream,
),
).content
}