fix(player): survive a flaky stream, and don't read 0:00 at EOF
Two MPV-side fixes for the same failure story — a wifi blip during playback. The demuxer gave up the moment a read failed and MPV raised EndFile(ERROR), so a momentary outage killed the track outright. Enabling ffmpeg's reconnect options handles the common case entirely below our level, so most outages never reach the recovery path at all. Set non-fatally: their availability varies with the libmpv/ffmpeg build, and losing resilience is not a reason to refuse to play anything. Separately, `time-pos` and `duration` are live properties of the *loaded* file: at EOF MPV unloads it and both stop resolving. Reading them straight through returned 0.0/unknown at exactly the moment end-of-file handling needed to know where playback had reached, so the player appeared to rewind to 0:00 as a track ended. `ObservedTime` records the last reading seen while media was loaded and the accessors fall back to it.
This commit is contained in:
@@ -102,6 +102,54 @@ pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||
format!("{}?{}", base, parts.join("&"))
|
||||
}
|
||||
|
||||
/// The last playback time actually observed while media was loaded.
|
||||
///
|
||||
/// Some backends expose position and duration as **live** properties of the
|
||||
/// loaded file — MPV's `time-pos` and `duration` stop resolving the moment it
|
||||
/// unloads the file at EOF. Reading them straight through means that at exactly
|
||||
/// the moment end-of-file handling wants to know where playback got to, the
|
||||
/// answer is `0.0` / unknown: the player appears to rewind to 0:00 as it ends.
|
||||
///
|
||||
/// The polling thread records here, and the accessors fall back to it, so an EOF
|
||||
/// reads as the last timestamp rather than as zero.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ObservedTime {
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
}
|
||||
|
||||
impl ObservedTime {
|
||||
/// Record a live reading. Non-positive durations are treated as unknown —
|
||||
/// that is how a backend reports "not established yet", not a real zero.
|
||||
pub fn record(&mut self, position: f64, duration: f64) {
|
||||
self.position = position.max(0.0);
|
||||
if duration > 0.0 {
|
||||
self.duration = Some(duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a position alone, e.g. straight after a seek, before the next poll.
|
||||
pub fn record_position(&mut self, position: f64) {
|
||||
self.position = position.max(0.0);
|
||||
}
|
||||
|
||||
/// Forget everything — a different file is loading, and the previous one's
|
||||
/// timestamp must not leak into it.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// The live reading if there is one, else the last observed value.
|
||||
pub fn position_or_last(&self, live: Option<f64>) -> f64 {
|
||||
live.filter(|p| *p >= 0.0).unwrap_or(self.position)
|
||||
}
|
||||
|
||||
/// The live reading if there is one, else the last observed value.
|
||||
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
||||
live.filter(|d| *d > 0.0).or(self.duration)
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for consecutive resume attempts that make no progress.
|
||||
///
|
||||
/// Held by the player controller across ends of the *same* stream. Any position
|
||||
@@ -225,6 +273,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug: MPV unloads the file at EOF, so `time-pos` stops resolving and a
|
||||
/// straight read reports 0.0 — the position collapses to zero at precisely
|
||||
/// the moment end-of-file handling needs to know where playback reached.
|
||||
#[test]
|
||||
fn test_eof_reads_as_the_last_observed_timestamp() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
|
||||
// The file is gone: both live properties fail.
|
||||
assert_eq!(observed.position_or_last(None), 178.0);
|
||||
assert_eq!(observed.duration_or_last(None), Some(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_readings_win_while_the_file_is_loaded() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
|
||||
assert_eq!(observed.position_or_last(Some(12.0)), 12.0);
|
||||
assert_eq!(observed.duration_or_last(Some(240.0)), Some(240.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unestablished_duration_is_not_recorded_as_zero() {
|
||||
let mut observed = ObservedTime::default();
|
||||
// A backend reports 0.0 for "duration not known yet", not a real zero.
|
||||
observed.record(5.0, 0.0);
|
||||
assert_eq!(observed.duration_or_last(None), None);
|
||||
assert_eq!(observed.position_or_last(None), 5.0);
|
||||
|
||||
observed.record(6.0, 180.0);
|
||||
assert_eq!(observed.duration_or_last(Some(0.0)), Some(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_stops_the_previous_file_leaking_into_the_next() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(178.0, 180.0);
|
||||
observed.reset();
|
||||
|
||||
assert_eq!(observed.position_or_last(None), 0.0);
|
||||
assert_eq!(observed.duration_or_last(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seek_updates_the_last_position_before_the_next_poll() {
|
||||
let mut observed = ObservedTime::default();
|
||||
observed.record(10.0, 180.0);
|
||||
observed.record_position(120.0);
|
||||
|
||||
assert_eq!(observed.position_or_last(None), 120.0);
|
||||
assert_eq!(
|
||||
observed.duration_or_last(None),
|
||||
Some(180.0),
|
||||
"seeking does not change how long the file is"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_tracker_bounds_stalled_retries() {
|
||||
let mut tracker = ResumeTracker::default();
|
||||
|
||||
Reference in New Issue
Block a user