fix(android): stop background audio rewinding to where it started

A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".

ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.

A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.

Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:

  before  13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
          (C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
          base, 3.5 minutes after the outage with nothing logged between
  after   14:05:08 "declining the player's retry", playback undisturbed off the
          buffer for 69s (a fatal load error is only raised when the renderer
          next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
          re-opening at 785.6s -> READY, and no rewind in the following 7 min

Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.

TRACES: UR-040, UR-004 | DR-203 | UT-200
This commit is contained in:
2026-08-19 17:29:31 +02:00
parent 69c2498cf7
commit c18d79c656
10 changed files with 4441 additions and 3928 deletions
@@ -20,6 +20,9 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import kotlinx.coroutines.*
/**
@@ -256,6 +259,45 @@ class JellyTauPlayer(private val appContext: Context) {
* (and leak) a focus request we already own. */
private var hasAudioFocus = false
/**
* Whether the stream that is loaded may be retried by the player itself.
*
* Set from Rust on every load; see [StreamRetryDecision] for why the
* background-audio handoff transcode must answer no. (DR-203)
*/
private val streamRetry = StreamRetryDecision()
/**
* The default retry behaviour, except that a stream the player could only
* restart is not retried at all.
*
* `C.TIME_UNSET` makes `ProgressiveMediaPeriod.onLoadError` return
* `DONT_RETRY_FATAL` *before* it reaches `configureRetry`, which is the
* method that would otherwise reset the sample queues and re-request the URL
* from offset 0. The error then surfaces through [onPlayerError] as
* recoverable, and Rust re-opens the stream at the position playback
* actually reached (DR-129).
*
* TRACES: UR-040, UR-004 | DR-203
*/
private val loadErrorHandlingPolicy: LoadErrorHandlingPolicy =
object : DefaultLoadErrorHandlingPolicy() {
override fun getRetryDelayMsFor(
loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo
): Long {
if (!streamRetry.playerMayRetry) {
android.util.Log.w(
"JellyTauPlayer",
"Load error on a stream that cannot be resumed in place — " +
"declining the player's retry so the backend can re-open it: " +
"${loadErrorInfo.exception}"
)
return C.TIME_UNSET
}
return super.getRetryDelayMsFor(loadErrorInfo)
}
}
init {
// Configure audio attributes for music playback with audio focus handling
val audioAttributes = AudioAttributes.Builder()
@@ -273,6 +315,13 @@ class JellyTauPlayer(private val appContext: Context) {
//
// TRACES: UR-004, UR-006 | IR-008
exoPlayer = ExoPlayer.Builder(appContext)
// Decline the player's own load-error retry for a stream it could
// only restart (DR-203). Every other source keeps the default
// behaviour, which resumes the failed load where it stopped.
.setMediaSourceFactory(
DefaultMediaSourceFactory(appContext)
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
@@ -354,6 +403,33 @@ class JellyTauPlayer(private val appContext: Context) {
updatePlaybackServiceNotification(isPlaying)
}
/**
* A jump in the timeline nobody asked for.
*
* Logged rather than acted on: with the load-error retry declined for
* streams that can only be restarted (DR-203), a backwards
* `DISCONTINUITY_REASON_INTERNAL` here means the player rewound one
* anyway, and this line is what would show it.
*/
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
val message = "▶ Position discontinuity: ${oldPosition.positionMs}ms -> " +
"${newPosition.positionMs}ms (reason=$reason)"
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
// The player moved the timeline of its own accord — the
// signature of the DR-203 rewind. Loud, because with the
// retry declined it should no longer be reachable.
android.util.Log.w("JellyTauPlayer", "$message — player-initiated")
} else if (newPosition.positionMs < oldPosition.positionMs - 1000) {
// Backwards, but asked for: a seek, or the re-prepare a
// stream resume does (reason REMOVE). Normal, so quiet.
android.util.Log.d("JellyTauPlayer", message)
}
}
override fun onPlayerError(error: PlaybackException) {
android.util.Log.e("JellyTauPlayer", "▶▶▶ PLAYER ERROR: ${error.errorCodeName}", error)
android.util.Log.e("JellyTauPlayer", " Error code: ${error.errorCode}")
@@ -845,11 +921,16 @@ class JellyTauPlayer(private val appContext: Context) {
artworkUrl: String?,
durationMs: Long,
mediaType: String = "audio",
subtitlesJson: String = "[]"
subtitlesJson: String = "[]",
nonResumableStream: Boolean = false
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Who owns recovery for this stream, decided in Rust (DR-203). Set
// before prepare(), since the first load error can arrive as soon as
// the player starts reading.
streamRetry.onLoad(nonResumableStream)
// Store metadata for notification updates
currentTitle = title
@@ -0,0 +1,50 @@
package com.dtourolle.jellytau.player
/**
* Whether the *player* is allowed to retry a failed load of what is currently
* loaded, or whether recovery belongs to the backend instead.
*
* Pure state, deliberately free of any media3 or Android type so the decision is
* unit-testable off-device — the same shape as `ScreenWakeState` (DR-202).
*
* ExoPlayer resumes a failed load in place only when it knows where "in place"
* is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
* content length is known *or* the extractor produced a seek map with a
* duration, and otherwise assumes the source is live — it resets every sample
* queue and re-requests the URL from offset 0.
*
* The background-audio handoff transcode (UR-040) satisfies neither condition:
* `/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http` is chunked, so
* there is no `Content-Length`, and a live mp3 encode carries no `Xing` header,
* so the duration is unset — visible in logcat as every position tick reading
* `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so a
* restart from offset 0 drops playback back to where audio-only mode began and
* carries on from there, and because that is a successful *retry* rather than a
* failure, no error and no `STATE_ENDED` is ever reported: the app cannot see it
* happen. That is the bug this exists to prevent (DR-203).
*
* Rust decides which streams those are and says so on every load; this only
* remembers the answer for the load-error policy to read. Refusing the retry
* turns the silent rewind into a recoverable error, which the backend answers by
* re-opening the stream at the position playback actually reached (DR-129).
*
* TRACES: UR-040, UR-004 | DR-203 | UT-200
*/
class StreamRetryDecision {
@Volatile
private var nonResumableStream = false
/**
* Record what is being loaded.
*
* @param nonResumable whether re-requesting this stream would restart it
* rather than continue it — `player_retry_restarts_stream` in Rust.
*/
fun onLoad(nonResumable: Boolean) {
nonResumableStream = nonResumable
}
/** True while the player may handle a load error by retrying it itself. */
val playerMayRetry: Boolean
get() = !nonResumableStream
}
@@ -0,0 +1,47 @@
package com.dtourolle.jellytau.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Who owns recovery for the stream that is loaded.
*
* TRACES: UR-040, UR-004 | DR-203 | UT-200
*/
class StreamRetryDecisionTest {
/** Nothing loaded yet is an ordinary stream: the player retries as it always has. */
@Test
fun `starts allowing the player to retry`() {
assertTrue(StreamRetryDecision().playerMayRetry)
}
/**
* The reported bug: the length-less handoff transcode can only be "retried"
* from its beginning, which replays the episode from the handoff point
* without reporting anything. The player must not be allowed to try.
*/
@Test
fun `a non-resumable stream refuses the player its retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = true)
assertFalse(decision.playerMayRetry)
}
@Test
fun `an ordinary stream keeps the player retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = false)
assertTrue(decision.playerMayRetry)
}
/** The next load decides for itself — the handoff must not outlive its item. */
@Test
fun `loading an ordinary stream after a handoff restores the retry`() {
val decision = StreamRetryDecision()
decision.onLoad(nonResumable = true)
decision.onLoad(nonResumable = false)
assertTrue(decision.playerMayRetry)
}
}
+6 -1
View File
@@ -17,6 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use super::stream_end;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{audio_settings_jni_payload, AudioSettings};
use crate::utils::conversions::seconds_to_ticks;
@@ -348,6 +349,9 @@ impl PlayerBackend for ExoPlayerBackend {
let artwork_url = media.artwork_url.clone();
// Convert duration from seconds to milliseconds
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
// A stream the player could only "retry" by restarting it must not be
// retried by the player at all — recovery is ours. (DR-203)
let player_retry_restarts_stream = stream_end::player_retry_restarts_stream(media);
// Update local state
{
@@ -454,7 +458,7 @@ impl PlayerBackend for ExoPlayerBackend {
let result = env.call_method(
&self.player_ref,
"loadWithMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Z)V",
&[
JValue::Object(&url_jstring),
JValue::Object(&media_id_jstring),
@@ -465,6 +469,7 @@ impl PlayerBackend for ExoPlayerBackend {
JValue::Long(duration_ms),
JValue::Object(&media_type_jstring),
JValue::Object(&subtitles_jstring),
JValue::Bool(player_retry_restarts_stream as u8),
],
);
+1 -2
View File
@@ -1670,8 +1670,7 @@ impl PlayerController {
/// audio-only handoff, the only place a length-less progressive transcode is
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
fn is_audio_only_video(item: &MediaItem) -> bool {
item.media_type == MediaType::Audio
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
stream_end::is_audio_only_video(item)
}
/// Claim a resume attempt for the current stream, returning the absolute
+131
View File
@@ -22,6 +22,8 @@
//! not a finish — and the right response is to re-open the stream where it died,
//! which is the "buffer and resume" the user expects.
use crate::player::media::{MediaItem, MediaSource, MediaType};
/// How far short of the item's runtime a stream may end and still count as a
/// natural finish.
///
@@ -45,6 +47,53 @@ pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
/// either the resume made progress, or a different item is loaded.
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
/// A video item played through the native *audio* path — i.e. the background
/// audio-only handoff, the only place a length-less progressive transcode is
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
///
/// TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200
pub fn is_audio_only_video(item: &MediaItem) -> bool {
item.media_type == MediaType::Audio
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
}
/// Would the *player's own* load-error retry restart this stream from its
/// beginning? If so the retry must be switched off and recovery left to
/// [`crate::player::PlayerController::recoverable_error_resume`].
///
/// ExoPlayer resumes a failed load in place only when it knows where "in place"
/// is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
/// content length is known *or* the extractor produced a seek map with a
/// duration, and otherwise treats the source as live — the data at the URL is
/// assumed to have changed, so it resets every sample queue and re-requests the
/// URL from offset 0.
///
/// The handoff transcode satisfies neither condition: it is chunked (no
/// `Content-Length`) and a live mp3 encode carries no `Xing` header, so the
/// player reports its duration as unset — visible in logcat as every position
/// tick reading `<position> / 0.0`. Its URL carries `StartTimeTicks` = the
/// handoff point, so restarting it from offset 0 restarts the *episode* at the
/// handoff point, and playback then runs on from there. Nothing surfaces: no
/// error, no `STATE_ENDED`, so neither the truncation path nor the error path of
/// DR-129 is consulted, and the app's only sign of it is a position that jumps
/// backwards. That is the "it randomly jumps back to where audio-only started"
/// the user sees, and how random it is depends on whether a network blip happens
/// to land while a load is in flight rather than while the ~50s buffer covers it.
///
/// A retry that can only restart the stream is worth less than no retry at all:
/// declining it turns the silent rewind into a recoverable error, which
/// `recoverable_error_resume` answers by re-opening the stream at the position
/// playback actually reached (`StartTimeTicks` rewritten, backoff and attempt
/// budget included). Every other source keeps the player's retry: a static file
/// and an HLS playlist both declare their timeline, so ExoPlayer resumes them
/// exactly where the load failed.
///
/// TRACES: UR-040, UR-004 | DR-203 | UT-200
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn player_retry_restarts_stream(item: &MediaItem) -> bool {
is_audio_only_video(item) && matches!(item.source, MediaSource::Remote { .. })
}
/// Did this end-of-stream happen far enough short of the item's runtime to be a
/// truncation rather than a finish?
///
@@ -202,6 +251,88 @@ impl ResumeTracker {
mod tests {
use super::*;
use std::path::PathBuf;
/// The background-audio handoff item, as `player_enter_background_audio`
/// builds it: the episode replayed as AUDIO off a remote stream URL whose
/// `StartTimeTicks` is the handoff point.
fn handoff_item() -> MediaItem {
MediaItem {
id: "ep2".to_string(),
title: "Episode 2".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: Some(1500.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
.to_string(),
jellyfin_item_id: "ep2".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: Some("series1".to_string()),
server_id: None,
}
}
/// The reported bug: a load error on the length-less handoff transcode let
/// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
/// the URL at its `StartTimeTicks` and drops playback back to the handoff
/// point, silently. This item must never be left to the player's own retry.
#[test]
fn test_handoff_transcode_must_not_use_the_players_own_retry() {
assert!(player_retry_restarts_stream(&handoff_item()));
}
#[test]
fn test_music_keeps_the_players_retry() {
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
// ranges, so ExoPlayer resumes it where the load failed.
let track = MediaItem {
item_type: Some("Audio".to_string()),
..handoff_item()
};
assert!(!player_retry_restarts_stream(&track));
}
#[test]
fn test_video_keeps_the_players_retry() {
// An HLS playlist declares its segments, so a failed segment load is
// retried at that segment, not at the start of the episode.
let video = MediaItem {
media_type: MediaType::Video,
..handoff_item()
};
assert!(!player_retry_restarts_stream(&video));
}
#[test]
fn test_downloaded_episode_keeps_the_players_retry() {
// A local file has no length problem and no network to lose.
let local = MediaItem {
source: MediaSource::Local {
file_path: PathBuf::from("/data/ep2.mkv"),
jellyfin_item_id: Some("ep2".to_string()),
},
..handoff_item()
};
assert!(!player_retry_restarts_stream(&local));
}
#[test]
fn test_end_near_duration_is_a_natural_finish() {
// Episode runtime 25:00, stream ended at 24:56 — that is the end.