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 ( - - )} - + ); + })} + -
- {t("settings.replay.replayPart")} - - ({ - value: seconds, - label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds), - }))} - onChange={(after_secs) => replay({ after_secs })} - className="min-w-28" - /> - replay({ after_secs })} +
+ {tab === "replay" && ( + <> +
+

+ + {estimate ? t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs)) }) : "—"} + + + {t("settings.replay.perClip", { duration: durationLabel(draft.replay.duration_secs) })} + + {estimate && ( + + {"· "} + {t("settings.replay.approx", { size: bytes(perMinute) })}{" "} + {t("settings.replay.perMinute")} + + )} +

+ {estimate && ( + <> +

+ {technical(estimate, draft.video.fps)} + {encoderBytes > 0 && <> · {t("settings.replay.encoderMemory", { size: bytes(encoderBytes) })}} +

+ {draft.replay.after_secs > 0 && ( +

+ {t("settings.replay.withAfter", { after: durationLabel(draft.replay.after_secs) })}{" "} + + {t("settings.replay.approx", { size: bytes(clipBytes(draft.replay.duration_secs + draft.replay.after_secs)) })} + +

+ )} +

+ {t("settings.replay.bufferSize")}{" "} + {bytes(estimate.bytes)}{" "} + {draft.replay.storage === "ram" ? t("settings.replay.inRam") : t("settings.replay.onDisk")} +

+ + )} + {tooMuchRam && memory && ( +
+

+ {t("settings.replay.ramAlert", { + percent: Math.round(ramShare * 100), + free: bytes(memory.available_bytes), + })} +

+ +
+ )} +
+ + ({ + value: seconds, + label: seconds === 0 ? t("settings.replay.afterNone") : durationLabel(seconds), + }))} + onChange={(after_secs) => replay({ after_secs })} + className="min-w-28" + /> + replay({ after_secs })} + /> +
+
+ + + label={t("settings.replay.buffer")} + value={draft.replay.storage} + options={[ + { value: "ram", label: t("settings.replay.ram") }, + { value: "disk", label: t("settings.replay.disk") }, + ]} + onChange={(storage) => replay({ storage })} + /> + + - - - - - label={t("settings.replay.buffer")} - value={draft.replay.storage} - options={[ - { value: "ram", label: t("settings.replay.ram") }, - { value: "disk", label: t("settings.replay.disk") }, - ]} - onChange={(storage) => replay({ storage })} - /> - - -
+ + )} -
- {t("settings.replay.recordingPart")} -

- - {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"} - {" "} - {t("settings.replay.perMinute")} - {recordEstimate && ( - <> - {" "} - ·{" "} - - {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })} - {" "} - {t("settings.replay.perHour")} - - )} - {separate && recordEstimate && ( - {technical(recordEstimate, recordFields.fps)} - )} -

- - - change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } }) - } - /> - - {separate && ( - + {tab === "recording" && ( + <> +
+

+ + {recordEstimate ? t("settings.replay.approx", { size: bytes(recordPerMinute) }) : "—"} + {" "} + {t("settings.replay.perMinute")} + {recordEstimate && ( + <> + {" "} + ·{" "} + + {t("settings.replay.approx", { size: bytes(recordPerMinute * 60) })} + {" "} + {t("settings.replay.perHour")} + + )} + {separate && recordEstimate && ( + {technical(recordEstimate, recordFields.fps)} + )} +

+
+ + + change({ ...draft, record: { ...draft.record, video: same ? null : recordVideoOf(draft.video) } }) + } + /> + + {separate && ( + + )} + )} -
-
- {t("settings.replay.commonPart")} - - - label={t("settings.replay.encoder")} - value={draft.video.encoder} - options={ENCODER_CHOICES.map((choice) => ({ - value: choice, - label: t(`settings.replay.encoders.${choice}`), - disabled: known && choice !== "auto" && !works(choice === "hardware"), - }))} - onChange={chooseEncoder} - /> - - - ({ + value: seconds, + label: t("units.seconds", { value: number(seconds, 1) }), + }))} + onChange={(keyframe_interval_secs) => video({ keyframe_interval_secs })} + /> + + + + label={t("settings.replay.gpuPriority")} + value={draft.video.gpu_priority} + options={[ + { value: "game", label: t("settings.replay.gpuPriorities.game") }, + { value: "capture", label: t("settings.replay.gpuPriorities.capture") }, + ]} + onChange={(gpu_priority) => video({ gpu_priority })} + /> + + + )} + ); 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);