fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117
This commit is contained in:
@@ -279,6 +279,53 @@ pub async fn player_on_playback_ended(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Try to recover playback after a **recoverable** player error, reporting
|
||||
/// whether it was handled.
|
||||
///
|
||||
/// The frontend's error handler stops the player, which is right for a real
|
||||
/// failure and wrong for a network blip — it turned every hiccup into "playback
|
||||
/// died". This is the echo path for backends that cannot decide in-process:
|
||||
/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
|
||||
/// its event thread has no controller to ask. It emits the error, the frontend
|
||||
/// echoes it here, and the decision stays in Rust — the same shape as
|
||||
/// `PlaybackEnded` → `player_on_playback_ended`.
|
||||
///
|
||||
/// Returns `true` when the stream was re-opened and the caller must NOT stop the
|
||||
/// player; `false` when the error is real and should be surfaced as before.
|
||||
/// Android decides inside its JNI callback and only emits errors it has already
|
||||
/// declined to recover, so this reports `false` for those without a second
|
||||
/// opinion — the shared attempt budget is spent by then either way.
|
||||
///
|
||||
/// TRACES: UR-004, UR-040 | DR-130 | UT-117
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
|
||||
let (position, delay_secs) = {
|
||||
let controller = player.0.lock().await;
|
||||
match controller.recoverable_error_resume() {
|
||||
Some(resume) => resume,
|
||||
None => return Ok(false),
|
||||
}
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[Recovery] Stream failed — re-opening at {:.1}s in {}s",
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
match controller.resume_stream_at(position).await {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) => {
|
||||
log::error!("[Recovery] Failed to re-open stream: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTML5 video state-report commands =====
|
||||
//
|
||||
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||
|
||||
@@ -142,6 +142,7 @@ use commands::{
|
||||
player_play_tracks,
|
||||
player_preload_upcoming,
|
||||
player_previous,
|
||||
player_recover_stream,
|
||||
player_remove_from_queue,
|
||||
player_report_media_loaded,
|
||||
player_report_position,
|
||||
@@ -698,6 +699,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_cancel_autoplay_countdown,
|
||||
player_play_next_episode,
|
||||
player_on_playback_ended,
|
||||
player_recover_stream,
|
||||
player_report_state,
|
||||
player_report_position,
|
||||
player_report_media_loaded,
|
||||
|
||||
@@ -1007,10 +1007,13 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: true,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -1032,7 +1035,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: true,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1390,11 +1390,13 @@ impl PlayerController {
|
||||
/// The wait grows with the attempt number so a short outage has time to
|
||||
/// clear, and the shared budget stops the retries when it doesn't.
|
||||
///
|
||||
/// Only *called* from the Android error callback (`#[cfg(android)]`), but
|
||||
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
/// Called from the Android error callback, which decides in-process, and from
|
||||
/// `player_recover_stream`, which is how the same decision reaches the
|
||||
/// backends whose event thread has no controller to call — MPV is built
|
||||
/// before the controller exists, so on Linux the error is emitted, echoed by
|
||||
/// the frontend, and decided here.
|
||||
///
|
||||
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
/// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
|
||||
pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
|
||||
self.claim_stream_resume()
|
||||
.map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
|
||||
|
||||
@@ -561,7 +561,7 @@ impl PlayerBackend for MpvBackend {
|
||||
/// 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
|
||||
/// TRACES: UR-005 | DR-130 | UT-121
|
||||
fn position(&self) -> f64 {
|
||||
let live = self.mpv.get_property::<f64>("time-pos").ok();
|
||||
self.observed.lock_safe().position_or_last(live)
|
||||
@@ -570,7 +570,7 @@ impl PlayerBackend for MpvBackend {
|
||||
/// Total duration — live, or the last one observed. Unloaded at EOF for the
|
||||
/// same reason as `position`.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-130 | UT-118
|
||||
/// TRACES: UR-005 | DR-130 | UT-121
|
||||
fn duration(&self) -> Option<f64> {
|
||||
let live = self.mpv.get_property::<f64>("duration").ok();
|
||||
self.observed.lock_safe().duration_or_last(live)
|
||||
|
||||
Reference in New Issue
Block a user