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
+10 -4
View File
@@ -5,7 +5,7 @@
* frontend stores accordingly. This enables push-based updates instead
* of polling.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
/**
* Route a backend-originated control command to the active player adapter, so a
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
* that Rust cannot reach directly. No-op when no video adapter is active (audio
* playback is already fully backend-driven).
* backend intent can drive the webview <video>/<audio> element that Rust cannot
* reach directly. No-op when no adapter is active (native playback is already
* fully backend-driven).
*
* This is the EXECUTION half of transport authority: for webview-rendered media
* the Rust controller decides play-vs-pause from the state the element reported
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
* facade in $lib/player) and come back through this path — never short-circuited
* in the webview, which is what caused the DR-097 pause loop.
*/
function handleControlCommand(action: string, position: number | null): void {
const adapter = playerController.getActiveAdapter();