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
+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.