diff --git a/app/src/App.tsx b/app/src/App.tsx
index 3337d10..cf06d1b 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -15,9 +15,10 @@ import { QualityButton, StatusBanner } from "./components/StatusBanner.tsx";
import { type DraftRequest, addDrafts } from "./lib/compressDrafts.ts";
import { useT } from "./lib/i18n.ts";
import { onboardingNeeded } from "./lib/ipc.ts";
+import { type QualityTab, rememberTab } from "./lib/qualityTabs.ts";
import { useEngine } from "./lib/useEngine.ts";
-export type Destination = { view: View; section?: SectionId; focus?: boolean };
+export type Destination = { view: View; section?: SectionId; focus?: boolean; tab?: QualityTab };
export default function App() {
return (
@@ -53,8 +54,12 @@ function Shell() {
[engine.config],
);
- const open = ({ view: nextView, section: nextSection, focus }: Destination) => {
- const focusQuality = () => focus && setQualityFocus((count) => count + 1);
+ const open = ({ view: nextView, section: nextSection, focus, tab }: Destination) => {
+ const focusQuality = () => {
+ if (!focus) return;
+ if (tab) rememberTab(tab);
+ setQualityFocus((count) => count + 1);
+ };
if (nextView === view && (!nextSection || nextSection === section)) {
focusQuality();
return;
diff --git a/app/src/components/StatusBanner.tsx b/app/src/components/StatusBanner.tsx
index 4e8fdb1..d63f97e 100644
--- a/app/src/components/StatusBanner.tsx
+++ b/app/src/components/StatusBanner.tsx
@@ -45,7 +45,8 @@ function ReplayWitness({ phase }: { phase: ReplayPhase }) {
);
}
-const QUALITY: Destination = { view: "settings", section: "replay", focus: true };
+const QUALITY: Destination = { view: "settings", section: "replay", focus: true, tab: "replay" };
+const RECORDING_QUALITY: Destination = { ...QUALITY, tab: "recording" };
export function QualityButton({ onOpen }: { onOpen: (destination: Destination) => void }) {
const t = useT();
@@ -61,13 +62,21 @@ export function QualityButton({ onOpen }: { onOpen: (destination: Destination) =
);
}
-function QualityLink({ summary, onOpen }: { summary: string; onOpen: (destination: Destination) => void }) {
+function QualityLink({
+ summary,
+ destination,
+ onOpen,
+}: {
+ summary: string;
+ destination: Destination;
+ onOpen: (destination: Destination) => void;
+}) {
const t = useT();
const cut = summary.lastIndexOf(" ") + 1;
return (
- {replaySummary && }
+ {replaySummary && }
{clipEndsAt !== null && }
-
- {t("settings.replay.replayPart")}
-
-
-
-
-
>
);
diff --git a/app/src/lib/qualityTabs.test.ts b/app/src/lib/qualityTabs.test.ts
new file mode 100644
index 0000000..135140e
--- /dev/null
+++ b/app/src/lib/qualityTabs.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "vitest";
+import type { EngineConfig } from "./ipc.ts";
+import { changedTabs, nextTab, rememberTab, rememberedTab } from "./qualityTabs.ts";
+
+const saved = {
+ replay: { duration_secs: 60, after_secs: 0, storage: "ram", arm_on_startup: true },
+ video: {
+ codec: "h264",
+ quality: "balanced",
+ bitrate_kbps: 40000,
+ fps: 60,
+ resolution: { kind: "native" },
+ encoder: "auto",
+ keyframe_interval_secs: 1,
+ gpu_priority: "game",
+ },
+ record: { video: null },
+} as unknown as EngineConfig;
+
+describe("quality tabs", () => {
+ test("only_the_tab_holding_a_change_is_marked", () => {
+ expect(changedTabs(saved, saved)).toEqual([]);
+ expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, after_secs: 10 } })).toEqual(["replay"]);
+ expect(changedTabs(saved, { ...saved, record: { ...saved.record, video: { fps: 30 } } } as EngineConfig)).toEqual([
+ "recording",
+ ]);
+ expect(changedTabs(saved, { ...saved, video: { ...saved.video, gpu_priority: "capture" } })).toEqual(["common"]);
+ });
+
+ test("settings_outside_the_page_mark_no_tab", () => {
+ expect(changedTabs(saved, { ...saved, replay: { ...saved.replay, arm_on_startup: false } })).toEqual([]);
+ });
+
+ test("arrows_wrap_and_home_end_jump", () => {
+ expect(nextTab("replay", "ArrowRight")).toBe("recording");
+ expect(nextTab("common", "ArrowRight")).toBe("replay");
+ expect(nextTab("replay", "ArrowLeft")).toBe("common");
+ expect(nextTab("recording", "Home")).toBe("replay");
+ expect(nextTab("recording", "End")).toBe("common");
+ expect(nextTab("recording", "Tab")).toBeNull();
+ });
+
+ test("the_chosen_tab_is_kept_for_the_session", () => {
+ expect(rememberedTab()).toBe("replay");
+ rememberTab("recording");
+ expect(rememberedTab()).toBe("recording");
+ });
+});
diff --git a/app/src/lib/qualityTabs.ts b/app/src/lib/qualityTabs.ts
new file mode 100644
index 0000000..b80096b
--- /dev/null
+++ b/app/src/lib/qualityTabs.ts
@@ -0,0 +1,41 @@
+import type { EngineConfig } from "./ipc.ts";
+
+export const QUALITY_TABS = ["replay", "recording", "common"] as const;
+export type QualityTab = (typeof QUALITY_TABS)[number];
+
+const TAB_FIELDS: Record unknown> = {
+ replay: ({ replay, video }) => [
+ replay.duration_secs,
+ replay.after_secs,
+ replay.storage,
+ video.codec,
+ video.quality,
+ video.bitrate_kbps,
+ video.fps,
+ video.resolution,
+ ],
+ recording: ({ record }) => record.video,
+ common: ({ video }) => [video.encoder, video.keyframe_interval_secs, video.gpu_priority],
+};
+
+export function changedTabs(saved: EngineConfig, draft: EngineConfig): QualityTab[] {
+ return QUALITY_TABS.filter((tab) => JSON.stringify(TAB_FIELDS[tab](saved)) !== JSON.stringify(TAB_FIELDS[tab](draft)));
+}
+
+let sessionTab: QualityTab = "replay";
+
+export function rememberedTab(): QualityTab {
+ return sessionTab;
+}
+
+export function rememberTab(tab: QualityTab) {
+ sessionTab = tab;
+}
+
+export function nextTab(current: QualityTab, key: string): QualityTab | null {
+ const index = QUALITY_TABS.indexOf(current);
+ const count = QUALITY_TABS.length;
+ const steps: Record = { ArrowRight: index + 1, ArrowLeft: index - 1 + count, Home: 0, End: count - 1 };
+ const target = steps[key];
+ return target === undefined ? null : (QUALITY_TABS[target % count] ?? null);
+}
diff --git a/app/src/locales/en.json b/app/src/locales/en.json
index df2c36a..b4f139f 100644
--- a/app/src/locales/en.json
+++ b/app/src/locales/en.json
@@ -349,6 +349,8 @@
"replayPart": "Replay",
"recordingPart": "Recording",
"commonPart": "Shared",
+ "tabs": "Replay and quality parts",
+ "unapplied": "unapplied changes",
"sameAsReplay": "Same as replay",
"forRecording": "{setting} (recording)",
"bufferSize": "Buffer:"
diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json
index bac7412..e7bf4b3 100644
--- a/app/src/locales/fr.json
+++ b/app/src/locales/fr.json
@@ -349,6 +349,8 @@
"replayPart": "Replay",
"recordingPart": "Enregistrement",
"commonPart": "Commun",
+ "tabs": "Parties de Replay et qualité",
+ "unapplied": "changements non appliqués",
"sameAsReplay": "Comme le replay",
"forRecording": "{setting} (enregistrement)",
"bufferSize": "Tampon :"
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 9b2b260..2e73408 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -131,7 +131,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1).
| APP-11 | Installeur Windows (NSIS, par utilisateur, sans droits admin) ; la désinstallation conserve les clips. |
| APP-12 | Panneau de performances : RAM/CPU du moteur, taille du buffer, images perdues, encodeur utilisé. |
| APP-13 | Accessibilité : navigation clavier complète, contrastes conformes, `prefers-reduced-motion` respecté. |
-| APP-15 | Accès direct (v1.3) : sur État, le résumé de la qualité (« 1440p60 · H.264 · ≈ X Mo/min », et celui de l'enregistrement s'il diffère) est un lien ; sur Capture, un bouton « Replay et qualité ». Les deux ouvrent Réglages → Replay & qualité (défilement et focus, via la garde de sortie). |
+| APP-15 | Accès direct (v1.3) : sur État, le résumé de la qualité (« 1440p60 · H.264 · ≈ X Mo/min », et celui de l'enregistrement s'il diffère) est un lien ; sur Capture, un bouton « Replay et qualité ». Les deux ouvrent Réglages → Replay & qualité (défilement et focus, via la garde de sortie). v1.4 : la page est en trois onglets accessibles (Replay, Enregistrement, Commun), chacun avec son estimation de taille, l'onglet choisi est retenu pendant la session, un point discret marque l'onglet qui a un changement non appliqué ; le résumé de l'enregistrement ouvre l'onglet Enregistrement. |
| APP-14 | Barre latérale (v1.2) : État, Galerie, Capture, Audio, Compression, Réglages ; Capture et Audio sont des pages à part entière. Quitter une page (ou fermer la fenêtre) avec des changements non appliqués ouvre une alerte « Appliquer / Ne pas appliquer / Rester » qui dit si appliquer redémarre le replay. |
### 2.8 bis Compression (CMP, v1.2)
diff --git a/scripts/quality-tabs-check.mjs b/scripts/quality-tabs-check.mjs
new file mode 100644
index 0000000..9d38bdb
--- /dev/null
+++ b/scripts/quality-tabs-check.mjs
@@ -0,0 +1,190 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { connect } from "./cdp.mjs";
+
+const [port, outDir] = process.argv.slice(2);
+mkdirSync(outDir, { recursive: true });
+const { send, page, evaluate, close } = await connect(port);
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const results = [];
+async function until(what, expression, timeout = 20_000) {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ if (await evaluate(expression)) return;
+ await sleep(150);
+ }
+ throw new Error(`${what} : rien après ${timeout} ms`);
+}
+async function check(label, run) {
+ try {
+ const detail = await run();
+ results.push(true);
+ console.log(`OK ${label}${detail ? ` : ${detail}` : ""}`);
+ } catch (error) {
+ results.push(false);
+ console.log(`ÉCHEC ${label} : ${error.message}`);
+ }
+}
+const expect = (condition, message) => {
+ if (!condition) throw new Error(message);
+};
+const nav = (name) =>
+ page((text) => {
+ const button = [...document.querySelectorAll("nav button")].find((item) => item.textContent.trim().startsWith(text));
+ button?.click();
+ return Boolean(button);
+ }, name);
+const tabs = () =>
+ page(() =>
+ [...document.querySelectorAll("[role=tab]")].map((tab) => ({
+ id: tab.id,
+ selected: tab.getAttribute("aria-selected") === "true",
+ tabIndex: tab.tabIndex,
+ dot: tab.querySelector(".sr-only") !== null,
+ focused: document.activeElement === tab,
+ })),
+ );
+const selected = async () => (await tabs()).find((tab) => tab.selected)?.id;
+const key = async (name, code) => {
+ await send("Input.dispatchKeyEvent", { type: "keyDown", key: name, code: name, windowsVirtualKeyCode: code });
+ await send("Input.dispatchKeyEvent", { type: "keyUp", key: name, code: name, windowsVirtualKeyCode: code });
+ await sleep(150);
+};
+async function shoot(name) {
+ const size = await page(() => ({ width: innerWidth, height: innerHeight }));
+ for (const theme of ["dark", "light"]) {
+ await page((value) => (document.documentElement.dataset.theme = value), theme);
+ for (const scale of [1, 1.5]) {
+ await send("Emulation.setDeviceMetricsOverride", {
+ width: Math.round(size.width / scale),
+ height: Math.round(size.height / scale),
+ deviceScaleFactor: scale,
+ mobile: false,
+ });
+ await sleep(300);
+ const { result } = await send("Page.captureScreenshot", { format: "png" });
+ writeFileSync(join(outDir, `${name}-${theme}-${scale * 100}.png`), Buffer.from(result.data, "base64"));
+ }
+ }
+ await send("Emulation.clearDeviceMetricsOverride");
+ await page(() => delete document.documentElement.dataset.theme);
+}
+const dialogOpen = () => page(() => Boolean(document.querySelector("dialog[open]")));
+
+await until("app", "document.querySelectorAll('nav button').length > 0");
+await until("replay armé", "document.querySelector('header [role=status]')?.textContent.includes('armé')", 30_000);
+
+await check("lien de l'enregistrement sur État → onglet Enregistrement", async () => {
+ await until("deux résumés", "document.querySelectorAll('header button[aria-label^=\"Replay et qualité\"]').length === 2");
+ await page(() => document.querySelectorAll('header button[aria-label^="Replay et qualité"]')[1].click());
+ await until("onglets", "document.querySelectorAll('[role=tab]').length === 3");
+ await sleep(400);
+ const id = await selected();
+ expect(id === "quality-tab-recording", id);
+ const focus = await page(() => document.activeElement?.id);
+ expect(focus === "replay-quality", `focus sur ${focus}`);
+ return `${id}, focus sur l'estimation`;
+});
+
+await check("rôles et liens tab/tabpanel", async () => {
+ const state = await page(() => {
+ const panel = document.querySelector("[role=tabpanel]");
+ const tab = document.querySelector("[role=tab][aria-selected=true]");
+ return { controls: tab.getAttribute("aria-controls"), panel: panel.id, labelledby: panel.getAttribute("aria-labelledby"), tab: tab.id, list: document.querySelector("[role=tablist]").getAttribute("aria-label") };
+ });
+ expect(state.controls === state.panel && state.labelledby === state.tab && state.list, JSON.stringify(state));
+ const all = await tabs();
+ expect(all.filter((tab) => tab.tabIndex === 0).length === 1, "un seul onglet dans l'ordre de tabulation");
+ return state.list;
+});
+
+await check("flèches, Origine, Fin au clavier", async () => {
+ await page(() => document.querySelector("[role=tab][aria-selected=true]").focus());
+ const seen = [];
+ for (const [name, code] of [["ArrowRight", 39], ["ArrowRight", 39], ["ArrowLeft", 37], ["Home", 36], ["End", 35]]) {
+ await key(name, code);
+ const state = await tabs();
+ const current = state.find((tab) => tab.selected);
+ expect(current.focused, `${name} : focus pas sur l'onglet choisi`);
+ seen.push(current.id.replace("quality-tab-", ""));
+ }
+ expect(seen.join(",") === "common,replay,common,replay,common", seen.join(","));
+ return seen.join(" → ");
+});
+
+await check("point discret sur l'onglet modifié, pas de garde en changeant d'onglet", async () => {
+ await page(() => [...document.querySelectorAll("[role=tab]")].find((tab) => tab.id === "quality-tab-replay").click());
+ await sleep(200);
+ const changed = await page(() => {
+ const select = [...document.querySelectorAll("select")].find((item) => item.getAttribute("aria-label") === "Images par seconde");
+ const option = [...select.options].find((item) => item.value !== select.value && item.value !== "0");
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(select, option.value);
+ select.dispatchEvent(new Event("change", { bubbles: true }));
+ return option.value;
+ });
+ await sleep(300);
+ let state = await tabs();
+ expect(state.find((tab) => tab.id === "quality-tab-replay").dot, "pas de point sur Replay");
+ expect(state.filter((tab) => tab.dot).length === 1, "point sur d'autres onglets");
+ await shoot("tabs-replay-dirty");
+ for (const id of ["recording", "common", "replay"]) {
+ await page((target) => document.getElementById(`quality-tab-${target}`).click(), id);
+ await sleep(250);
+ expect(!(await dialogOpen()), `garde ouverte en passant sur ${id}`);
+ if (id !== "replay") await shoot(`tabs-${id}`);
+ }
+ state = await tabs();
+ expect(state.find((tab) => tab.id === "quality-tab-replay").dot, "le point a disparu");
+ return `${changed} i/s, point sur Replay seul`;
+});
+
+await check("quitter la page avec le changement → garde", async () => {
+ await page(() => document.getElementById("quality-tab-recording").click());
+ await nav("État");
+ await sleep(400);
+ expect(await dialogOpen(), "pas de garde");
+ await page(() => [...document.querySelectorAll("dialog[open] button")].find((button) => button.textContent.trim() === "Rester").click());
+ await sleep(300);
+ expect(!(await dialogOpen()), "garde toujours ouverte");
+ const id = await selected();
+ expect(id === "quality-tab-recording", `onglet après Rester : ${id}`);
+ await page(() => [...document.querySelectorAll("button")].find((button) => button.textContent.trim() === "Ne pas appliquer")?.click());
+ await sleep(300);
+ return "Rester garde la page et l'onglet";
+});
+
+await check("onglet retenu pendant la session", async () => {
+ await page(() => document.getElementById("quality-tab-common").click());
+ await nav("Galerie");
+ await sleep(300);
+ if (await dialogOpen()) throw new Error("garde après annulation");
+ await nav("Réglages");
+ await sleep(300);
+ await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim() === "Replay & qualité")?.click());
+ await until("onglets", "document.querySelectorAll('[role=tab]').length === 3");
+ const id = await selected();
+ expect(id === "quality-tab-common", id);
+ return id;
+});
+
+await check("lien du replay sur État → onglet Replay ; bouton de Capture → onglet Replay", async () => {
+ await nav("État");
+ await until("résumés", "document.querySelectorAll('header button[aria-label^=\"Replay et qualité\"]').length === 2");
+ await page(() => document.querySelectorAll('header button[aria-label^="Replay et qualité"]')[0].click());
+ await until("onglets", "document.querySelectorAll('[role=tab]').length === 3");
+ const fromStatus = await selected();
+ await page(() => document.getElementById("quality-tab-recording").click());
+ await nav("Capture");
+ await sleep(500);
+ await page(() => [...document.querySelectorAll("main button")].find((button) => button.textContent.trim() === "Replay et qualité").click());
+ await until("onglets", "document.querySelectorAll('[role=tab]').length === 3");
+ const fromCapture = await selected();
+ expect(fromStatus === "quality-tab-replay" && fromCapture === "quality-tab-replay", `${fromStatus} / ${fromCapture}`);
+ await shoot("tabs-replay");
+ return "Replay dans les deux cas";
+});
+
+close();
+const passed = results.filter(Boolean).length;
+console.log(`${passed} sur ${results.length}`);
+process.exit(passed === results.length ? 0 : 1);