mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-10-29 18:22:03 +01:00
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:
committed by
Niels van Velzen
parent
b7b14db731
commit
b4d4837fda
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user