mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
1 parent
c1c74dece8
commit
33f1851cdc
28 files changed
+423
-128
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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": "開啟",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 সাহায্যকারী আবার চালু হয়েছে",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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 دوباره راهاندازی شد",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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 हेल्पर फिर से शुरू हुआ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 のヘルパーが再起動しました",
|
||||
|
||||
@@ -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 헬퍼가 다시 시작되었습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 перезапустился",
|
||||
|
||||
@@ -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 เริ่มใหม่แล้ว",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 助手已重启",
|
||||
|
||||
@@ -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 助手已重新啟動",
|
||||
|
||||
Reference in new issue
Block a user