Tapping the video surface pause-looped: it would unpause and bounce straight back to paused about a second later. Long-press unpaused fine, which is what pinned it to the tap path rather than the media pipeline. The gesture handler deferred the first tap's play/pause behind a 300ms timer so a second tap could cancel it and seek instead. But the timer callback cleared its own handle *before* invoking the toggle, and handleVideoClick used exactly that handle (`tapTimeout !== null`) to suppress the compatibility click Android's WebView synthesizes after a touch. So the guard was already open when the late click arrived, and it toggled a second time. Replace the deferral with immediate action — there are only first and second taps: 1st tap: toggle play/pause 2nd tap: seek, then toggle play/pause again The second toggle undoes the first, so a double tap seeks while leaving the play state exactly as it was: playing jumps and keeps playing, paused jumps and stays paused. No timer, no window race, no loop. Click suppression no longer depends on the timer: ignore detail === 0 and any click within 700ms of a touch tap, since Android can deliver the synthesized click late and with a real detail value. A swipe now undoes the touchstart toggle (latched on swipeGestureActive so it happens once, not per touchmove frame), keeping brightness swipes from changing the play state. UT-085..087 described the old deferred behaviour and are updated to the new contract. UT-091 is used for the DR-097 facade tests, since UT-089 and UT-090 were already claimed by extract-traces.test.ts.
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
/**
|
|
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
|
|
*
|
|
* TRACES: UR-005 | DR-097 | UT-091
|
|
*
|
|
* 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);
|
|
});
|
|
});
|