fix(player): a duration of zero is not a duration

DR-251. Scrubbing was dead on Android because the seek bar had no scale:
every position tick read `<position> / 0.0`.

ExoPlayer reports C.TIME_UNSET until it has resolved a duration, and
JellyTauPlayer.getDuration() maps that to 0.0. So the engine answered
Some(0.0) rather than None, which satisfied every "unknown duration" fallback
in the controller — `observed_duration()` was never consulted, and neither was
the runtime the catalog had carried since long before anything started
decoding.

Zero is now read as "does not know yet" at each step, with a final fallback to
the item's own duration. That fixes it for any engine that cannot answer,
rather than for ExoPlayer specifically.

Red first: the test asserts a controller whose engine reports nothing usable
still reports the queued item's 1800s, and failed with None before the change.

790 Rust tests, clippy clean both ways.
This commit is contained in:
2026-08-23 09:13:21 +02:00
parent 5b5162dd1e
commit e5b7003489
2 changed files with 54 additions and 1 deletions
+52 -1
View File
@@ -1102,12 +1102,34 @@ impl PlayerController {
///
/// TRACES: UR-005 | DR-178
pub fn duration(&self) -> Option<f64> {
// Zero is not a duration, it is an engine saying it does not know yet.
//
// ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
// `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
// answers `Some(0.0)`, every "unknown duration" fallback below is
// skipped, and the seek bar is left with no scale. That presents as
// scrubbing being broken rather than as a duration that never arrived.
//
// The item usually knows: the catalog carried a runtime long before
// anything started decoding.
//
// TRACES: UR-005, UR-040 | DR-251
let usable = |d: f64| (d > 0.0).then_some(d);
self.backend
.lock_safe()
.snapshot()
.duration
.map(|d| d.as_secs_f64())
.or_else(|| self.observed_duration())
.and_then(usable)
.or_else(|| self.observed_duration().and_then(usable))
.or_else(|| {
self.queue
.lock_safe()
.current()
.and_then(|item| item.duration)
.and_then(usable)
})
}
/// Get queue reference
@@ -2290,6 +2312,35 @@ impl Default for PlayerController {
#[cfg(test)]
mod tests {
/// A duration the engine does not know must fall back to the one the item
/// carries, and zero must count as "does not know".
///
/// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
/// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
/// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
/// skipped. The seek bar then has no scale, which presents as scrubbing
/// being dead rather than as a missing duration.
///
/// TRACES: UR-005, UR-040 | DR-251 | UT-221
#[test]
fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
let controller = PlayerController::default();
let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
item.duration = Some(1800.0);
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock_safe();
queue.set_queue(vec![item], 0);
}
assert_eq!(
controller.duration(),
Some(1800.0),
"an engine that cannot report a duration should not erase the one the item carries"
);
}
use super::*;
/// Test emitter that captures events for asserting the HTML5 report methods