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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user