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:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
|
||||
*
|
||||
* TRACES: UR-005 | DR-097 | UT-089
|
||||
*
|
||||
* The frontend used to short-circuit transport controls whenever a video adapter
|
||||
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
|
||||
* directly, so the Rust `PlayerController` never saw the intent and could not
|
||||
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
|
||||
* element buffers or settles a seek, two intents arriving ~150ms apart could read
|
||||
* *different* values and perform *opposing* actions — one playing, one pausing —
|
||||
* which is the self-sustaining play/pause loop observed on Android.
|
||||
*
|
||||
* The rule these tests pin: a transport intent always reaches the backend. Rust
|
||||
* decides play-vs-pause from controller state and drives the webview element back
|
||||
* through a ControlCommand event (the same "backend decides, adapter executes"
|
||||
* split `player_seek_video` already uses).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCommands = {
|
||||
playerPlay: vi.fn(async () => ({})),
|
||||
playerPause: vi.fn(async () => ({})),
|
||||
playerToggle: vi.fn(async () => ({})),
|
||||
playerStop: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
vi.mock("$lib/api/bindings", () => ({
|
||||
commands: mockCommands,
|
||||
// Stores pulled in transitively subscribe to typed events at module load.
|
||||
events: {
|
||||
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||
downloadEvent: { listen: vi.fn(async () => () => {}) },
|
||||
searchEvent: { listen: vi.fn(async () => () => {}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: {
|
||||
subscribe: (fn: (v: unknown) => void) => {
|
||||
fn({ isAuthenticated: true });
|
||||
return () => {};
|
||||
},
|
||||
getRepository: () => ({ getHandle: () => "handle-1" }),
|
||||
},
|
||||
}));
|
||||
|
||||
/** A video adapter that records whether the facade reached into it directly. */
|
||||
function makeAdapter() {
|
||||
return {
|
||||
kind: "html5" as const,
|
||||
play: vi.fn(async () => {}),
|
||||
pause: vi.fn(async () => {}),
|
||||
toggle: vi.fn(async () => true),
|
||||
seekElement: vi.fn(async () => {}),
|
||||
reloadSource: vi.fn(async () => {}),
|
||||
attach: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
setVolume: vi.fn(),
|
||||
setMuted: vi.fn(),
|
||||
selectSubtitle: vi.fn(async () => {}),
|
||||
getPosition: vi.fn(() => 0),
|
||||
load: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("transport authority lives in Rust", () => {
|
||||
let playerController: any;
|
||||
let adapter: ReturnType<typeof makeAdapter>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
({ playerController } = await import("./index"));
|
||||
adapter = makeAdapter();
|
||||
playerController.setActiveAdapter(adapter);
|
||||
});
|
||||
|
||||
it("routes toggle to the backend even when a video adapter is active", async () => {
|
||||
await playerController.toggle();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
// The webview must NOT decide play-vs-pause from the DOM.
|
||||
expect(adapter.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes play to the backend even when a video adapter is active", async () => {
|
||||
await playerController.play();
|
||||
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes pause to the backend even when a video adapter is active", async () => {
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
|
||||
playerController.clearActiveAdapter();
|
||||
|
||||
await playerController.toggle();
|
||||
await playerController.play();
|
||||
await playerController.pause();
|
||||
|
||||
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user