diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt index 139fdd22..3cf871d4 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index 9cff8793..cba2ec76 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -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 = emptyList() private var currentQueueIndex: Int = 0 + private var playbackRetries = 0 + private var lastPlaybackError = 0L + private val _currentMediaSource: MutableLiveData = MutableLiveData() val currentMediaSource: LiveData 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 + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt index f99eb907..4e78b700 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt @@ -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 { // 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 }