fix(player): decide transport in Rust for webview media (DR-097)

Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.

el.paused flips transiently while an element buffers or settles a seek.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.

The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.

Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.

Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
This commit is contained in:
2026-07-30 13:54:41 +02:00
parent 64d07b8940
commit 75cd07a5c0
6 changed files with 372 additions and 11 deletions
+231 -1
View File
@@ -152,6 +152,17 @@ pub struct PlayerController {
// Auto-play episode counter (session-based, resets on manual play)
autoplay_episode_count: Arc<Mutex<u32>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
// REPORTED state here is what lets transport (play/pause/toggle) be decided
// in Rust for that media instead of the frontend reading `el.paused` off the
// DOM — a value that flips transiently while buffering/seeking and caused
// competing intents to take opposing actions. `None` means no webview media
// is active and the native backend is authoritative. See DR-097.
html5_playing: Arc<Mutex<Option<bool>>>,
}
impl PlayerController {
@@ -174,6 +185,7 @@ impl PlayerController {
position_throttler,
end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)),
html5_playing: Arc::new(Mutex::new(None)),
};
// Start background timer thread for sleep timer countdown
@@ -476,21 +488,72 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
pub fn is_html5_active(&self) -> bool {
self.html5_playing.lock_safe().is_some()
}
/// Whether the webview element last reported itself as playing. Meaningless
/// unless [`Self::is_html5_active`] is true.
///
/// TRACES: UR-005 | DR-097
pub fn html5_is_playing(&self) -> bool {
self.html5_playing.lock_safe().unwrap_or(false)
}
/// Send a transport intent to the webview element that is rendering media.
fn emit_html5_control(&self, action: &str) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position: None,
});
}
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
// Webview-rendered media: the native backend isn't playing it, so drive
// the element via a ControlCommand instead (DR-097).
if self.is_html5_active() {
self.emit_html5_control("play");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
self.emit_html5_control("pause");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
let action = if self.html5_is_playing() {
"pause"
} else {
"play"
};
self.emit_html5_control(action);
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
backend.pause()
@@ -890,6 +953,23 @@ impl PlayerController {
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
// Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to
// the native backend — otherwise music playback would keep emitting
// ControlCommands at a element that no longer exists.
{
let mut tracked = self.html5_playing.lock_safe();
*tracked = match state.as_str() {
"playing" => Some(true),
// "loading" counts as active-but-not-playing so a toggle during
// load resolves to "play" rather than falling through to the
// native backend.
"paused" | "loading" => Some(false),
// "stopped"/"idle": element is gone, native backend resumes authority.
_ => None,
};
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
}
@@ -1489,6 +1569,156 @@ mod tests {
}
}
// ===== HTML5 transport authority (DR-097) =====
//
// Webview-rendered video is played by an element the native backend cannot
// reach, so transport for it must be decided from the state the element
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
// flips transiently while buffering/seeking — two intents ~150ms apart read
// different values, took opposing actions, and self-sustained a pause loop.
#[test]
fn test_html5_state_is_tracked_from_reports() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// No HTML5 media reported yet: the native backend stays authoritative.
assert!(!controller.is_html5_active());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(!controller.html5_is_playing());
}
#[test]
fn test_html5_toggle_from_paused_emits_play_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string()]);
}
#[test]
fn test_html5_toggle_from_playing_emits_pause_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string()]);
}
#[test]
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
// The loop signature: two intents in quick succession must NOT both
// resolve the same way, and must not produce opposing actions from a
// stale read. Rust's own tracked state makes the sequence deterministic
// as long as the element reports back between intents.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
// Element confirms the pause it was told to do.
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
}
#[test]
fn test_html5_play_and_pause_emit_control_commands() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.play().unwrap();
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
}
#[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native
// backend (music playback must not keep emitting ControlCommands).
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
controller.report_html5_state("stopped".to_string(), None);
assert!(!controller.is_html5_active());
}
#[test]
fn test_html5_transport_emits_exactly_one_control_per_intent() {
// Guards against a double-drive on platforms where the *backend* is also
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
// must replace the backend call, not run in addition to it.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.pause().unwrap();
let controls = emitter
.events()
.into_iter()
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
.count();
assert_eq!(controls, 1, "one intent must produce exactly one control");
}
#[test]
fn test_controller_volume_default() {
let controller = PlayerController::default();