mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user