Merge pull request #4974 from LoneAngelFayt/fix/streaming-desktop-pad-nav

fix(streaming): let the desktop session own the controller
This commit is contained in:
Georges-Antoine Assi
2026-10-01 09:09:04 -04:00
committed by GitHub
18 changed files with 534 additions and 161 deletions
@@ -13,14 +13,7 @@
// Tone defaults to "warning"; pass "danger" for irreversible-and-serious.
import { RBtn, RDialog, RTextField } from "@v2/lib";
import type { Emitter } from "mitt";
import {
computed,
inject,
nextTick,
onBeforeUnmount,
onMounted,
ref,
} from "vue";
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { Events } from "@/types/emitter";
@@ -34,7 +27,6 @@ const emitter = inject<Emitter<Events>>("emitter");
const open = ref(false);
const payload = ref<Payload | null>(null);
const typed = ref("");
const cancelButtonRef = ref<InstanceType<typeof RBtn> | null>(null);
const tone = computed(() => payload.value?.tone ?? "warning");
const confirmColor = computed(() =>
@@ -66,9 +58,6 @@ function onShow(p: Payload) {
payload.value = p;
typed.value = "";
open.value = true;
nextTick(() => {
cancelButtonRef.value?.$el?.focus?.();
});
}
// Closing without picking (header X, route change) counts as a cancel.
@@ -122,9 +111,11 @@ onBeforeUnmount(() => emitter?.off("showConfirm", onShow));
</div>
</template>
<template v-if="payload" #footer-start>
<RBtn ref="cancelButtonRef" variant="outlined" @click="onCancel">
<!-- eslint-disable vuejs-accessibility/no-autofocus -- RDialog reads [autofocus] to place initial focus, and Cancel is the safe default -->
<RBtn autofocus variant="outlined" @click="onCancel">
{{ payload.cancelText ?? t("common.cancel") }}
</RBtn>
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
</template>
<template v-if="payload" #footer>
<RBtn
@@ -4,7 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent } from "vue";
import storePlaying from "@/stores/playing";
import { useInputModality } from "@/v2/composables/useInputModality";
import { AXIS_THRESHOLD, PAD_BUTTON, useGamepad } from "./index";
import {
popEscapable,
pushEscapable,
} from "@/v2/lib/overlays/RDialog/escapeStack";
import {
AXIS_THRESHOLD,
EXIT_CHORD_HOLD_MS,
PAD_BUTTON,
useGamepad,
} from "./index";
vi.mock("vue-router", () => ({
useRoute: () => ({ path: "/platforms" }),
@@ -31,11 +40,11 @@ function padWithStick(x: number, y: number): Gamepad {
};
}
function padHolding(button: number): Gamepad {
function padHolding(...held: number[]): Gamepad {
const buttons = Array.from({ length: 17 }, (_, i) => ({
pressed: i === button,
touched: i === button,
value: i === button ? 1 : 0,
pressed: held.includes(i),
touched: held.includes(i),
value: held.includes(i) ? 1 : 0,
}));
return { ...padWithStick(0, 0), buttons };
}
@@ -59,9 +68,9 @@ describe("useGamepad", () => {
// Seeing a pad at install flips the modality, so hand the session back to
// the mouse: these tests are about the user who launched with a click.
function installOnMouse(pad: Gamepad) {
function installOnMouse(pad: Gamepad | (() => Gamepad[])) {
Object.defineProperty(navigator, "getGamepads", {
value: () => [pad],
value: typeof pad === "function" ? pad : () => [pad],
configurable: true,
});
wrapper = mount(
@@ -144,4 +153,127 @@ describe("useGamepad", () => {
expect(keys).toEqual([]);
expect(modality.value).toBe("mouse");
});
describe("exit chord", () => {
const CHORD = [PAD_BUTTON.back, PAD_BUTTON.start];
let pads: Gamepad[] = [];
let chords = 0;
const onChord = () => chords++;
let now: ReturnType<typeof vi.spyOn>;
// Polls once at `ms` past the clock's start.
function stepAt(ms: number) {
now.mockReturnValue(1000 + ms);
step();
}
beforeEach(() => {
pads = [];
chords = 0;
now = vi.spyOn(performance, "now").mockReturnValue(1000);
window.addEventListener("gamepad:exitchord", onChord);
installOnMouse(() => pads);
storePlaying().setPlaying(true);
});
afterEach(() => {
window.removeEventListener("gamepad:exitchord", onChord);
});
it("fires once Select+Start has been held for the full hold", () => {
pads = [padHolding(...CHORD)];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS - 1);
expect(chords).toBe(0);
stepAt(EXIT_CHORD_HOLD_MS);
expect(chords).toBe(1);
});
it("waits for a release before a held chord can fire again", () => {
pads = [padHolding(...CHORD)];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS);
stepAt(EXIT_CHORD_HOLD_MS * 3);
expect(chords).toBe(1);
pads = [padHolding()];
stepAt(EXIT_CHORD_HOLD_MS * 3 + 100);
pads = [padHolding(...CHORD)];
stepAt(EXIT_CHORD_HOLD_MS * 4);
stepAt(EXIT_CHORD_HOLD_MS * 5);
expect(chords).toBe(2);
});
it("starts the hold over when either button is let go", () => {
pads = [padHolding(...CHORD)];
stepAt(0);
pads = [padHolding(PAD_BUTTON.back)];
stepAt(1000);
pads = [padHolding(...CHORD)];
stepAt(1100);
stepAt(1100 + EXIT_CHORD_HOLD_MS - 1);
expect(chords).toBe(0);
});
it("ignores a disconnected pad's stale buttons", () => {
// Firefox keeps disconnected entries in getGamepads() (#3851).
pads = [{ ...padHolding(...CHORD), connected: false }];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS * 2);
expect(chords).toBe(0);
});
it("ignores pads without the standard mapping", () => {
pads = [{ ...padHolding(...CHORD), mapping: "" }];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS * 2);
expect(chords).toBe(0);
});
it("stays quiet outside a game, where Select and Start drive the app", () => {
storePlaying().setPlaying(false);
pads = [padHolding(...CHORD)];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS * 2);
expect(chords).toBe(0);
});
it("holds off while an overlay sits over the game", () => {
const dialog = { close: () => {}, persistent: true };
pushEscapable(dialog);
try {
pads = [padHolding(...CHORD)];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS * 2);
} finally {
popEscapable(dialog);
}
expect(chords).toBe(0);
});
it("stays fired after its dialog closes while the chord is still held", () => {
const dialog = { close: () => {}, persistent: false };
pads = [padHolding(...CHORD)];
stepAt(0);
stepAt(EXIT_CHORD_HOLD_MS);
expect(chords).toBe(1);
pushEscapable(dialog);
try {
stepAt(EXIT_CHORD_HOLD_MS + 100);
} finally {
popEscapable(dialog);
}
stepAt(EXIT_CHORD_HOLD_MS * 3);
stepAt(EXIT_CHORD_HOLD_MS * 5);
expect(chords).toBe(1);
});
});
});
@@ -26,7 +26,9 @@
// Two contexts suppress this translation:
// * Game running (storePlaying.playing): the emulator reads the pad
// itself, so all translation is off. Otherwise B (shared by Circle /
// Nintendo-A in the standard mapping) would quit the game.
// Nintendo-A in the standard mapping) would quit the game. Holding
// Select+Start instead fires `gamepad:exitchord`; a player opts into
// a pad exit by listening for it.
// * Controller-test screen (ACTIONS_DISABLED_PATHS): built-in actions
// are muted so every button can be pressed and inspected in place.
import { onBeforeUnmount } from "vue";
@@ -51,6 +53,8 @@ const ACTIONS_DISABLED_PATHS = new Set<string>(["/controller-debug"]);
const INITIAL_DELAY_MS = 350;
const REPEAT_MS = 120;
export const AXIS_THRESHOLD = 0.5;
// Outlasts anything a game itself binds to Select+Start.
export const EXIT_CHORD_HOLD_MS = 1500;
// Short symbolic name → W3C standard-mapping button index, so a view can
// read raw pad state without memorising indices. Exhaustive over the standard
@@ -92,6 +96,7 @@ export interface GamepadButtonEventDetail {
declare global {
interface WindowEventMap {
"gamepad:buttondown": CustomEvent<GamepadButtonEventDetail>;
"gamepad:exitchord": Event;
}
}
@@ -123,6 +128,15 @@ export function isUsablePad(pad: Gamepad | null): pad is Gamepad {
return pad !== null && pad.connected;
}
// Other mappings don't guarantee the Back/Start indices.
function holdsExitChord(pad: Gamepad): boolean {
return (
pad.mapping === "standard" &&
!!pad.buttons[PAD_BUTTON.back]?.pressed &&
!!pad.buttons[PAD_BUTTON.start]?.pressed
);
}
function dispatchKey(binding: Binding) {
const target =
(document.activeElement as HTMLElement | null) ?? document.body;
@@ -242,6 +256,10 @@ export function useGamepad() {
const { setModality } = useInputModality();
let rafId = 0;
let everSawPad = false;
let chordHeldSince: number | null = null;
// Cleared only on release, so a chord still held after its dialog is
// cancelled doesn't reopen it.
let chordFired = false;
const onAnyInput = () => setModality("pad");
const onConnect = () => setModality("pad");
@@ -285,10 +303,12 @@ export function useGamepad() {
// On the controller-test screen the built-in actions are muted so
// every button can be pressed and inspected without side effects.
const actionsDisabled = ACTIONS_DISABLED_PATHS.has(route.path);
let chordHeld = false;
for (const pad of pads) {
// Skip disconnected phantom gamepads.
if (!isUsablePad(pad)) continue;
chordHeld ||= holdsExitChord(pad);
const key = `${pad.index}:${pad.id}`;
const st = (states[key] ||= {
buttons: {},
@@ -353,6 +373,20 @@ export function useGamepad() {
}
}
// B is muted during play, so a long Select+Start hold is the pad's exit.
if (!chordHeld) {
chordHeldSince = null;
chordFired = false;
} else if (!gameOwnsInput) {
chordHeldSince = null;
} else if (!chordFired) {
chordHeldSince ??= t;
if (t - chordHeldSince >= EXIT_CHORD_HOLD_MS) {
chordFired = true;
window.dispatchEvent(new Event("gamepad:exitchord"));
}
}
rafId = requestAnimationFrame(loop);
};
@@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, ref } from "vue";
import storePlaying from "@/stores/playing";
import { useGridNav } from "@/v2/composables/useGridNav";
import {
type EscapableEntry,
popEscapable,
pushEscapable,
} from "@/v2/lib/overlays/RDialog/escapeStack";
import { useSpatialNav } from "./index";
vi.mock("vue-router", () => ({
@@ -86,7 +91,38 @@ describe("useSpatialNav", () => {
wrapper = mount(Page, { attachTo: document.body });
});
// A dialog below the page with Cancel and Confirm side by side.
let dialog: EscapableEntry | null = null;
let dialogPanel: HTMLElement | null = null;
function openDialog(withPanel = true): void {
const panel = document.createElement("div");
for (const [id, x] of [
["cancel", 0],
["confirm", 100],
] as const) {
const button = document.createElement("button");
button.id = id;
button.dataset.x = String(x);
button.dataset.y = "600";
panel.append(button);
}
document.body.append(panel);
dialogPanel = panel;
dialog = {
close: () => {},
persistent: true,
panel: withPanel ? () => panel : undefined,
};
pushEscapable(dialog);
}
afterEach(() => {
if (dialog) {
popEscapable(dialog);
dialog = null;
}
dialogPanel?.remove();
dialogPanel = null;
wrapper?.unmount();
wrapper = null;
restores.splice(0).forEach((restore) => restore());
@@ -157,4 +193,40 @@ describe("useSpatialNav", () => {
expect(document.activeElement).toBe(el("play"));
});
it("moves between an open dialog's buttons", () => {
openDialog();
el("cancel").focus();
expect(press("ArrowRight").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(el("confirm"));
});
it("keeps focus inside the open dialog", () => {
openDialog();
el("cancel").focus();
press("ArrowUp");
expect(document.activeElement).toBe(el("cancel"));
});
it("moves inside a dialog opened over a running game", () => {
storePlaying().setPlaying(true);
openDialog();
el("cancel").focus();
press("ArrowRight");
expect(document.activeElement).toBe(el("confirm"));
});
it("leaves an overlay without a panel to its owner", () => {
openDialog(false);
el("tab").focus();
press("ArrowUp");
expect(document.activeElement).toBe(el("tab"));
});
});
@@ -3,7 +3,10 @@
// The grids listen on `document`, which an event reaches before `window`.
import { onBeforeUnmount } from "vue";
import storePlaying from "@/stores/playing";
import { hasOpenEscapable } from "@/v2/lib/overlays/RDialog/escapeStack";
import {
hasOpenEscapable,
topEscapablePanel,
} from "@/v2/lib/overlays/RDialog/escapeStack";
import {
ARROW_DIRECTIONS,
FOCUSABLE_SELECTOR,
@@ -77,10 +80,14 @@ function isInView(el: HTMLElement): boolean {
return !hit || el.contains(hit) || hit.contains(el);
}
/** Moves focus from `from` to the nearest control in `dir`, if any. */
export function moveFocus(from: HTMLElement, dir: SpatialDirection): boolean {
/** Moves focus from `from` to the nearest control in `dir` under `scope`. */
export function moveFocus(
from: HTMLElement,
dir: SpatialDirection,
scope: ParentNode = document,
): boolean {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
)
.filter((el) => el !== from && !el.contains(from) && isNavigable(el))
.map((el) => ({ item: el, box: el.getBoundingClientRect() }));
@@ -117,13 +124,20 @@ export function useSpatialNav() {
const dir = ARROW_DIRECTIONS[e.key];
if (!dir || e.defaultPrevented) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
// A running game reads the arrows itself, and an open overlay keeps
// focus inside its own panel.
if (playingStore.playing || hasOpenEscapable()) return;
const active = document.activeElement;
if (!(active instanceof HTMLElement) || active === document.body) return;
if (ownsArrowKeys(active)) return;
if (moveFocus(active, dir)) e.preventDefault();
// An open overlay, a dialog over a game included, keeps focus inside
// its own panel; otherwise a running game reads the arrows itself.
let scope: ParentNode = document;
if (hasOpenEscapable()) {
const panel = topEscapablePanel();
if (!panel?.contains(active)) return;
scope = panel;
} else if (playingStore.playing) {
return;
}
if (moveFocus(active, dir, scope)) e.preventDefault();
}
window.addEventListener("keydown", onKey);
@@ -2,7 +2,11 @@ import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { effectScope, nextTick, ref } from "vue";
import storePlaying from "@/stores/playing";
import { installStageActiveClass, useStageActive } from "./index";
import {
installStageActiveClass,
usePlayingWhile,
useStageActive,
} from "./index";
beforeEach(() => {
setActivePinia(createPinia());
@@ -50,6 +54,31 @@ describe("useStageActive", () => {
});
});
describe("usePlayingWhile", () => {
it("mirrors the active source into the playing flag", async () => {
const active = ref(true);
const scope = effectScope();
scope.run(() => usePlayingWhile(active));
const store = storePlaying();
expect(store.playing).toBe(true);
active.value = false;
await nextTick();
expect(store.playing).toBe(false);
scope.stop();
});
it("clears the flag when its owning scope is disposed", () => {
const scope = effectScope();
scope.run(() => usePlayingWhile(() => true));
const store = storePlaying();
expect(store.playing).toBe(true);
scope.stop();
expect(store.playing).toBe(false);
});
});
describe("installStageActiveClass", () => {
function htmlHasClass(): boolean {
return document.documentElement.classList.contains("r-v2-stage-active");
@@ -1,18 +1,29 @@
import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from "vue";
import storePlaying from "@/stores/playing";
// Mirrors a source into one of the playing store's flags, cleared on dispose.
function mirrorFlag(
source: MaybeRefOrGetter<boolean>,
set: (on: boolean) => void,
): void {
watch(() => toValue(source), set, { immediate: true });
onScopeDispose(() => set(false));
}
/** Mirrors a player's running state into the global stage flag that
* unmounts the app chrome and zeroes the nav-height tokens. */
export function useStageActive(running: MaybeRefOrGetter<boolean>): void {
const playingStore = storePlaying();
// Deliberately not the `playing` flag: that one also spans pre-stage
// phases (Stream sets it while loading behind its config screen).
watch(
() => toValue(running),
(active) => playingStore.setStageActive(active),
{ immediate: true },
);
onScopeDispose(() => playingStore.setStageActive(false));
mirrorFlag(running, (on) => playingStore.setStageActive(on));
}
/** Mirrors a session, launch or pending claim included, into the global
* playing flag, which hands the controller from useGamepad to the session. */
export function usePlayingWhile(active: MaybeRefOrGetter<boolean>): void {
const playingStore = storePlaying();
mirrorFlag(active, (on) => playingStore.setPlaying(on));
}
/** Mirrors the flag onto <html> (next to the theme classes) so
@@ -74,6 +74,11 @@ export function onEscapableOpen(listener: () => void): () => void {
return () => openListeners.delete(listener);
}
/** The topmost overlay's panel; null when none is open or it has no panel. */
export function topEscapablePanel(): HTMLElement | null {
return stack[stack.length - 1]?.panel?.() ?? null;
}
/** True when `el` is covered by the topmost overlay rather than living
* inside it. A panel-less entry has no opinion and covers nothing. */
export function isUnderOpenEscapable(el: Node | null): boolean {
+134 -23
View File
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
heartbeatTick: null as (() => Promise<void>) | null,
routeLeave: null as (() => Promise<boolean> | boolean) | null,
socketHandlers: {} as Record<string, (payload: unknown) => unknown>,
setPlaying: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
@@ -49,6 +50,10 @@ vi.mock("@/services/api/streaming", () => ({
},
}));
vi.mock("@/stores/playing", () => ({
default: () => ({ setPlaying: mocks.setPlaying }),
}));
vi.mock("@/stores/streaming", () => ({
useStreamingStore: () => ({ heartbeatSession: mocks.heartbeatSession }),
}));
@@ -83,6 +88,35 @@ let mounted: VueWrapper | null = null;
const CLAIMED_AT = "2026-09-17T10:00:00";
const KEY = "http://webstation-dev:8000";
const CLAIM = {
data: {
container: KEY,
host: "http://webstation-dev:8080",
label: "PS2",
platform: "ps2",
claimed_at: CLAIMED_AT,
},
};
// A pending call the test answers by hand.
function deferred() {
let answer = (_: unknown) => {};
const promise = new Promise((resolve) => {
answer = resolve;
});
return { promise, answer };
}
// Unstamped, a release reaches whichever session took the container.
function expectReleasedOwnClaim(): void {
expect(mocks.releaseSession).toHaveBeenCalledWith(
"ps2",
undefined,
KEY,
undefined,
CLAIMED_AT,
);
}
beforeEach(() => {
vi.clearAllMocks();
@@ -103,15 +137,7 @@ async function mountDesktop(): Promise<VueWrapper> {
}
async function openDesktop(): Promise<VueWrapper> {
mocks.claimDesktop.mockResolvedValue({
data: {
container: KEY,
host: "http://webstation-dev:8080",
label: "PS2",
platform: "ps2",
claimed_at: CLAIMED_AT,
},
});
mocks.claimDesktop.mockResolvedValue(CLAIM);
return mountDesktop();
}
@@ -229,12 +255,8 @@ describe("Desktop heartbeats", () => {
it("keeps the exit a release made while a beat was in flight", async () => {
const wrapper = await openDesktop();
mocks.releaseSession.mockResolvedValue({});
let answer = (_: unknown) => {};
mocks.heartbeatSession.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const { promise, answer } = deferred();
mocks.heartbeatSession.mockReturnValue(promise);
const beat = mocks.heartbeatTick?.();
expect(await mocks.routeLeave?.()).toBe(true);
@@ -256,20 +278,13 @@ describe("Desktop heartbeats", () => {
});
describe("Desktop releases", () => {
// Unstamped, a release reaches whichever session took the container.
it("names the claim it releases on exit", async () => {
mocks.releaseSession.mockResolvedValue({});
await openDesktop();
expect(await mocks.routeLeave?.()).toBe(true);
expect(mocks.releaseSession).toHaveBeenCalledWith(
"ps2",
undefined,
KEY,
undefined,
CLAIMED_AT,
);
expectReleasedOwnClaim();
});
it("names the claim it releases when the tab closes", async () => {
@@ -345,3 +360,99 @@ describe("Desktop container naming", () => {
);
});
});
describe("Desktop controller ownership", () => {
// The emulator inside the container reads the pad, so RomM's gamepad
// navigation must stay muted or B pops history out of the session.
it("mutes pad navigation while the desktop is claimed", async () => {
await openDesktop();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(true);
});
it("hands the pad back once a notice ends the claim", async () => {
await openDesktop();
endSession({ platform: "ps2", container: KEY });
await flushPromises();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
it("hands the pad back when the claim is refused", async () => {
await refuseDesktop({ rom_name: null, claimed_at: null, draining: true });
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
it("hands the pad back when the view unmounts", async () => {
const wrapper = await openDesktop();
wrapper.unmount();
mounted = null;
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
it("mutes pad navigation while the claim is still in flight", async () => {
mocks.claimDesktop.mockReturnValue(new Promise(() => {}));
const wrapper = await mountDesktop();
expect(vmOf(wrapper).state).toBe("loading");
expect(mocks.setPlaying).toHaveBeenLastCalledWith(true);
});
it("hands the pad back once the desktop is exited", async () => {
mocks.releaseSession.mockResolvedValue({});
await openDesktop();
expect(await mocks.routeLeave?.()).toBe(true);
await flushPromises();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
it("ends the desktop from the pad's exit chord", async () => {
// B is muted with the rest of the pad, so the chord is the pad's way out.
mocks.releaseSession.mockResolvedValue({});
const wrapper = await openDesktop();
window.dispatchEvent(new Event("gamepad:exitchord"));
await flushPromises();
expectReleasedOwnClaim();
expect(vmOf(wrapper).state).toBe("exited");
});
});
describe("Desktop claims that outlive the view", () => {
it("hands back a claim that answers after the view is gone", async () => {
const { promise, answer } = deferred();
mocks.claimDesktop.mockReturnValue(promise);
mocks.releaseSession.mockResolvedValue({});
const wrapper = await mountDesktop();
wrapper.unmount();
mounted = null;
answer(CLAIM);
await flushPromises();
expectReleasedOwnClaim();
});
it("hands back a claim that answers while the leave is still resolving", async () => {
const { promise, answer } = deferred();
mocks.claimDesktop.mockReturnValue(promise);
mocks.releaseSession.mockResolvedValue({});
const wrapper = await mountDesktop();
expect(await mocks.routeLeave?.()).toBe(true);
answer(CLAIM);
await flushPromises();
wrapper.unmount();
mounted = null;
await flushPromises();
expectReleasedOwnClaim();
});
});
+26 -1
View File
@@ -10,7 +10,7 @@
import { RAlert, RBtn, RSpinner } from "@v2/lib";
import { useEventListener, useIntervalFn } from "@vueuse/core";
import { isAxiosError } from "axios";
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { ROUTES } from "@/plugins/router";
@@ -21,8 +21,10 @@ import { type SessionTermination, useStreamingStore } from "@/stores/streaming";
import SessionEndedReason from "@/v2/components/Player/SessionEndedReason.vue";
import StreamStage from "@/v2/components/Player/StreamStage.vue";
import { useConfirm } from "@/v2/composables/useConfirm";
import { useIsAlive } from "@/v2/composables/useIsAlive";
import { usePageTitle } from "@/v2/composables/usePageTitle";
import { useSocketEvent } from "@/v2/composables/useSocketEvent";
import { usePlayingWhile } from "@/v2/composables/useStageActive";
const { t } = useI18n();
const route = useRoute();
@@ -55,6 +57,19 @@ const claimedAt = ref("");
usePageTitle(() => t("play.desktop-title"));
// The emulator inside the desktop owns the controller, so pad presses must not
// also drive RomM behind the stream (B would go back and prompt to end it).
const sessionActive = computed(
() => state.value === "loading" || state.value === "running",
);
usePlayingWhile(sessionActive);
// With B muted, useGamepad's Select+Start hold is the pad's way out.
useEventListener(window, "gamepad:exitchord", () => void handleExit());
// Leaving mid-claim is allowed, so a claim that answers after unmount is
// released at once; otherwise it would hold the container until it went stale.
const alive = useIsAlive();
async function openDesktop(): Promise<void> {
if (!containerName.value) {
state.value = "error";
@@ -69,6 +84,10 @@ async function openDesktop(): Promise<void> {
platform.value = data.platform;
claimedAt.value = data.claimed_at;
holdsClaim.value = true;
if (!alive.value) {
void release();
return;
}
state.value = "running";
} catch (err: unknown) {
state.value = "error";
@@ -218,6 +237,12 @@ useEventListener(window, "pagehide", onPageHide);
onMounted(() => {
void openDesktop();
});
// A claim that answered after the leave guard let navigation through, while
// the next route was still resolving, is still held here.
onBeforeUnmount(() => {
if (holdsClaim.value) void release();
});
</script>
<template>
@@ -165,7 +165,10 @@ vi.mock("@/v2/composables/useSnackbar", () => ({
}),
}));
vi.mock("@/v2/composables/useStageActive", () => ({ useStageActive: vi.fn() }));
vi.mock("@/v2/composables/useStageActive", () => ({
useStageActive: vi.fn(),
usePlayingWhile: vi.fn(),
}));
vi.mock("@/v2/composables/useUnloadGuard", () => ({ useUnloadGuard: vi.fn() }));
+5 -10
View File
@@ -30,7 +30,6 @@ import {
} from "@v2/lib";
import { useEventListener, useLocalStorage } from "@vueuse/core";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import {
computed,
defineAsyncComponent,
@@ -49,7 +48,6 @@ import romApi from "@/services/api/rom";
import { AUTOSAVE_SLOT, SAVE_SLOT_MAX_LENGTH } from "@/services/api/save";
import storeConfig from "@/stores/config";
import { useNativeStore } from "@/stores/native";
import storePlaying from "@/stores/playing";
import type { DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import {
@@ -77,7 +75,10 @@ import { usePlayerHero } from "@/v2/composables/usePlayerHero";
import { usePlayerNav } from "@/v2/composables/usePlayerNav";
import { useSaveStateTabs } from "@/v2/composables/useSaveStateTabs";
import { useSnackbar } from "@/v2/composables/useSnackbar";
import { useStageActive } from "@/v2/composables/useStageActive";
import {
usePlayingWhile,
useStageActive,
} from "@/v2/composables/useStageActive";
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
import type { AssetType } from "@/v2/utils/assets";
import { joinNames } from "@/v2/utils/lists";
@@ -135,10 +136,8 @@ const Player = defineAsyncComponent(
const { t, locale } = useI18n();
const snackbar = useSnackbar();
const emitter = inject<Emitter<Events>>("emitter");
const playingStore = storePlaying();
const configStore = storeConfig();
const nativeStore = useNativeStore();
const { playing } = storeToRefs(playingStore);
const { fullscreenOnPlay } = useFullscreenPref();
useFullscreenFallback();
const playSession = usePlaySession();
@@ -199,6 +198,7 @@ const exit = usePlayerExit(
// quiet for the full navigation it turns into.
useUnloadGuard(() => gameRunning.value && !exit.departing.value);
useStageActive(gameRunning);
usePlayingWhile(gameRunning);
onBeforeRouteLeave(exit.guard);
// Stage-scoped so the non-passive listener never taxes touches elsewhere.
@@ -471,7 +471,6 @@ async function onPlay() {
gameRunning.value = true;
window.EJS_fullscreenOnLoaded = fullscreenOnPlay.value;
playing.value = true;
const { EJS_NETPLAY_ENABLED } = configStore.config;
const EMULATORJS_VERSION = EJS_NETPLAY_ENABLED ? "nightly" : "4.2.3";
@@ -500,7 +499,6 @@ async function onPlay() {
// No emulator booted, so drop back to the config screen instead of
// leaving the unload guard and the input mute armed.
gameRunning.value = false;
playing.value = false;
}
}
@@ -674,9 +672,6 @@ onBeforeUnmount(() => {
// the user never exited the game to the config screen first. flush() is
// idempotent, so an exit that already flushed via the watch is a no-op.
endSession();
// Hand the keyboard and gamepad back to the UI; the flag otherwise
// stays true and pad/hotkey navigation is dead until a reload.
playing.value = false;
exitEmulatorOnce();
emitter?.off("saveSelected", selectSave);
emitter?.off("stateSelected", selectState);
+3 -3
View File
@@ -344,9 +344,9 @@ describe("JsDos player exit", () => {
expect(mocks.locationReplace).toHaveBeenCalledWith("/rom/1");
expect(mocks.routerReplace).not.toHaveBeenCalled();
expect(mocks.flushPlaySession).toHaveBeenCalledOnce();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
wrapper.unmount();
expect(handle.stop).toHaveBeenCalledOnce();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
// A player document opened directly is cross-origin isolated, and the rest
@@ -402,7 +402,7 @@ describe("JsDos player exit", () => {
expect(mocks.routerReplace).not.toHaveBeenCalled();
expect(mocks.locationReplace).not.toHaveBeenCalled();
expect(mocks.flushPlaySession).not.toHaveBeenCalled();
expect(mocks.setPlaying).not.toHaveBeenCalledWith(false);
expect(mocks.setPlaying).toHaveBeenLastCalledWith(true);
expect(
wrapper.get(".r-v2-player__quit").attributes("disabled"),
).toBeUndefined();
@@ -419,9 +419,9 @@ describe("JsDos player exit", () => {
expect(handle.stop).toHaveBeenCalledOnce();
expect(mocks.flushPlaySession).toHaveBeenCalledOnce();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
expect(mocks.locationReplace).toHaveBeenCalledWith("/rom/1");
wrapper.unmount();
expect(mocks.setPlaying).toHaveBeenLastCalledWith(false);
});
it("keeps the player open when the final save fails", async () => {
+2 -6
View File
@@ -6,7 +6,6 @@ import { useI18n } from "vue-i18n";
import { onBeforeRouteLeave } from "vue-router";
import romApi from "@/services/api/rom";
import storeAuth from "@/stores/auth";
import storePlaying from "@/stores/playing";
import type { DetailedRom } from "@/stores/roms";
import type { JsDosProps } from "@/types/js-dos";
import { getDownloadPath } from "@/utils";
@@ -23,12 +22,12 @@ import { usePlaySession } from "@/v2/composables/usePlaySession";
import { usePlayerExit } from "@/v2/composables/usePlayerExit";
import { usePlayerHero } from "@/v2/composables/usePlayerHero";
import { useSnackbar } from "@/v2/composables/useSnackbar";
import { usePlayingWhile } from "@/v2/composables/useStageActive";
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
import { loadJsDosRuntime } from "./jsDosRuntime";
const { t } = useI18n();
const authStore = storeAuth();
const playingStore = storePlaying();
const { fullscreenOnPlay } = useFullscreenPref();
useFullscreenFallback();
const playSession = usePlaySession();
@@ -41,6 +40,7 @@ const exit = usePlayerExit(() => runtimeBound);
const rom = shallowRef<DetailedRom | null>(null);
const gameRunning = ref(false);
usePlayingWhile(gameRunning);
const quitting = ref(false);
const stage = ref<HTMLDivElement | null>(null);
@@ -81,13 +81,10 @@ async function onPlay() {
return;
}
gameRunning.value = true;
// Let the emulator own keyboard input while running.
playingStore.setPlaying(true);
await nextTick();
if (!stage.value) {
gameRunning.value = false;
playingStore.setPlaying(false);
return;
}
@@ -136,7 +133,6 @@ async function saveQuietly(handle: JsDosProps) {
function teardown() {
playSession.flush();
playingStore.setPlaying(false);
stopDos();
}
+2 -4
View File
@@ -5,7 +5,6 @@ import { useEventListener } from "@vueuse/core";
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import { useI18n } from "vue-i18n";
import romApi from "@/services/api/rom";
import storePlaying from "@/stores/playing";
import type { DetailedRom } from "@/stores/roms";
import { getDownloadPath } from "@/utils";
import PlayerShell from "@/v2/components/Player/PlayerShell.vue";
@@ -15,6 +14,7 @@ import { usePlaySession } from "@/v2/composables/usePlaySession";
import { usePlayerFullscreen } from "@/v2/composables/usePlayerFullscreen";
import { usePlayerHero } from "@/v2/composables/usePlayerHero";
import { useSnackbar } from "@/v2/composables/useSnackbar";
import { usePlayingWhile } from "@/v2/composables/useStageActive";
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
import { createPico8Audio, type Pico8Audio } from "@/v2/utils/pico8Audio";
import { createPico8Input } from "@/v2/utils/pico8Input";
@@ -29,7 +29,6 @@ import {
} from "@/v2/utils/pico8Runtime";
const { t } = useI18n();
const playingStore = storePlaying();
const playSession = usePlaySession();
const snackbar = useSnackbar();
const { fullscreenOnPlay } = useFullscreenPref();
@@ -37,6 +36,7 @@ const alive = useIsAlive();
const rom = shallowRef<DetailedRom | null>(null);
const gameRunning = ref(false);
usePlayingWhile(gameRunning);
const loading = ref(false);
const stage = ref<HTMLDivElement | null>(null);
const canvas = ref<HTMLCanvasElement | null>(null);
@@ -186,7 +186,6 @@ function releaseGame() {
audio = null;
input.reset();
playSession.flush();
playingStore.setPlaying(false);
gameRunning.value = false;
loading.value = false;
}
@@ -211,7 +210,6 @@ async function onPlay() {
gameRunning.value = true;
loading.value = true;
playingStore.setPlaying(true);
await nextTick();
if (!gameRunning.value) return;
+3 -8
View File
@@ -7,7 +7,6 @@ import { RIcon, RSwitch } from "@v2/lib";
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import { useI18n } from "vue-i18n";
import romApi from "@/services/api/rom";
import storePlaying from "@/stores/playing";
import type { DetailedRom } from "@/stores/roms";
import type { RuffleSourceAPI } from "@/types/ruffle";
import { getDownloadPath } from "@/utils";
@@ -16,6 +15,7 @@ import { useFullscreenFallback } from "@/v2/composables/useFullscreenFallback";
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
import { usePlaySession } from "@/v2/composables/usePlaySession";
import { usePlayerHero } from "@/v2/composables/usePlayerHero";
import { usePlayingWhile } from "@/v2/composables/useStageActive";
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
import { colorCanvas } from "@/v2/tokens";
@@ -25,11 +25,12 @@ const DEFAULT_BACKGROUND_COLOR = colorCanvas.bgDeep;
const { t } = useI18n();
const { fullscreenOnPlay } = useFullscreenPref();
useFullscreenFallback();
const playingStore = storePlaying();
const playSession = usePlaySession();
const rom = shallowRef<DetailedRom | null>(null);
const gameRunning = ref(false);
// Flash games are keyboard-driven, so hotkeys and pad navigation stay muted.
usePlayingWhile(gameRunning);
const backgroundColor = ref<string>(DEFAULT_BACKGROUND_COLOR);
useUnloadGuard(gameRunning);
@@ -56,14 +57,10 @@ const { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
// Nothing is running, so drop the guard and the input mute the launch armed.
function abortPlay() {
gameRunning.value = false;
playingStore.setPlaying(false);
}
function onPlay() {
gameRunning.value = true;
// Flash games are keyboard-driven; flag the session so global hotkeys
// and pad-to-UI translation stay muted while the game owns input.
playingStore.setPlaying(true);
nextTick(() => {
if (!rom.value) {
@@ -147,8 +144,6 @@ onBeforeUnmount(() => {
// Every exit path (Quit, back links, route change) unmounts the view, so
// this is the single choke point for recording the session.
playSession.flush();
// Hand the keyboard and gamepad back to the UI on any exit path.
playingStore.setPlaying(false);
});
</script>
@@ -154,6 +154,7 @@ vi.mock("@/v2/composables/useSocketEvent", () => ({
vi.mock("@/v2/composables/useStageActive", () => ({
useStageActive: vi.fn(),
usePlayingWhile: vi.fn(),
}));
vi.mock("@/v2/composables/useUnloadGuard", () => ({
@@ -476,6 +477,7 @@ type StreamVm = {
claimedAt: string | null;
playerState: string;
endedDialogOpen: boolean;
exitDialogOpen: boolean;
holdsClaim: boolean;
containerHost: string;
errorMessage: string;
@@ -626,6 +628,26 @@ describe("Stream session-ended notices", () => {
});
});
describe("Stream exit chord", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.claimSession.mockResolvedValue(CLAIM);
});
it("opens the exit dialog from the pad's exit chord", async () => {
// B is muted with the rest of the pad, so the chord is the pad's way out.
const wrapper = await launch({ picker: false });
await vmOf(wrapper).onPlay();
await launchReady();
expect(vmOf(wrapper).playerState).toBe("playing");
window.dispatchEvent(new Event("gamepad:exitchord"));
await flushPromises();
expect(vmOf(wrapper).exitDialogOpen).toBe(true);
});
});
describe("Stream claim hygiene", () => {
beforeEach(() => {
vi.clearAllMocks();
+9 -69
View File
@@ -51,7 +51,6 @@ import streamingApi, {
type MemoryCardImportDetail,
} from "@/services/api/streaming";
import storeAuth from "@/stores/auth";
import storePlaying from "@/stores/playing";
import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
import {
type SessionStatus,
@@ -82,7 +81,10 @@ import { usePlayerNav } from "@/v2/composables/usePlayerNav";
import { romIdFromRoute } from "@/v2/composables/useRouteRom";
import { useSnackbar } from "@/v2/composables/useSnackbar";
import { useSocketEvent } from "@/v2/composables/useSocketEvent";
import { useStageActive } from "@/v2/composables/useStageActive";
import {
usePlayingWhile,
useStageActive,
} from "@/v2/composables/useStageActive";
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
import type { SliderBtnGroupItem } from "@/v2/lib/primitives/RSliderBtnGroup/types";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
@@ -97,7 +99,6 @@ const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const auth = storeAuth();
const playingStore = storePlaying();
const streamingStore = useStreamingStore();
const snackbar = useSnackbar();
const { fullscreenOnPlay } = useFullscreenPref();
@@ -160,8 +161,8 @@ const holdsClaim = ref(false);
const sessionActive = computed(
() => playerState.value === "playing" || playerState.value === "loading",
);
usePlayingWhile(sessionActive);
watch(sessionActive, (active) => {
playingStore.setPlaying(active);
if (active) startSessionPoll();
else stopSessionPoll();
});
@@ -1265,69 +1266,9 @@ async function exitWithoutSaving(): Promise<void> {
(leave ?? backToRom)();
}
// Dialogs have no automatic spatial navigation, so cycle focus between
// the action buttons on arrow keys (the d-pad arrives as synthetic
// ArrowLeft/ArrowRight keydowns from useGamepad).
function onExitDialogKeydown(event: KeyboardEvent): void {
const arrows = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
if (!arrows.includes(event.key)) return;
const root = event.currentTarget as HTMLElement;
const buttons = Array.from(
root.querySelectorAll<HTMLElement>("button:not([disabled])"),
);
if (buttons.length === 0) return;
const idx = buttons.indexOf(document.activeElement as HTMLElement);
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
buttons[(idx + step + buttons.length) % buttons.length]?.focus();
event.preventDefault();
}
// ── Select+Start exit chord ────────────────────────────────────────
// useGamepad is muted for the whole session (launch included), so the
// chord is read straight from the Gamepad API here; polling while
// "loading" keeps the cancel dialog reachable by pad if a launch hangs.
// The 1.5s hold filters out anything a game itself binds to Select+Start.
// Only standard-mapped pads participate: elsewhere indices 8/9 are not
// guaranteed to be Select+Start.
const EXIT_CHORD_HOLD_MS = 1500;
// A 1.5s hold needs nowhere near frame resolution, and this runs on the thread
// compositing the stream for as long as the session lasts.
const EXIT_CHORD_POLL_MS = 100;
let chordHeldSince = 0;
function pollExitChord(): void {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const held = Array.from(pads).some(
(pad) =>
pad &&
pad.mapping === "standard" &&
pad.buttons[8]?.pressed &&
pad.buttons[9]?.pressed,
);
const now = performance.now();
if (!held) {
chordHeldSince = 0;
} else if (!chordHeldSince) {
chordHeldSince = now;
} else if (now - chordHeldSince >= EXIT_CHORD_HOLD_MS) {
chordHeldSince = 0;
if (!exitDialogOpen.value) void openExitDialog();
}
}
const exitChordPoll = useIntervalFn(pollExitChord, EXIT_CHORD_POLL_MS, {
immediate: false,
});
function stopExitChordPoll(): void {
exitChordPoll.pause();
chordHeldSince = 0;
}
watch(sessionActive, (active) => {
if (active) exitChordPoll.resume();
else stopExitChordPoll();
});
// useGamepad is muted for the whole session (launch included), so its
// Select+Start hold is the pad's only way to the exit dialog.
useEventListener(window, "gamepad:exitchord", () => void openExitDialog());
function formatTime(iso: string): string {
try {
@@ -1405,7 +1346,6 @@ onBeforeUnmount(() => {
// Every exit path (Stop, Save & Exit, back nav) unmounts the view, so this
// is the single choke point for recording the session.
playSession.flush();
playingStore.setPlaying(false);
if (volumeDebounce) clearTimeout(volumeDebounce);
// The polls clear themselves with the scope; the presence board does not
// know the player has gone until it is told.
@@ -1880,7 +1820,7 @@ onBeforeUnmount(() => {
</template>
<template #footer>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- arrow keys rove focus between this container's real buttons, which stay the interactive elements; the listener sits here to catch keydowns bubbling from either of them -->
<div class="r-v2-stream__exit-actions" @keydown="onExitDialogKeydown">
<div class="r-v2-stream__exit-actions">
<!-- eslint-disable vuejs-accessibility/no-autofocus -- RDialog reads [autofocus] to place initial focus, and the least destructive action is the intended target -->
<RBtn
autofocus