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:
+231
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user