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:
2026-08-04 19:39:14 +02:00
parent 62873cab3d
commit 32f8de5c91
2 changed files with 187 additions and 7 deletions
+81 -7
View File
@@ -2,6 +2,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use super::stream_end::ObservedTime;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
@@ -26,6 +27,13 @@ pub struct MpvBackend {
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
last_seek_time: Arc<AtomicU64>,
/// Last position/duration seen while a file was loaded.
///
/// `time-pos` and `duration` are live properties of the *loaded* file: at
/// EOF MPV unloads it and both stop resolving, so reading them straight
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
/// know where playback reached. See [`ObservedTime`].
observed: Arc<Mutex<ObservedTime>>,
}
struct InternalState {
@@ -139,6 +147,31 @@ impl MpvBackend {
message: format!("Failed to set initial volume: {:?}", e),
})?;
// Survive a flaky connection instead of dying on it. Without these,
// ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
// EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
// in the demuxer handles the common case entirely below our level, so
// most outages never reach the recovery in `player_recover_stream`.
//
// Non-fatal: these are ffmpeg-side options whose availability varies with
// the libmpv/ffmpeg build, and losing resilience is not a reason to
// refuse to play anything (graceful backend init, CLAUDE.md).
mpv.set_property(
"stream-lavf-o",
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
)
.unwrap_or_else(|e| {
warn!(
"[MpvBackend] Could not enable stream reconnection: {:?} — \
playback will not survive network interruptions",
e
);
});
mpv.set_property("network-timeout", 15i64)
.unwrap_or_else(|e| {
warn!("[MpvBackend] Could not set network timeout: {:?}", e);
});
let state = Arc::new(Mutex::new(InternalState {
current_media: None,
volume: 1.0,
@@ -152,6 +185,7 @@ impl MpvBackend {
playback_reporter,
position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)),
observed: Arc::new(Mutex::new(ObservedTime::default())),
};
// Start event loop in background thread
@@ -250,8 +284,22 @@ impl MpvBackend {
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
// Don't emit - player is shutting down
} else if reason == MPV_END_FILE_REASON_ERROR {
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
// Don't emit - we should handle errors separately
// NOT PlaybackEnded — the track did not finish, so
// autoplay must not advance. It is an error, and it
// has to be *said*: emitting nothing here left
// playback halted with the UI still showing
// "playing" and no way back. Marked recoverable so
// the frontend echoes it into player_recover_stream,
// which re-opens the stream where it stopped —
// MPV's own reconnect handles shorter blips before
// they ever get this far.
warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::Error {
message: "Playback stream failed".to_string(),
recoverable: true,
});
}
} else {
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
}
@@ -283,6 +331,7 @@ impl MpvBackend {
let reporter_for_position = reporter.clone();
let throttler_for_position = throttler.clone();
let last_seek_time_for_position = self.last_seek_time.clone();
let observed_for_position = self.observed.clone();
std::thread::spawn(move || {
loop {
@@ -294,6 +343,13 @@ impl MpvBackend {
mpv_for_position.get_property::<f64>("time-pos"),
mpv_for_position.get_property::<f64>("duration"),
) {
// Remember it: both properties belong to the *loaded* file and
// stop resolving the instant MPV unloads it at EOF, which is
// exactly when end-of-file handling asks where playback got to.
// Recorded before the post-seek skip below so a track that ends
// right after a seek still reports the seek target, not zero.
observed_for_position.lock_safe().record(pos, dur);
// Check if we recently seeked - skip position updates briefly after seeks
// to avoid "jumping to zero" visual glitches while MPV is seeking
let now = SystemTime::now()
@@ -404,6 +460,9 @@ impl PlayerBackend for MpvBackend {
let mut state = self.state.lock_safe();
state.current_media = Some(media.clone());
}
// A different file: the previous one's timestamp must not survive as this
// one's "last observed" position.
self.observed.lock_safe().reset();
// Load the media file
self.mpv
@@ -469,6 +528,10 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to seek: {:?}", e),
})?;
// The poll thread suppresses updates for 150ms after a seek, so without
// this a file ending inside that window would report the pre-seek time.
self.observed.lock_safe().record_position(position);
Ok(())
}
@@ -491,15 +554,26 @@ impl PlayerBackend for MpvBackend {
Ok(())
}
/// Current position — the live `time-pos`, or the last one observed while a
/// file was loaded.
///
/// The fallback is the point: `time-pos` is a property of the *loaded* file,
/// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
/// exactly the moment end-of-file handling asks where playback reached.
///
/// TRACES: UR-005 | DR-130 | UT-118
fn position(&self) -> f64 {
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
let live = self.mpv.get_property::<f64>("time-pos").ok();
self.observed.lock_safe().position_or_last(live)
}
/// Total duration — live, or the last one observed. Unloaded at EOF for the
/// same reason as `position`.
///
/// TRACES: UR-005 | DR-130 | UT-118
fn duration(&self) -> Option<f64> {
self.mpv
.get_property::<f64>("duration")
.ok()
.filter(|d| *d > 0.0)
let live = self.mpv.get_property::<f64>("duration").ok();
self.observed.lock_safe().duration_or_last(live)
}
fn state(&self) -> PlayerState {