diff --git a/client/src/console/fullscreen.test.ts b/client/src/console/fullscreen.test.ts index 0104aa5f..386d6ff5 100644 --- a/client/src/console/fullscreen.test.ts +++ b/client/src/console/fullscreen.test.ts @@ -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 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; 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 = {}, broken = false) { @@ -55,8 +86,24 @@ function storage(init: Record = {}, broken = false) { return m; } +function win() { + const listeners: Record 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 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 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).requestFullscreen = () => { - n++; - return n === 1 ? Promise.reject(new Error("no")) : Promise.resolve(); - }; - const listeners: Record 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(); }); }); diff --git a/client/src/console/fullscreen.ts b/client/src/console/fullscreen.ts index 6ed6d294..9cc85104 100644 --- a/client/src/console/fullscreen.ts +++ b/client/src/console/fullscreen.ts @@ -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; 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,21 +83,63 @@ 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(); +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 { if (!fullscreenSupported(doc)) return Promise.resolve(false); - try { - if (fullscreenApi(doc) === "standard") { + if (fullscreenApi(doc) === "standard") { + try { return Promise.resolve(doc.documentElement.requestFullscreen!()).then( () => true, - () => false, + () => { + markRefused(); + return false; + }, ); + } catch { + markRefused(); + return Promise.resolve(false); } - doc.documentElement.webkitRequestFullscreen!(); // returns nothing; a refusal is an event - return Promise.resolve(true); - } catch { - return Promise.resolve(false); } + return new Promise((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 { @@ -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); + }; } diff --git a/client/src/console/nav.dom.test.ts b/client/src/console/nav.dom.test.ts index 002674b6..a78f42ca 100644 --- a/client/src/console/nav.dom.test.ts +++ b/client/src/console/nav.dom.test.ts @@ -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)); diff --git a/client/src/console/nav.test.ts b/client/src/console/nav.test.ts index ba09ee54..fb6770bb 100644 --- a/client/src/console/nav.test.ts +++ b/client/src/console/nav.test.ts @@ -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", () => { diff --git a/client/src/console/nav.ts b/client/src/console/nav.ts index 8b33328f..345d820d 100644 --- a/client/src/console/nav.ts +++ b/client/src/console/nav.ts @@ -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 = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", "Escape", "Backspace"]); +const ALWAYS_LIVE_KEYCODES: ReadonlySet = 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 = 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 = new Set(["up", "down", "left", "right export function createPadReader(map: InputMap): (pressed: boolean[], now: number) => NavAction[] { const downSince = new Map(); const lastFire = new Map(); - 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(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); }; diff --git a/client/src/console/settings/Settings.tsx b/client/src/console/settings/Settings.tsx index f170dec1..0280cda9 100644 --- a/client/src/console/settings/Settings.tsx +++ b/client/src/console/settings/Settings.tsx @@ -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(null); const [language, setLanguageState] = useState(storedLanguage); - const [fullscreen, setFullscreen] = useState(() => (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); }} /> ); diff --git a/client/src/console/strings.gen.json b/client/src/console/strings.gen.json index a9352d58..d3e6a366 100644 --- a/client/src/console/strings.gen.json +++ b/client/src/console/strings.gen.json @@ -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": "開啟", diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 0fc862a9..01915d41 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -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", diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index 8a1b4fb6..0b492532 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -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 সাহায্যকারী আবার চালু হয়েছে", diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index 4d9d8ad2..d6c0f795 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -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", diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 7af00f09..16d0a5bd 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -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", diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index 1dfb423f..3e483548 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -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ó", diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index 4ff39768..8bf2c722 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -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 دوباره راه‌اندازی شد", diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index 8080c7f6..755d60fd 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -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é", diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index dfc7f21f..e5a4f531 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -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 हेल्पर फिर से शुरू हुआ", diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index 299d3353..6d29f748 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -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", diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index b3e8b58a..20ce0c43 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -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", diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index 5d8652cf..33a4440a 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -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", diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index b75e68b8..92c01f0f 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -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 のヘルパーが再起動しました", diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index e27b80a1..bd7ad113 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -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 헬퍼가 다시 시작되었습니다", diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index 910a0ec4..f42c2a72 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -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", diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index e08eaccc..0174d94a 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index 5c1f5795..f33c9dd6 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -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 перезапустился", diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index fc4d3ea0..3fb48da4 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -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 เริ่มใหม่แล้ว", diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index 84c3cd4d..fa5af9ae 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -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ı", diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 25fbba03..37d18fca 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -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", diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 4cc71859..33c4b5dc 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -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 助手已重启", diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index 917869e3..f9cc8eb8 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -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 助手已重新啟動",