console page R1 fixes: text fields never take hover focus, unmeasured buttons stay inert, honest full screen

- Hover never focuses a text field; a field holds focus against hover only for
  1.5 s after a key typed into it, so the ring cannot stay on an input while
  the pointer (and the native ✕ click) sits on OK.
- Ruling R18: only verified bindings act; the always-live set is the arrows,
  Enter, Escape and Backspace (and the old gamepad buttons). F5, Home,
  PageUp/PageDown, ContextMenu, F1, F11 keep their browser defaults. PageUp/
  PageDown are left to text fields once verified.
- Full screen: a refusal (error event or rejected promise) hides it for the
  session; a webkit request counts only when the page really went full
  screen; automatic only in the PS5 browser; Escape spends no try; the
  Settings switch follows fullscreenchange.
- cw_hint_filter removed from all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 08:52:14 -07:00
1 parent c1c74dece8
commit 33f1851cdc
28 files changed
+423 -128

No files matched your search

+139 -42
View File
@@ -1,43 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetFullscreenForTests,
fullscreenApi,
fullscreenPref,
fullscreenSupported,
installFullscreen,
isPs5Browser,
setFullscreenPref,
toggleFullscreen,
watchFullscreen,
type FullscreenDoc,
} from "./fullscreen";
function doc(kind: "standard" | "webkit" | "both" | "none", enabled = true) {
const PS5_UA =
"Mozilla/5.0 (PlayStation 5 13.60) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15";
type Opts = { enabled?: boolean; refuse?: boolean; webkitSilent?: boolean };
/** A document stand-in: the request APIs, the change/error events, and a call log. */
function doc(kind: "standard" | "webkit" | "both" | "none", o: Opts = {}) {
const calls: string[] = [];
const d: FullscreenDoc = { documentElement: {} as FullscreenDoc["documentElement"] };
const listeners: Record<string, (() => void)[]> = {};
const fire = (t: string) => (listeners[t] ?? []).slice().forEach((fn) => fn());
const d: FullscreenDoc = {
documentElement: {} as FullscreenDoc["documentElement"],
addEventListener: (t: string, fn: () => void) => void (listeners[t] ??= []).push(fn),
removeEventListener: (t: string, fn: () => void) => {
listeners[t] = (listeners[t] ?? []).filter((f) => f !== fn);
},
};
const el = d.documentElement as unknown as Record<string, unknown>;
if (kind === "standard" || kind === "both") {
el.requestFullscreen = () => {
calls.push("standard");
if (o.refuse) {
fire("fullscreenerror");
return Promise.reject(new Error("refused"));
}
d.fullscreenElement = el as unknown as Element;
fire("fullscreenchange");
return Promise.resolve();
};
d.fullscreenEnabled = enabled;
d.fullscreenEnabled = o.enabled ?? true;
d.exitFullscreen = () => {
calls.push("exit");
d.fullscreenElement = null;
fire("fullscreenchange");
return Promise.resolve();
};
}
if (kind === "webkit" || kind === "both") {
el.webkitRequestFullscreen = () => {
calls.push("webkit");
if (o.webkitSilent) return; // neither change nor error: the browser ignored it
if (o.refuse) {
fire("webkitfullscreenerror");
return;
}
d.webkitFullscreenElement = el as unknown as Element;
fire("webkitfullscreenchange");
};
d.webkitFullscreenEnabled = enabled;
d.webkitFullscreenEnabled = o.enabled ?? true;
d.webkitExitFullscreen = () => {
calls.push("webkit-exit");
d.webkitFullscreenElement = null;
fire("webkitfullscreenchange");
};
}
return { d, calls };
return { d, calls, listeners };
}
function storage(init: Record<string, string> = {}, broken = false) {
@@ -55,8 +86,24 @@ function storage(init: Record<string, string> = {}, broken = false) {
return m;
}
function win() {
const listeners: Record<string, ((e: any) => void)[]> = {};
return {
listeners,
addEventListener: (t: string, fn: (e: any) => void) => void (listeners[t] ??= []).push(fn),
removeEventListener: (t: string, fn: (e: any) => void) => {
listeners[t] = (listeners[t] ?? []).filter((f) => f !== fn);
},
fire: (t: string, e: any = {}) => (listeners[t] ?? []).slice().forEach((fn) => fn(e)),
};
}
const tick = () => new Promise((r) => setTimeout(r, 0));
beforeEach(() => _resetFullscreenForTests());
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("full screen", () => {
@@ -69,7 +116,7 @@ describe("full screen", () => {
it("is unsupported when the API is missing or the browser disables it", () => {
expect(fullscreenSupported(doc("webkit").d)).toBe(true);
expect(fullscreenSupported(doc("none").d)).toBe(false);
expect(fullscreenSupported(doc("standard", false).d)).toBe(false);
expect(fullscreenSupported(doc("standard", { enabled: false }).d)).toBe(false);
});
it("the setting defaults to on, and survives broken storage", () => {
@@ -82,63 +129,113 @@ describe("full screen", () => {
expect(() => setFullscreenPref(false, doc("none").d)).not.toThrow();
});
it("enters on the first user activation with the prefixed API, only once", async () => {
it("only the PS5 browser goes full screen by itself", () => {
expect(isPs5Browser(PS5_UA)).toBe(true);
expect(isPs5Browser(DESKTOP_UA)).toBe(false);
});
it("PS5: enters on the first activation with the prefixed API, only once", async () => {
storage();
const { d, calls } = doc("webkit");
const listeners: Record<string, (() => void)[]> = {};
const win = {
addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn),
removeEventListener: (t: string, fn: () => void) => {
listeners[t] = (listeners[t] ?? []).filter((f) => f !== fn);
},
};
installFullscreen(win, d);
for (const fn of listeners.click ?? []) fn();
await Promise.resolve();
const w = win();
installFullscreen(w, d, PS5_UA);
w.fire("click");
await tick();
expect(calls).toEqual(["webkit"]);
for (const fn of listeners.click ?? []) fn();
for (const fn of listeners.keydown ?? []) fn();
await Promise.resolve();
w.fire("click");
w.fire("keydown", { key: "Enter" });
await tick();
expect(calls).toEqual(["webkit"]);
});
it("a LAN viewer (phone, desktop) is never put in full screen", async () => {
storage();
const { d, calls } = doc("standard");
const w = win();
installFullscreen(w, d, DESKTOP_UA);
w.fire("click");
await tick();
expect(calls).toEqual([]);
});
it("Escape is not an activation: it spends no try", async () => {
storage();
const { d, calls } = doc("standard");
const w = win();
installFullscreen(w, d, PS5_UA);
w.fire("keydown", { key: "Escape" });
await tick();
expect(calls).toEqual([]);
w.fire("keydown", { key: "Enter" });
await tick();
expect(calls).toEqual(["standard"]);
});
it("does nothing on activation when the setting is off", async () => {
storage({ "ps5upload.console.fullscreen": "0" });
const { d, calls } = doc("standard");
const listeners: Record<string, (() => void)[]> = {};
installFullscreen({ addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn), removeEventListener: () => {} }, d);
for (const fn of listeners.click ?? []) fn();
await Promise.resolve();
const w = win();
installFullscreen(w, d, PS5_UA);
w.fire("click");
await tick();
expect(calls).toEqual([]);
});
it("a refused request is tried again on the next activation", async () => {
it("a refusal makes full screen unsupported for the session (the switch hides)", async () => {
storage();
const { d } = doc("standard");
let n = 0;
(d.documentElement as unknown as Record<string, unknown>).requestFullscreen = () => {
n++;
return n === 1 ? Promise.reject(new Error("no")) : Promise.resolve();
};
const listeners: Record<string, (() => void)[]> = {};
installFullscreen({ addEventListener: (t: string, fn: () => void) => (listeners[t] ??= []).push(fn), removeEventListener: () => {} }, d);
for (const fn of listeners.click ?? []) fn();
await new Promise((r) => setTimeout(r, 0));
for (const fn of listeners.click ?? []) fn();
await new Promise((r) => setTimeout(r, 0));
expect(n).toBe(2);
const { d, calls } = doc("standard", { refuse: true });
const w = win();
const seen: number[] = [];
watchFullscreen(() => seen.push(1), d);
installFullscreen(w, d, PS5_UA);
w.fire("click");
await tick();
expect(calls).toEqual(["standard"]);
expect(fullscreenSupported(d)).toBe(false);
expect(seen.length).toBeGreaterThan(0);
w.fire("click");
await tick();
expect(calls).toEqual(["standard"]); // never asked again
});
it("the switch and the touchpad toggle enter and leave", async () => {
it("webkit: a request the browser silently ignores is not counted as success", async () => {
vi.useFakeTimers();
storage();
const { d, calls } = doc("webkit", { webkitSilent: true });
const w = win();
installFullscreen(w, d, PS5_UA);
w.fire("click");
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toEqual(["webkit"]);
w.fire("click");
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toEqual(["webkit", "webkit"]); // tried again: it did not take
});
it("webkit: an error event is a refusal", async () => {
storage();
const { d } = doc("webkit", { refuse: true });
const w = win();
installFullscreen(w, d, PS5_UA);
w.fire("click");
await tick();
expect(fullscreenSupported(d)).toBe(false);
});
it("the switch and the touchpad toggle enter and leave; watchers hear each change", async () => {
const m = storage();
const { d, calls } = doc("standard");
const states: boolean[] = [];
const stop = watchFullscreen((s) => states.push(s.on), d);
setFullscreenPref(false, d);
expect(m.get("ps5upload.console.fullscreen")).toBe("0");
setFullscreenPref(true, d);
await Promise.resolve();
await tick();
expect(calls).toEqual(["standard"]);
toggleFullscreen(d);
await Promise.resolve();
await tick();
expect(calls).toEqual(["standard", "exit"]);
expect(states).toEqual([true, false]);
stop();
});
});
+137 -28
View File
@@ -1,12 +1,19 @@
/**
* Full screen for the console page: the PS5 browser's own bars take part of a
* 1540×700 viewport, so the page asks for full screen on the first user
* activation (a click or a key: browsers only grant it inside one) while the
* Settings switch is on (the default). The standard API first, then WebKit's
* prefixed one. Where neither exists, or the browser disables it, nothing is
* asked, Settings hides the switch and the layout simply scales to the viewport.
* 1540×700 viewport, so on the PS5 browser the page asks for full screen on
* the first user activation (a click or a key: browsers only grant it inside
* one) while the Settings switch is on (the default). A LAN viewer (phone,
* desktop) is never put in full screen by itself; the switch still works there.
*
* The standard API first, then WebKit's prefixed one. Where neither exists, the
* browser disables it, or it refuses a request (a fullscreenerror, a rejected
* promise), full screen counts as unsupported for the rest of the session:
* nothing more is asked, Settings hides the switch and the layout simply
* scales to the viewport.
*/
type Fn = () => void;
/** The parts of `document` this module uses (tests pass a stand-in). */
export interface FullscreenDoc {
documentElement: Element & {
@@ -19,21 +26,42 @@ export interface FullscreenDoc {
webkitFullscreenElement?: Element | null;
exitFullscreen?: () => Promise<void>;
webkitExitFullscreen?: () => void;
addEventListener?: (type: string, fn: Fn) => void;
removeEventListener?: (type: string, fn: Fn) => void;
}
interface Listener {
addEventListener(type: string, fn: () => void, capture?: boolean): void;
removeEventListener(type: string, fn: () => void, capture?: boolean): void;
addEventListener(type: string, fn: (e: KeyboardEvent | MouseEvent) => void, capture?: boolean): void;
removeEventListener(type: string, fn: (e: KeyboardEvent | MouseEvent) => void, capture?: boolean): void;
}
const STORAGE_KEY = "ps5upload.console.fullscreen";
/** Activations that may carry the request. */
const ACTIVATIONS = ["click", "keydown"];
const CHANGE_EVENTS = ["fullscreenchange", "webkitfullscreenchange"];
const ERROR_EVENTS = ["fullscreenerror", "webkitfullscreenerror"];
/** How long a webkit request may stay unanswered before it counts as not taken. */
const WEBKIT_ANSWER_MS = 1500;
/** Requests that neither entered nor were refused (silently ignored) before giving up. */
const MAX_TRIES = 3;
/** The browser refused a request this session: full screen is off the table. */
let refused = false;
/** Tests only: forget a refusal. */
export function _resetFullscreenForTests(): void {
refused = false;
}
function currentDoc(): FullscreenDoc {
return document as unknown as FullscreenDoc;
}
/** The PS5's own browser (its user agent names the console). */
export function isPs5Browser(ua: string = typeof navigator !== "undefined" ? navigator.userAgent : ""): boolean {
return ua.includes("PlayStation 5");
}
/** Which API the browser has: the standard one, WebKit's prefixed one, or none. */
export function fullscreenApi(doc: FullscreenDoc = currentDoc()): "standard" | "webkit" | null {
const el = doc.documentElement;
@@ -42,8 +70,9 @@ export function fullscreenApi(doc: FullscreenDoc = currentDoc()): "standard" | "
return null;
}
/** Whether the page can go full screen at all (an API, and the browser does not disable it). */
/** Whether the page can go full screen: an API, not disabled, and not refused this session. */
export function fullscreenSupported(doc: FullscreenDoc = currentDoc()): boolean {
if (refused) return false;
const api = fullscreenApi(doc);
if (api === "standard") return doc.fullscreenEnabled !== false;
if (api === "webkit") return doc.webkitFullscreenEnabled !== false;
@@ -54,22 +83,64 @@ export function isFullscreen(doc: FullscreenDoc = currentDoc()): boolean {
return !!(doc.fullscreenElement || doc.webkitFullscreenElement);
}
/** Ask for full screen; resolves true when the browser accepted (or did not say no). */
const watchers = new Set<Fn>();
function notify(): void {
for (const fn of Array.from(watchers)) fn();
}
function markRefused(): void {
if (refused) return;
refused = true;
notify();
}
/**
* Ask for full screen. Resolves true once the page is full screen. A refusal
* (rejected promise, error event) resolves false and marks full screen
* unsupported for the session. A webkit request the browser answers with
* neither event is not counted as success: after WEBKIT_ANSWER_MS the real
* state decides.
*/
export function enterFullscreen(doc: FullscreenDoc = currentDoc()): Promise<boolean> {
if (!fullscreenSupported(doc)) return Promise.resolve(false);
try {
if (fullscreenApi(doc) === "standard") {
try {
return Promise.resolve(doc.documentElement.requestFullscreen!()).then(
() => true,
() => false,
() => {
markRefused();
return false;
},
);
}
doc.documentElement.webkitRequestFullscreen!(); // returns nothing; a refusal is an event
return Promise.resolve(true);
} catch {
markRefused();
return Promise.resolve(false);
}
}
return new Promise<boolean>((resolve) => {
let done = false;
const finish = (ok: boolean) => {
if (done) return;
done = true;
clearTimeout(timer);
doc.removeEventListener?.("webkitfullscreenchange", onChange);
doc.removeEventListener?.("webkitfullscreenerror", onError);
resolve(ok);
};
const onChange = () => finish(isFullscreen(doc));
const onError = () => {
markRefused();
finish(false);
};
const timer = setTimeout(() => finish(isFullscreen(doc)), WEBKIT_ANSWER_MS);
doc.addEventListener?.("webkitfullscreenchange", onChange);
doc.addEventListener?.("webkitfullscreenerror", onError);
try {
doc.documentElement.webkitRequestFullscreen!();
} catch {
onError();
}
});
}
export function exitFullscreen(doc: FullscreenDoc = currentDoc()): void {
try {
@@ -83,7 +154,7 @@ export function exitFullscreen(doc: FullscreenDoc = currentDoc()): void {
}
}
/** The Settings switch: on unless the viewer turned it off (also when storage fails). */
/** The Settings switch's stored wish: on unless the viewer turned it off (also when storage fails). */
export function fullscreenPref(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "0";
@@ -112,36 +183,74 @@ export function toggleFullscreen(doc: FullscreenDoc = currentDoc()): void {
else void enterFullscreen(doc);
}
/**
* Go full screen on the first user activation when the switch is on. A refused
* request is tried again on the next activation (at most MAX_TRIES); once full
* screen was entered, or the page is already full screen, the listeners go.
* Leaving full screen later (the browser's own way) is respected: no re-entry.
*/
const MAX_TRIES = 3;
export interface FullscreenState {
/** The switch can be shown. */
supported: boolean;
/** The page is full screen now. */
on: boolean;
}
export function installFullscreen(win: Listener = window, doc: FullscreenDoc = currentDoc()): () => void {
export function fullscreenState(doc: FullscreenDoc = currentDoc()): FullscreenState {
return { supported: fullscreenSupported(doc), on: isFullscreen(doc) };
}
/**
* Call `fn` with the new state whenever full screen is entered, left or
* refused (Settings keeps its switch true to the page). Returns the unsubscribe.
*/
export function watchFullscreen(fn: (s: FullscreenState) => void, doc: FullscreenDoc = currentDoc()): () => void {
const run = () => fn(fullscreenState(doc));
const onError = () => markRefused(); // markRefused notifies every watcher, this one included
watchers.add(run);
for (const t of CHANGE_EVENTS) doc.addEventListener?.(t, run);
for (const t of ERROR_EVENTS) doc.addEventListener?.(t, onError);
return () => {
watchers.delete(run);
for (const t of CHANGE_EVENTS) doc.removeEventListener?.(t, run);
for (const t of ERROR_EVENTS) doc.removeEventListener?.(t, onError);
};
}
/**
* On the PS5 browser, go full screen on the first user activation (Escape
* is not one) while the switch is on. Once full screen was entered or refused,
* or after MAX_TRIES silently ignored requests, the listeners go. Leaving full
* screen later (the browser's own way) is respected: no re-entry.
*/
export function installFullscreen(
win: Listener = window,
doc: FullscreenDoc = currentDoc(),
ua: string = typeof navigator !== "undefined" ? navigator.userAgent : "",
): () => void {
if (!isPs5Browser(ua)) return () => {};
let tries = 0;
let pending = false;
let removed = false;
const onError = () => markRefused();
const remove = () => {
if (removed) return;
removed = true;
for (const t of ACTIVATIONS) win.removeEventListener(t, onActivation, true);
};
function onActivation() {
function onActivation(e: KeyboardEvent | MouseEvent) {
if (removed || pending) return;
if (!fullscreenPref() || !fullscreenSupported(doc) || isFullscreen(doc)) {
if (isFullscreen(doc) || !fullscreenSupported(doc)) remove();
if (e && (e as KeyboardEvent).key === "Escape") return; // a key event; a click has no key
if (!fullscreenSupported(doc) || isFullscreen(doc)) {
remove();
return;
}
if (!fullscreenPref()) return;
tries++;
pending = true;
void enterFullscreen(doc).then((ok) => {
pending = false;
if (ok || tries >= MAX_TRIES) remove();
if (ok || refused || tries >= MAX_TRIES) remove();
});
}
for (const t of ERROR_EVENTS) doc.addEventListener?.(t, onError);
for (const t of ACTIVATIONS) win.addEventListener(t, onActivation, true);
return remove;
return () => {
remove();
for (const t of ERROR_EVENTS) doc.removeEventListener?.(t, onError);
};
}
+74 -2
View File
@@ -47,11 +47,11 @@ class FakeEl {
if (sel === "[data-nav-modal]") return this.descendants().filter((e) => "data-nav-modal" in e.attrs);
if (sel === "[data-nav-region]") return this.descendants().filter((e) => "data-nav-region" in e.attrs);
if (sel === '[data-nav-region="rail"]') return this.descendants().filter((e) => e.attrs["data-nav-region"] === "rail");
return this.descendants().filter((e) => e.tagName === "BUTTON"); // the FOCUSABLE selector
return this.descendants().filter((e) => e.tagName === "BUTTON" || e.tagName === "INPUT"); // the FOCUSABLE selector
}
closest(sel: string): FakeEl | null {
if (sel === FOCUSABLE) {
if (this.tagName === "BUTTON") return this;
if (this.tagName === "BUTTON" || this.tagName === "INPUT") return this;
return this.parent ? this.parent.closest(sel) : null;
}
if (sel !== "[data-nav-region]") return null; // nothing is inert or aria-hidden in these fixtures
@@ -69,6 +69,7 @@ class FakeEl {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top } as DOMRect;
}
focusOpts: unknown = undefined;
type = "text";
scrollTop = 0;
focus(opts?: unknown): void {
this.focusOpts = opts;
@@ -504,6 +505,77 @@ describe("pointer = focus (the PS5 browser's left stick drives a pointer)", () =
expect(railFiles.clicks).toBe(0); // the native click runs, not ours
});
it("hovering a text field never focuses it (the ring would stay on it, ✕ clicks elsewhere)", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const field = new FakeEl("name", {}, { left: 300, top: 100, width: 300, height: 40 }, "INPUT");
const ok = btn("ok", 300, 200);
body.add(field, ok);
railHome.focus();
w.move(field, 350, 120);
expect(fakeDoc.activeElement).toBe(railHome);
w.move(ok, 350, 220);
expect(fakeDoc.activeElement).toBe(ok);
});
it("a field typed into keeps focus against hover for a moment, then hover moves on", () => {
const w = fakeWindow();
let now = 1000;
vi.spyOn(performance, "now").mockImplementation(() => now);
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const field = new FakeEl("name", {}, { left: 300, top: 100, width: 300, height: 40 }, "INPUT");
const ok = btn("ok", 300, 200);
body.add(field, ok);
field.focus(); // by ✕ or a key
w.fire("keydown", { key: "a", keyCode: 65, target: field });
now = 1500;
w.move(ok, 350, 220);
expect(fakeDoc.activeElement).toBe(field);
now = 3000;
w.move(ok, 360, 230);
expect(fakeDoc.activeElement).toBe(ok);
});
it("a focused field nobody typed into lets hover move on", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const field = new FakeEl("name", {}, { left: 300, top: 100, width: 300, height: 40 }, "INPUT");
const ok = btn("ok", 300, 200);
body.add(field, ok);
field.focus();
w.move(ok, 350, 220);
expect(fakeDoc.activeElement).toBe(ok);
});
it("R18: unverified keys (F5, Home, PageUp/PageDown, ContextMenu) keep their browser default", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
const seen: string[] = [];
offs.push(onNavAction((a) => {
seen.push(a);
return true;
}));
railHome.focus();
for (const [k, code] of [["F5", 116], ["Home", 36], ["PageUp", 33], ["PageDown", 34], ["ContextMenu", 93], ["F1", 112]] as const) {
const ev = w.key(k, code);
expect(ev.preventDefault, k).not.toHaveBeenCalled();
}
expect(seen).toEqual([]);
expect(fakeDoc.activeElement).toBe(railHome);
});
it("verified PageDown is left to a text field", () => {
const w = fakeWindow();
const map = DEFAULT_INPUT_MAP.map((b) => (b.button === "r2" ? { ...b, verified: true } : b));
offs.push(installNavigation(map));
const field = new FakeEl("name", {}, { left: 300, top: 100, width: 300, height: 40 }, "INPUT");
body.add(field);
field.focus();
const ev = w.key("PageDown", 34);
expect(ev.preventDefault).not.toHaveBeenCalled();
expect(fakeDoc.activeElement).toBe(field);
});
it("keys are matched by keyCode when the browser names the key Unidentified", () => {
const w = fakeWindow();
offs.push(installNavigation(DEFAULT_INPUT_MAP));
+19
View File
@@ -139,6 +139,25 @@ describe("default input map", () => {
expect(bindingForKey(DEFAULT_INPUT_MAP, "Unidentified", 27)?.action).toBe("back");
expect(bindingForKey(DEFAULT_INPUT_MAP, "x", 88)).toBeNull();
});
it("R18: unverified seeded keys are inert, the always-live set is not", () => {
for (const k of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape", "Backspace"]) {
expect(bindingForKey(DEFAULT_INPUT_MAP, k, 0), k).not.toBeNull();
}
for (const [k, code] of [["F5", 116], ["Home", 36], ["PageUp", 33], ["PageDown", 34], ["ContextMenu", 93], ["F1", 112], ["F11", 122]] as const) {
expect(bindingForKey(DEFAULT_INPUT_MAP, k, code), k).toBeNull();
expect(bindingForKey(DEFAULT_INPUT_MAP, "Unidentified", code), `${k} by keyCode`).toBeNull();
}
// Once measured, the entry acts.
const map = DEFAULT_INPUT_MAP.map((b) => (b.button === "create" ? { ...b, verified: true } : b));
expect(bindingForKey(map, "F5", 116)?.action).toBe("refresh");
});
it("R18: unverified gamepad buttons outside the old set are inert", () => {
const b: boolean[] = new Array(17).fill(false);
b[6] = b[7] = b[8] = b[9] = b[10] = b[11] = true;
expect(createPadReader(DEFAULT_INPUT_MAP)(b, 0)).toEqual([]);
});
});
describe("hint bar entries", () => {
+46 -8
View File
@@ -153,11 +153,30 @@ function isNavAction(a: ButtonAction): a is NavAction {
return ACTIONS_THE_PAGE_RUNS.has(a);
}
/** The binding a keydown belongs to: by `key`, else by `keyCode` (a key the browser does not name). */
/*
* Ruling R18: a binding acts only once measured (verified). Until then only the
* keys the page always handled stay live: the arrows, Enter, Escape and
* Backspace (and their keyCodes). Every other seeded key (F5, Home, PageUp, ...)
* is inert: not matched, its default never prevented, so it keeps doing what
* the browser does with it. The entries stay in the table for the Controls
* screen and the measurement pass.
*/
const ALWAYS_LIVE_KEYS: ReadonlySet<string> = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape", "Backspace"]);
const ALWAYS_LIVE_KEYCODES: ReadonlySet<number> = new Set([38, 40, 37, 39, 13, 27, 8]);
/** Gamepad buttons the page always handled (✕ ○ □ △ L1 R1 and the D-pad). */
const ALWAYS_LIVE_PADS: ReadonlySet<number> = new Set([0, 1, 2, 3, 4, 5, 12, 13, 14, 15]);
/** The binding a keydown belongs to: by `key`, else by `keyCode` (a key the browser does not name). Inert entries never match. */
export function bindingForKey(map: InputMap, key: string, keyCode: number): InputBinding | null {
for (const b of map) if (isNavAction(b.action) && b.keys.includes(key)) return b;
for (const b of map) {
if (isNavAction(b.action) && b.keys.includes(key) && (b.verified || ALWAYS_LIVE_KEYS.has(key))) return b;
}
if (key.length === 1) return null; // a printable key is text, never a button
if (keyCode) for (const b of map) if (isNavAction(b.action) && b.keyCodes.includes(keyCode)) return b;
if (keyCode) {
for (const b of map) {
if (isNavAction(b.action) && b.keyCodes.includes(keyCode) && (b.verified || ALWAYS_LIVE_KEYCODES.has(keyCode))) return b;
}
}
return null;
}
@@ -257,7 +276,9 @@ const DIRECTIONS: ReadonlySet<NavAction> = new Set(["up", "down", "left", "right
export function createPadReader(map: InputMap): (pressed: boolean[], now: number) => NavAction[] {
const downSince = new Map<number, number>();
const lastFire = new Map<number, number>();
const padded = map.filter((b) => b.pad !== undefined && isNavAction(b.action));
const padded = map.filter(
(b) => b.pad !== undefined && isNavAction(b.action) && (b.verified || ALWAYS_LIVE_PADS.has(b.pad)),
);
return (pressed, now) => {
const out: NavAction[] = [];
for (const binding of padded) {
@@ -559,6 +580,8 @@ function act(action: NavAction): void {
/** Pointer movement smaller than this (px) after a key move is stick drift or a scroll re-send, not the user. */
export const POINTER_SLOP_PX = 6;
/** A field typed into this recently keeps focus against hover. */
export const TYPING_HOLD_MS = 1500;
/** Focus `el` because the pointer is over it: no scroll (the pointer is already there). */
function focusFromPointer(el: HTMLElement): void {
@@ -573,6 +596,9 @@ function hoverTarget(target: EventTarget | null): HTMLElement | null {
if (!t || typeof t.closest !== "function") return null;
const el = t.closest<HTMLElement>(FOCUSABLE);
if (!el || el.closest("[inert], [aria-hidden='true']")) return null;
// A text field takes focus only from a click/✕ or a key: hovering one would put the ring on it
// (and may raise the PS5's on-screen keyboard) while the pointer moves on.
if (isTextField(el)) return null;
const modal = topModal();
if (modal && !modal.contains(el)) return null;
return el;
@@ -583,9 +609,11 @@ function hoverTarget(target: EventTarget | null): HTMLElement | null {
* function is called.
*
* Keyboard: keydown (one a control handled itself is left alone), matched by
* key then keyCode (bindingForKey).
* key then keyCode (bindingForKey); unverified bindings outside the always-live
* set are inert (ruling R18).
*
* Pointer: a mousemove over a control focuses it (hover = focus). After a key
* Pointer: a mousemove over a control focuses it (hover = focus); text fields
* excepted, and a field typed into in the last TYPING_HOLD_MS keeps focus. After a key
* moved focus the pointer is "parked": it stays where it was, so moves within
* POINTER_SLOP_PX of that spot are ignored (stick drift, the browser re-sending
* the spot after a scroll), and a pointer click there, which would land on
@@ -604,7 +632,10 @@ export function installNavigation(map: InputMap): () => void {
ptr.ay = ptr.y;
};
let typedAt = -Infinity;
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target as Element | null)) typedAt = performance.now();
if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
const binding = bindingForKey(map, e.key, e.keyCode);
if (!binding || !isNavAction(binding.action)) return;
@@ -612,7 +643,13 @@ export function installNavigation(map: InputMap): () => void {
const typing = isTextField(e.target as Element | null);
if (
typing &&
(action === "left" || action === "right" || action === "confirm" || action === "top" || e.key === "Backspace")
(action === "left" ||
action === "right" ||
action === "confirm" ||
action === "top" ||
action === "pageUp" ||
action === "pageDown" ||
e.key === "Backspace")
) {
return;
}
@@ -641,7 +678,8 @@ export function installNavigation(map: InputMap): () => void {
ptr.y = y;
const el = hoverTarget(e.target);
if (!el || el === document.activeElement) return;
if (isTextField(document.activeElement)) return; // never pull focus out of a field being typed into
// Never pull focus out of a field while it is being typed into.
if (isTextField(document.activeElement) && performance.now() - typedAt < TYPING_HOLD_MS) return;
focusFromPointer(el);
};
+8 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError, call, isOffline } from "../api";
import { fullscreenPref, fullscreenSupported, setFullscreenPref } from "../fullscreen";
import { fullscreenState, setFullscreenPref, watchFullscreen } from "../fullscreen";
import { onNavAction } from "../nav";
import { LANGUAGE_NAMES } from "../languages";
import { chooseLanguage, LANGUAGES, storedLanguage, t } from "../strings";
@@ -71,7 +71,8 @@ export interface SettingsViewProps {
onDismissLan: () => void;
onToggleTile: () => void;
onLanguage: (code: string) => void;
/** The Full screen switch: on/off, or null where the browser cannot (the switch is hidden). */
/** The Full screen switch: whether the page is full screen now, or null where the browser cannot
* or refused (the switch is hidden). */
fullscreen?: boolean | null;
onToggleFullscreen?: () => void;
}
@@ -209,7 +210,10 @@ export function Settings() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [language, setLanguageState] = useState(storedLanguage);
const [fullscreen, setFullscreen] = useState<boolean | null>(() => (fullscreenSupported() ? fullscreenPref() : null));
const [fs, setFs] = useState(fullscreenState);
// The switch shows the real state: entered, left (the browser's own way too) or refused.
useEffect(() => watchFullscreen(setFs), []);
const fullscreen = fs.supported ? fs.on : null;
/** Bumped by Create (refresh) to read /api/settings again. */
const [tick, setTick] = useState(0);
@@ -302,9 +306,7 @@ export function Settings() {
}}
fullscreen={fullscreen}
onToggleFullscreen={() => {
if (fullscreen === null) return;
setFullscreenPref(!fullscreen);
setFullscreen(!fullscreen);
if (fullscreen !== null) setFullscreenPref(!fullscreen);
}}
/>
);
-21
View File
@@ -56,7 +56,6 @@
"cw_files_up": "لأعلى",
"cw_files_view": "عرض",
"cw_hint_back": "رجوع",
"cw_hint_filter": "تصفية",
"cw_hint_fullscreen": "ملء الشاشة",
"cw_hint_help": "مساعدة",
"cw_hint_open": "فتح",
@@ -205,7 +204,6 @@
"cw_files_up": "উপরে",
"cw_files_view": "দেখুন",
"cw_hint_back": "পিছনে",
"cw_hint_filter": "ফিল্টার",
"cw_hint_fullscreen": "ফুল স্ক্রিন",
"cw_hint_help": "সাহায্য",
"cw_hint_open": "খুলুন",
@@ -354,7 +352,6 @@
"cw_files_up": "Nach oben",
"cw_files_view": "Ansehen",
"cw_hint_back": "Zurück",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Vollbild",
"cw_hint_help": "Hilfe",
"cw_hint_open": "Öffnen",
@@ -503,7 +500,6 @@
"cw_files_up": "Up",
"cw_files_view": "View",
"cw_hint_back": "Back",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Full screen",
"cw_hint_help": "Help",
"cw_hint_open": "Open",
@@ -652,7 +648,6 @@
"cw_files_up": "Subir",
"cw_files_view": "Ver",
"cw_hint_back": "Atrás",
"cw_hint_filter": "Filtrar",
"cw_hint_fullscreen": "Pantalla completa",
"cw_hint_help": "Ayuda",
"cw_hint_open": "Abrir",
@@ -801,7 +796,6 @@
"cw_files_up": "بالا",
"cw_files_view": "نمایش",
"cw_hint_back": "بازگشت",
"cw_hint_filter": "فیلتر",
"cw_hint_fullscreen": "تمام‌صفحه",
"cw_hint_help": "راهنما",
"cw_hint_open": "باز کردن",
@@ -950,7 +944,6 @@
"cw_files_up": "Remonter",
"cw_files_view": "Afficher",
"cw_hint_back": "Retour",
"cw_hint_filter": "Filtrer",
"cw_hint_fullscreen": "Plein écran",
"cw_hint_help": "Aide",
"cw_hint_open": "Ouvrir",
@@ -1099,7 +1092,6 @@
"cw_files_up": "ऊपर",
"cw_files_view": "देखें",
"cw_hint_back": "वापस",
"cw_hint_filter": "फ़िल्टर",
"cw_hint_fullscreen": "फ़ुल स्क्रीन",
"cw_hint_help": "सहायता",
"cw_hint_open": "खोलें",
@@ -1248,7 +1240,6 @@
"cw_files_up": "Fel",
"cw_files_view": "Megtekintés",
"cw_hint_back": "Vissza",
"cw_hint_filter": "Szűrés",
"cw_hint_fullscreen": "Teljes képernyő",
"cw_hint_help": "Súgó",
"cw_hint_open": "Megnyitás",
@@ -1397,7 +1388,6 @@
"cw_files_up": "Naik",
"cw_files_view": "Lihat",
"cw_hint_back": "Kembali",
"cw_hint_filter": "Filter",
"cw_hint_fullscreen": "Layar penuh",
"cw_hint_help": "Bantuan",
"cw_hint_open": "Buka",
@@ -1546,7 +1536,6 @@
"cw_files_up": "Su",
"cw_files_view": "Visualizza",
"cw_hint_back": "Indietro",
"cw_hint_filter": "Filtra",
"cw_hint_fullscreen": "Schermo intero",
"cw_hint_help": "Guida",
"cw_hint_open": "Apri",
@@ -1695,7 +1684,6 @@
"cw_files_up": "上へ",
"cw_files_view": "表示",
"cw_hint_back": "戻る",
"cw_hint_filter": "絞り込み",
"cw_hint_fullscreen": "全画面",
"cw_hint_help": "ヘルプ",
"cw_hint_open": "開く",
@@ -1844,7 +1832,6 @@
"cw_files_up": "위로",
"cw_files_view": "보기",
"cw_hint_back": "뒤로",
"cw_hint_filter": "필터",
"cw_hint_fullscreen": "전체 화면",
"cw_hint_help": "도움말",
"cw_hint_open": "열기",
@@ -1993,7 +1980,6 @@
"cw_files_up": "W górę",
"cw_files_view": "Pokaż",
"cw_hint_back": "Wstecz",
"cw_hint_filter": "Filtruj",
"cw_hint_fullscreen": "Pełny ekran",
"cw_hint_help": "Pomoc",
"cw_hint_open": "Otwórz",
@@ -2142,7 +2128,6 @@
"cw_files_up": "Acima",
"cw_files_view": "Ver",
"cw_hint_back": "Voltar",
"cw_hint_filter": "Filtrar",
"cw_hint_fullscreen": "Tela cheia",
"cw_hint_help": "Ajuda",
"cw_hint_open": "Abrir",
@@ -2291,7 +2276,6 @@
"cw_files_up": "Вверх",
"cw_files_view": "Просмотр",
"cw_hint_back": "Назад",
"cw_hint_filter": "Фильтр",
"cw_hint_fullscreen": "Полный экран",
"cw_hint_help": "Справка",
"cw_hint_open": "Открыть",
@@ -2440,7 +2424,6 @@
"cw_files_up": "ขึ้น",
"cw_files_view": "ดู",
"cw_hint_back": "กลับ",
"cw_hint_filter": "ตัวกรอง",
"cw_hint_fullscreen": "เต็มจอ",
"cw_hint_help": "ความช่วยเหลือ",
"cw_hint_open": "เปิด",
@@ -2589,7 +2572,6 @@
"cw_files_up": "Yukarı",
"cw_files_view": "Görüntüle",
"cw_hint_back": "Geri",
"cw_hint_filter": "Filtrele",
"cw_hint_fullscreen": "Tam ekran",
"cw_hint_help": "Yardım",
"cw_hint_open": "Aç",
@@ -2738,7 +2720,6 @@
"cw_files_up": "Lên",
"cw_files_view": "Xem",
"cw_hint_back": "Quay lại",
"cw_hint_filter": "Lọc",
"cw_hint_fullscreen": "Toàn màn hình",
"cw_hint_help": "Trợ giúp",
"cw_hint_open": "Mở",
@@ -2887,7 +2868,6 @@
"cw_files_up": "上一级",
"cw_files_view": "查看",
"cw_hint_back": "返回",
"cw_hint_filter": "筛选",
"cw_hint_fullscreen": "全屏",
"cw_hint_help": "帮助",
"cw_hint_open": "打开",
@@ -3036,7 +3016,6 @@
"cw_files_up": "上一層",
"cw_files_view": "檢視",
"cw_hint_back": "返回",
"cw_hint_filter": "篩選",
"cw_hint_fullscreen": "全螢幕",
"cw_hint_help": "說明",
"cw_hint_open": "開啟",
-1
View File
@@ -4037,7 +4037,6 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB:
"cw_hint_open": "فتح",
"cw_hint_back": "رجوع",
"cw_hint_switch_section": "تبديل القسم",
"cw_hint_filter": "تصفية",
"cw_actions": "إجراءات",
"cw_select": "تحديد",
"cw_reload_title": "أُعيد تشغيل مساعد PS5Upload",
-1
View File
@@ -4037,7 +4037,6 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা
"cw_hint_open": "খুলুন",
"cw_hint_back": "পিছনে",
"cw_hint_switch_section": "বিভাগ বদলান",
"cw_hint_filter": "ফিল্টার",
"cw_actions": "কাজ",
"cw_select": "বাছাই করুন",
"cw_reload_title": "PS5Upload সাহায্যকারী আবার চালু হয়েছে",
-1
View File
@@ -4045,7 +4045,6 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}",
"cw_hint_open": "Öffnen",
"cw_hint_back": "Zurück",
"cw_hint_switch_section": "Bereich wechseln",
"cw_hint_filter": "Filter",
"cw_actions": "Aktionen",
"cw_select": "Auswählen",
"cw_reload_title": "Der PS5Upload-Helfer wurde neu gestartet",
-1
View File
@@ -4360,7 +4360,6 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}",
"cw_hint_open": "Open",
"cw_hint_back": "Back",
"cw_hint_switch_section": "Switch section",
"cw_hint_filter": "Filter",
"cw_actions": "Actions",
"cw_select": "Select",
"cw_reload_title": "The PS5Upload helper restarted",
-1
View File
@@ -3987,7 +3987,6 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}",
"cw_hint_open": "Abrir",
"cw_hint_back": "Atrás",
"cw_hint_switch_section": "Cambiar de sección",
"cw_hint_filter": "Filtrar",
"cw_actions": "Acciones",
"cw_select": "Seleccionar",
"cw_reload_title": "El asistente de PS5Upload se reinició",
-1
View File
@@ -4338,7 +4338,6 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}",
"cw_hint_open": "باز کردن",
"cw_hint_back": "بازگشت",
"cw_hint_switch_section": "تغییر بخش",
"cw_hint_filter": "فیلتر",
"cw_actions": "کارها",
"cw_select": "انتخاب",
"cw_reload_title": "راهنمای PS5Upload دوباره راه‌اندازی شد",
-1
View File
@@ -4055,7 +4055,6 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}",
"cw_hint_open": "Ouvrir",
"cw_hint_back": "Retour",
"cw_hint_switch_section": "Changer de section",
"cw_hint_filter": "Filtrer",
"cw_actions": "Actions",
"cw_select": "Sélectionner",
"cw_reload_title": "L'assistant PS5Upload a redémarré",
-1
View File
@@ -4053,7 +4053,6 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ
"cw_hint_open": "खोलें",
"cw_hint_back": "वापस",
"cw_hint_switch_section": "अनुभाग बदलें",
"cw_hint_filter": "फ़िल्टर",
"cw_actions": "कार्य",
"cw_select": "चुनें",
"cw_reload_title": "PS5Upload हेल्पर फिर से शुरू हुआ",
-1
View File
@@ -4352,7 +4352,6 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}",
"cw_hint_open": "Megnyitás",
"cw_hint_back": "Vissza",
"cw_hint_switch_section": "Rész váltása",
"cw_hint_filter": "Szűrés",
"cw_actions": "Műveletek",
"cw_select": "Kijelölés",
"cw_reload_title": "A PS5Upload segédprogram újraindult",
-1
View File
@@ -4046,7 +4046,6 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}",
"cw_hint_open": "Buka",
"cw_hint_back": "Kembali",
"cw_hint_switch_section": "Ganti bagian",
"cw_hint_filter": "Filter",
"cw_actions": "Tindakan",
"cw_select": "Pilih",
"cw_reload_title": "Pembantu PS5Upload dimulai ulang",
-1
View File
@@ -3987,7 +3987,6 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}",
"cw_hint_open": "Apri",
"cw_hint_back": "Indietro",
"cw_hint_switch_section": "Cambia sezione",
"cw_hint_filter": "Filtra",
"cw_actions": "Azioni",
"cw_select": "Seleziona",
"cw_reload_title": "L'helper di PS5Upload si è riavviato",
-1
View File
@@ -4058,7 +4058,6 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail
"cw_hint_open": "開く",
"cw_hint_back": "戻る",
"cw_hint_switch_section": "セクションを切り替え",
"cw_hint_filter": "絞り込み",
"cw_actions": "操作",
"cw_select": "選択",
"cw_reload_title": "PS5Upload のヘルパーが再起動しました",
-1
View File
@@ -4056,7 +4056,6 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det
"cw_hint_open": "열기",
"cw_hint_back": "뒤로",
"cw_hint_switch_section": "섹션 전환",
"cw_hint_filter": "필터",
"cw_actions": "작업",
"cw_select": "선택",
"cw_reload_title": "PS5Upload 헬퍼가 다시 시작되었습니다",
-1
View File
@@ -4292,7 +4292,6 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}",
"cw_hint_open": "Otwórz",
"cw_hint_back": "Wstecz",
"cw_hint_switch_section": "Zmień sekcję",
"cw_hint_filter": "Filtruj",
"cw_actions": "Działania",
"cw_select": "Zaznacz",
"cw_reload_title": "Helper PS5Upload uruchomił się ponownie",
-1
View File
@@ -4054,7 +4054,6 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}",
"cw_hint_open": "Abrir",
"cw_hint_back": "Voltar",
"cw_hint_switch_section": "Trocar de seção",
"cw_hint_filter": "Filtrar",
"cw_actions": "Ações",
"cw_select": "Selecionar",
"cw_reload_title": "O auxiliar do PS5Upload reiniciou",
-1
View File
@@ -3987,7 +3987,6 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла:
"cw_hint_open": "Открыть",
"cw_hint_back": "Назад",
"cw_hint_switch_section": "Сменить раздел",
"cw_hint_filter": "Фильтр",
"cw_actions": "Действия",
"cw_select": "Выбрать",
"cw_reload_title": "Помощник PS5Upload перезапустился",
-1
View File
@@ -4046,7 +4046,6 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ
"cw_hint_open": "เปิด",
"cw_hint_back": "กลับ",
"cw_hint_switch_section": "สลับส่วน",
"cw_hint_filter": "ตัวกรอง",
"cw_actions": "การทำงาน",
"cw_select": "เลือก",
"cw_reload_title": "ตัวช่วย PS5Upload เริ่มใหม่แล้ว",
-1
View File
@@ -4044,7 +4044,6 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}",
"cw_hint_open": "Aç",
"cw_hint_back": "Geri",
"cw_hint_switch_section": "Bölüm değiştir",
"cw_hint_filter": "Filtrele",
"cw_actions": "İşlemler",
"cw_select": "Seç",
"cw_reload_title": "PS5Upload yardımcısı yeniden başladı",
-1
View File
@@ -4039,7 +4039,6 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}",
"cw_hint_open": "Mở",
"cw_hint_back": "Quay lại",
"cw_hint_switch_section": "Chuyển mục",
"cw_hint_filter": "Lọc",
"cw_actions": "Thao tác",
"cw_select": "Chọn",
"cw_reload_title": "Trình trợ giúp PS5Upload đã khởi động lại",
-1
View File
@@ -3993,7 +3993,6 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}",
"cw_hint_open": "打开",
"cw_hint_back": "返回",
"cw_hint_switch_section": "切换分区",
"cw_hint_filter": "筛选",
"cw_actions": "操作",
"cw_select": "选择",
"cw_reload_title": "PS5Upload 助手已重启",
-1
View File
@@ -3987,7 +3987,6 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}",
"cw_hint_open": "開啟",
"cw_hint_back": "返回",
"cw_hint_switch_section": "切換分區",
"cw_hint_filter": "篩選",
"cw_actions": "操作",
"cw_select": "選取",
"cw_reload_title": "PS5Upload 助手已重新啟動",