From 50cfba4ceb8bb37b55137300760f694fdfcce87e Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 16:00:19 +0100 Subject: [PATCH 1/4] feat(library): find a clip by its path --- app/src-tauri/src/clips.rs | 6 +++ app/src-tauri/src/commands.rs | 7 ++- app/src-tauri/src/lib.rs | 1 + crates/engine/src/library/index.rs | 78 +++++++++++++++++++++--------- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/app/src-tauri/src/clips.rs b/app/src-tauri/src/clips.rs index e8e5de5..e45624f 100644 --- a/app/src-tauri/src/clips.rs +++ b/app/src-tauri/src/clips.rs @@ -206,6 +206,12 @@ impl EngineSlot { self.with_library(|library| library.games()) } + pub fn get_clip(&self, path: &str) -> Result { + let path = self.known_clip(path)?; + self.with_library(|library| library.clip(&path))? + .ok_or_else(|| Localized::new("clip_unknown")) + } + pub fn clip_mix(&self, path: &str) -> Result, Localized> { let path = self.known_clip(path)?; let stored = self.with_library(|library| library.mix(&path))?; diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 8dc90ea..2629a88 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use palaclips_engine::config::CaptureTarget; use palaclips_engine::encode::{EncoderInfo, video_encoders}; use palaclips_engine::engine::ReplayEstimate; use palaclips_engine::events::Localized; -use palaclips_engine::library::{ClipSummary, disk_space, recent_clips}; +use palaclips_engine::library::{Clip, ClipSummary, disk_space, recent_clips}; use palaclips_engine::sources::{AppSession, AudioDevice, app_sessions, audio_devices}; use serde::Serialize; use tauri::State; @@ -333,6 +333,11 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result, Loca slot.clip_games() } +#[tauri::command] +pub async fn get_clip(slot: State<'_, EngineSlot>, path: String) -> Result { + slot.get_clip(&path) +} + #[tauri::command] pub async fn clip_mix( slot: State<'_, EngineSlot>, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index a3b361f..6b5301a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -778,6 +778,7 @@ pub fn run() { commands::record_stop, commands::list_clips, commands::clip_games, + commands::get_clip, commands::clip_mix, commands::set_clip_mix, commands::set_clip_favorite, diff --git a/crates/engine/src/library/index.rs b/crates/engine/src/library/index.rs index 4ac8d37..785baff 100644 --- a/crates/engine/src/library/index.rs +++ b/crates/engine/src/library/index.rs @@ -29,6 +29,30 @@ CREATE TABLE IF NOT EXISTS clips ( CREATE INDEX IF NOT EXISTS clips_by_date ON clips(modified_ms); "; +const CLIP_COLUMNS: &str = "path, name, game, kind, modified_ms, size_bytes, duration_secs, tracks, thumbnail, favorite, fps"; + +fn clip_row(row: &rusqlite::Row) -> rusqlite::Result { + let kind: String = row.get(3)?; + let tracks: String = row.get(7)?; + Ok(Clip { + path: row.get(0)?, + name: row.get(1)?, + game: row.get(2)?, + kind: if kind == "replay" { + ClipKind::Replay + } else { + ClipKind::Recording + }, + modified_ms: row.get::<_, i64>(4)? as u64, + size_bytes: row.get::<_, i64>(5)? as u64, + duration_secs: row.get(6)?, + tracks: serde_json::from_str(&tracks).unwrap_or_default(), + thumbnail: row.get(8)?, + favorite: row.get(9)?, + fps: row.get(10)?, + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClipKind { @@ -384,9 +408,20 @@ impl Library { Ok(removed > 0) } + pub fn clip(&self, path: &Path) -> Result> { + self.conn + .query_row( + &format!("SELECT {CLIP_COLUMNS} FROM clips WHERE path = ?1"), + params![key(path)], + clip_row, + ) + .optional() + .map_err(db_error) + } + pub fn query(&self, query: &ClipQuery) -> Result> { let sql = format!( - "SELECT path, name, game, kind, modified_ms, size_bytes, duration_secs, tracks, thumbnail, favorite, fps + "SELECT {CLIP_COLUMNS} FROM clips WHERE (?1 IS NULL OR game = ?1) AND (?2 IS NULL OR kind = ?2) @@ -413,27 +448,7 @@ impl Library { search, query.limit.map_or(-1, i64::from), ], - |row| { - let kind: String = row.get(3)?; - let tracks: String = row.get(7)?; - Ok(Clip { - path: row.get(0)?, - name: row.get(1)?, - game: row.get(2)?, - kind: if kind == "replay" { - ClipKind::Replay - } else { - ClipKind::Recording - }, - modified_ms: row.get::<_, i64>(4)? as u64, - size_bytes: row.get::<_, i64>(5)? as u64, - duration_secs: row.get(6)?, - tracks: serde_json::from_str(&tracks).unwrap_or_default(), - thumbnail: row.get(8)?, - favorite: row.get(9)?, - fps: row.get(10)?, - }) - }, + clip_row, ) .map_err(db_error)?; let mut clips = Vec::new(); @@ -803,6 +818,25 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test] + fn a_clip_is_found_by_its_path() { + let dir = scratch("by-path"); + let clips = dir.join("clips"); + let path = clips.join("Fortnite").join("Fortnite_a_replay.mp4"); + write_clip(&path, 1, 1); + let mut library = library(&dir); + library.sync().expect("sync"); + let found = library.clip(&path).expect("clip").expect("indexed"); + assert_eq!(found.name, "Fortnite_a_replay"); + assert!( + library + .clip(&clips.join("absent.mp4")) + .expect("clip") + .is_none() + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn a_clip_keeps_its_player_mix_through_a_rename_and_a_rescan() { let dir = scratch("mix"); From d17221cdb88dfd560a191ca9aa4f1ef2bd82d1b5 Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 16:00:20 +0100 Subject: [PATCH 2/4] fix(ui): open a recent clip from the status page --- app/src/App.tsx | 34 +++++++++++++++++++--- app/src/components/RecentClips.tsx | 40 +++++++++++++++++++++++--- app/src/components/gallery/Gallery.tsx | 34 +++++++++++++++++++++- app/src/lib/destination.test.ts | 21 ++++++++++++++ app/src/lib/destination.ts | 15 ++++++++++ app/src/lib/ipc.ts | 4 +++ app/src/locales/en.json | 2 ++ app/src/locales/fr.json | 2 ++ app/src/styles.css | 18 +++++++++++- 9 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 app/src/lib/destination.test.ts create mode 100644 app/src/lib/destination.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index cf06d1b..65facc6 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,12 +13,15 @@ import { CaptureSection } from "./components/settings/CaptureSection.tsx"; import { type SectionId, Settings } from "./components/settings/Settings.tsx"; 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 ClipRequest, clipRequest } from "./lib/destination.ts"; +import { errorText, useT } from "./lib/i18n.ts"; +import { getClip, 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; tab?: QualityTab }; +export type Destination = { view: View; section?: SectionId; focus?: boolean; tab?: QualityTab; clip?: string }; + +const STATUS_NOTICE_MS = 6_000; export default function App() { return ( @@ -43,6 +46,8 @@ function Shell() { const [view, setView] = useState("status"); const [section, setSection] = useState("replay"); const [qualityFocus, setQualityFocus] = useState(0); + const [request, setRequest] = useState(null); + const [statusNotice, setStatusNotice] = useState(null); const engine = useEngine(); const [onboarding, setOnboarding] = useState(null); @@ -54,7 +59,13 @@ function Shell() { [engine.config], ); - const open = ({ view: nextView, section: nextSection, focus, tab }: Destination) => { + useEffect(() => { + if (!statusNotice) return; + const timer = window.setTimeout(() => setStatusNotice(null), STATUS_NOTICE_MS); + return () => window.clearTimeout(timer); + }, [statusNotice]); + + const open = ({ view: nextView, section: nextSection, focus, tab, clip }: Destination) => { const focusQuality = () => { if (!focus) return; if (tab) rememberTab(tab); @@ -65,12 +76,21 @@ function Shell() { return; } guard.leave(() => { + setRequest((previous) => clipRequest(clip, view, previous)); setView(nextView); if (nextSection) setSection(nextSection); focusQuality(); }); }; + const openClip = (path: string) => { + setStatusNotice(null); + getClip(path).then( + () => open({ view: "gallery", clip: path }), + (failure) => setStatusNotice(errorText(failure)), + ); + }; + if (onboarding === null) return null; if (onboarding) return ( @@ -97,6 +117,9 @@ function Shell() {

)} +

+ {statusNotice} +

@@ -121,6 +145,8 @@ function Shell() { open({ view: next })} onCompress={(requests: DraftRequest[]) => { addDrafts(requests); open({ view: "compress" }); diff --git a/app/src/components/RecentClips.tsx b/app/src/components/RecentClips.tsx index 78532cd..d745749 100644 --- a/app/src/components/RecentClips.tsx +++ b/app/src/components/RecentClips.tsx @@ -2,6 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import { Film } from "lucide-react"; import type { ClipSummary } from "../lib/ipc.ts"; import { clock, relativeTime } from "../lib/format.ts"; +import { dayLabel, timeOfDay } from "../lib/gallery.ts"; import { useT } from "../lib/i18n.ts"; import { type TrackLabels, kindOfTitle, trackColor } from "../lib/tracks.ts"; @@ -10,17 +11,21 @@ export function ClipCard({ appOrder, labels, fresh, + onOpen, }: { clip: ClipSummary; appOrder: string[]; labels: TrackLabels | null; fresh: boolean; + onOpen?: () => void; }) { const t = useT(); return (
  • {clip.thumbnail ? ( @@ -53,6 +58,25 @@ export function ClipCard({ ))}
    + {onOpen && ( +
  • ); } @@ -63,20 +87,21 @@ export function RecentClips({ labels, freshClip, saveKeys, + onOpen, }: { clips: ClipSummary[]; appOrder: string[]; labels: TrackLabels | null; freshClip: string | null; saveKeys: string | null; + onOpen: (path: string) => void; }) { const t = useT(); const [beforeKeys, afterKeys] = t("clips.emptyWithKeys").split("{keys}"); return (

    {t("clips.title")} @@ -99,7 +124,14 @@ export function RecentClips({ ) : (
      {clips.map((clip) => ( - + onOpen(clip.path)} + /> ))}
    )} diff --git a/app/src/components/gallery/Gallery.tsx b/app/src/components/gallery/Gallery.tsx index c5e8e0b..600a2a5 100644 --- a/app/src/components/gallery/Gallery.tsx +++ b/app/src/components/gallery/Gallery.tsx @@ -1,6 +1,7 @@ import { Search, Star } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DraftRequest } from "../../lib/compressDrafts.ts"; +import { type ClipRequest, playerExit } from "../../lib/destination.ts"; import { type Period, clipTitle, sinceOf } from "../../lib/gallery.ts"; import { errorText, t as translate, useT } from "../../lib/i18n.ts"; import { useLibraryChanges } from "../../lib/library.ts"; @@ -11,12 +12,14 @@ import { type EngineConfig, clipGames, copyClip, + getClip, listClips, renameClip, revealClip, setClipFavorite, trashClip, } from "../../lib/ipc.ts"; +import type { View } from "../Rail.tsx"; import { Segmented, Select } from "../settings/Settings.tsx"; import type { ClipAction } from "./ClipCard.tsx"; import { ClipGrid } from "./ClipGrid.tsx"; @@ -63,10 +66,14 @@ function gridShortcuts(): Shortcut[] { export function Gallery({ config, saveKeys, + clipToOpen = null, + onReturn, onCompress, }: { config: EngineConfig | null; saveKeys: string | null; + clipToOpen?: ClipRequest | null; + onReturn?: (view: View) => void; onCompress: (requests: DraftRequest[]) => void; }) { const t = useT(); @@ -82,6 +89,8 @@ export function Gallery({ const [renaming, setRenaming] = useState(null); const [renameError, setRenameError] = useState(null); const [open, setOpen] = useState(null); + const [opening, setOpening] = useState(clipToOpen !== null); + const [returnTo, setReturnTo] = useState("gallery"); const scroller = useRef(null); const request = useRef(0); const trashQueue = useRef(null); @@ -120,6 +129,20 @@ export function Gallery({ useEffect(load, [load]); + useEffect(() => { + if (!clipToOpen) return; + setOpening(true); + getClip(clipToOpen.path) + .then( + (clip) => { + setReturnTo(playerExit(clipToOpen)); + setOpen(clip); + }, + (failure) => setError(errorText(failure)), + ) + .finally(() => setOpening(false)); + }, [clipToOpen?.key]); + useEffect(() => { const timer = window.setTimeout(() => setFilters((current) => ({ ...current, search: search.trim() })), SEARCH_DELAY_MS); return () => window.clearTimeout(timer); @@ -196,7 +219,10 @@ export function Gallery({ setNotice(null); setError(null); const fail = (failure: unknown) => setError(errorText(failure)); - if (action === "open") setOpen(clip); + if (action === "open") { + setReturnTo("gallery"); + setOpen(clip); + } else if (action === "favorite") setClipFavorite(clip.path, !clip.favorite).then( () => setClips((current) => current?.map((item) => (item.path === clip.path ? { ...item, favorite: !clip.favorite } : item)) ?? null), @@ -233,6 +259,10 @@ export function Gallery({ appOrder={appOrder} onBack={() => { setOpen(null); + if (returnTo !== "gallery" && onReturn) { + onReturn(returnTo); + return; + } load(); setRestoreFocus((current) => current + 1); }} @@ -247,6 +277,8 @@ export function Gallery({ ); } + if (opening) return null; + const filtered = filters.search !== "" || filters.game !== "" || filters.kind !== "all" || filters.favorites || filters.period !== "all"; diff --git a/app/src/lib/destination.test.ts b/app/src/lib/destination.test.ts new file mode 100644 index 0000000..43aa90a --- /dev/null +++ b/app/src/lib/destination.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { clipRequest, playerExit } from "./destination.ts"; + +describe("clip destination", () => { + test("opening_a_clip_from_status_returns_to_status", () => { + const request = clipRequest("C:\clips\Fortnite\a.mp4", "status", null); + expect(request).toEqual({ path: "C:\clips\Fortnite\a.mp4", returnTo: "status", key: 1 }); + expect(playerExit(request)).toBe("status"); + }); + + test("a_destination_without_a_clip_opens_no_player", () => { + expect(clipRequest(undefined, "status", null)).toBeNull(); + expect(playerExit(null)).toBe("gallery"); + }); + + test("opening_the_same_clip_again_is_a_new_request", () => { + const first = clipRequest("a.mp4", "status", null); + const second = clipRequest("a.mp4", "status", first); + expect(second?.key).toBe(2); + }); +}); diff --git a/app/src/lib/destination.ts b/app/src/lib/destination.ts new file mode 100644 index 0000000..2e32d2c --- /dev/null +++ b/app/src/lib/destination.ts @@ -0,0 +1,15 @@ +import type { View } from "../components/Rail.tsx"; + +export interface ClipRequest { + path: string; + returnTo: View; + key: number; +} + +export function clipRequest(clip: string | undefined, from: View, previous: ClipRequest | null): ClipRequest | null { + return clip ? { path: clip, returnTo: from, key: (previous?.key ?? 0) + 1 } : null; +} + +export function playerExit(request: ClipRequest | null): View { + return request?.returnTo ?? "gallery"; +} diff --git a/app/src/lib/ipc.ts b/app/src/lib/ipc.ts index dcdac0c..8d9375d 100644 --- a/app/src/lib/ipc.ts +++ b/app/src/lib/ipc.ts @@ -311,6 +311,10 @@ export function clipGames(): Promise { return invoke("clip_games"); } +export function getClip(path: string): Promise { + return invoke("get_clip", { path }); +} + export interface ClipMix { tracks: { volume: number; mute: boolean; solo: boolean }[]; full_mix_chosen: boolean; diff --git a/app/src/locales/en.json b/app/src/locales/en.json index 4c2f271..a16095b 100644 --- a/app/src/locales/en.json +++ b/app/src/locales/en.json @@ -187,6 +187,8 @@ }, "clips": { "title": "Latest clips", + "card": "{game}, {date}, {duration}, {tracks}", + "openHint": "Double-click or Enter: open in the player", "fallbackName": "Clip", "emptyTitle": "No clips yet", "emptyWithKeys": "Press {keys} in game to save the last seconds.", diff --git a/app/src/locales/fr.json b/app/src/locales/fr.json index c549489..f527ed0 100644 --- a/app/src/locales/fr.json +++ b/app/src/locales/fr.json @@ -187,6 +187,8 @@ }, "clips": { "title": "Derniers clips", + "card": "{game}, {date}, {duration}, {tracks}", + "openHint": "Double-clic ou Entrée : ouvrir dans le lecteur", "fallbackName": "Clip", "emptyTitle": "Aucun clip pour l'instant", "emptyWithKeys": "Appuie sur {keys} en jeu pour sauvegarder les dernières secondes.", diff --git a/app/src/styles.css b/app/src/styles.css index b06b436..154b8d2 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -178,7 +178,23 @@ body { .clip-card { transition: opacity 240ms var(--ease-out), - transform 240ms var(--ease-out); + transform 240ms var(--ease-out), + background-color 140ms ease, + border-color 140ms ease; +} + +.clip-card:has(> [data-open]:focus-visible) { + border-color: var(--ink); +} + +.clip-card > [data-open]:focus-visible { + outline-offset: -3px; + border-radius: inherit; +} + +.clip-card:has(> [data-open]:active) { + transform: scale(0.985); + transition-duration: 140ms; } .clip-card[data-fresh="true"] { From a7b10d9f18d92e7c7c78db67917040de01eaea98 Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 16:00:20 +0100 Subject: [PATCH 3/4] test(scripts): check opening recent clips in the isolated app --- scripts/recent-open-check.mjs | 141 ++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/recent-open-check.mjs diff --git a/scripts/recent-open-check.mjs b/scripts/recent-open-check.mjs new file mode 100644 index 0000000..fbc10dc --- /dev/null +++ b/scripts/recent-open-check.mjs @@ -0,0 +1,141 @@ +import { readdirSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +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 root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const results = []; +async function until(what, expression, timeout = 15_000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (await evaluate(expression)) return; + await sleep(100); + } + 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 || error.stack || error}`); + } +} +const expect = (condition, message) => { + if (!condition) throw new Error(message); +}; +const onStatus = "document.querySelector('#clips-title') !== null"; +const inPlayer = "document.querySelector('video') !== null && document.querySelectorAll('.track-lane').length > 0"; +const cards = () => page(() => [...document.querySelectorAll("[data-open]")].map((card) => card.getAttribute("aria-label"))); +const playing = () => page(() => decodeURIComponent(document.querySelector("video")?.src ?? "")); +async function point(index) { + return page((at) => { + const card = document.querySelectorAll("[data-open]")[at]; + card.scrollIntoView({ block: "center" }); + const rect = card.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, index); +} +async function mouse(x, y, count) { + for (let click = 1; click <= count; click++) { + await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: click }); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: click }); + } +} +async function key(name, code, text) { + await send("Input.dispatchKeyEvent", { type: "keyDown", key: name, code: name, windowsVirtualKeyCode: code, text }); + await send("Input.dispatchKeyEvent", { type: "keyUp", key: name, code: name, windowsVirtualKeyCode: code }); +} +async function shoot(name) { + const { result } = await send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(outDir, `${name}.png`), Buffer.from(result.data, "base64")); +} + +await until("app", onStatus); +await until("cartes", "document.querySelectorAll('[data-open]').length === 3"); + +await check("chaque carte a un nom lisible et un raccourci", async () => { + const labels = await cards(); + const shortcuts = await page(() => [...document.querySelectorAll("[data-open]")].map((card) => card.getAttribute("aria-keyshortcuts"))); + expect(labels.every((label) => label && label.includes(",")) && shortcuts.every((item) => item === "Enter"), JSON.stringify(labels)); + return labels[0]; +}); + +await check("un clic simple ne fait rien", async () => { + const { x, y } = await point(0); + await mouse(x, y, 1); + await sleep(600); + expect(await evaluate(onStatus), "État a été quitté"); + return "toujours sur État"; +}); + +await check("double-clic → ce clip dans le lecteur, puis retour à État", async () => { + const { x, y } = await point(0); + const label = (await cards())[0]; + await mouse(x, y, 2); + await until("lecteur", inPlayer); + const src = await playing(); + const game = label.split(",")[0]; + await shoot("opened-from-status"); + expect(src.includes(`\\${game}\\`), `${src} / ${game}`); + await page(() => document.querySelector('button[aria-label="Retour à la galerie"]').click()); + await until("retour à État", onStatus); + return `${src.split("\\").pop()}, retour sur État`; +}); + +await check("Entrée au clavier ouvre le clip, Échap ramène à État avec le focus sur la page", async () => { + await page(() => document.querySelectorAll("[data-open]")[0].focus()); + await key("Tab", 9); + await sleep(200); + const ring = await page(() => { + const card = document.activeElement; + return { open: card?.hasAttribute("data-open"), outline: getComputedStyle(card).outlineStyle, border: getComputedStyle(card.parentElement).borderColor }; + }); + await shoot("card-focus"); + expect(ring.open && ring.outline === "solid", JSON.stringify(ring)); + await key("Enter", 13, "\r"); + await until("lecteur", inPlayer); + await sleep(400); + await key("Escape", 27); + await until("retour à État", onStatus); + return "Entrée puis Échap"; +}); + +await check("galerie ouverte par la barre : pas de lecteur, et son lecteur revient à la galerie", async () => { + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim().startsWith("Galerie")).click()); + await until("galerie", "document.querySelectorAll('[data-clip]').length === 3"); + expect(!(await evaluate("document.querySelector('video') !== null")), "lecteur ouvert sans demande"); + await evaluate("document.querySelector('[data-clip]').click(), true"); + await until("lecteur", inPlayer); + await page(() => document.querySelector('button[aria-label="Retour à la galerie"]').click()); + await until("grille", "document.querySelectorAll('[data-clip]').length === 3"); + await page(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.trim().startsWith("État")).click()); + await until("État", onStatus); + return "retour dans la galerie"; +}); + +await check("fichier disparu entre-temps → message sur État, carte retirée", async () => { + const dir = join(root, "target", "test-config", "clips"); + const file = readdirSync(dir, { recursive: true }).map(String).filter((name) => name.endsWith(".mp4")).sort()[0]; + const target = (await cards()).findIndex((label) => label.startsWith(file.split("\\")[0])); + expect(target >= 0, `carte de ${file} introuvable`); + const { x, y } = await point(target); + unlinkSync(join(dir, file)); + await mouse(x, y, 2); + await until("message", "[...document.querySelectorAll('[role=status]')].some((item) => item.textContent.includes('existe plus'))", 5_000); + expect(await evaluate(onStatus), "État a été quitté"); + await until("carte retirée", "document.querySelectorAll('[data-open]').length === 2", 5_000); + await shoot("file-missing"); + return file; +}); + +close(); +const passed = results.filter(Boolean).length; +console.log(`${passed} sur ${results.length}`); +process.exit(passed === results.length ? 0 : 1); From da7671e22ccd18d6303e1322f3096403dd729a41 Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 28 Oct 2026 16:00:20 +0100 Subject: [PATCH 4/4] docs(docs): document opening recent clips --- docs/ARCHITECTURE.md | 1 + docs/SPEC.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e1e700..d54c2ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -341,6 +341,7 @@ d'une erreur système. | `estimate_replay { config }` | 5 ✅ | estimation du tampon pour une config non appliquée (RPL-03) : taille de la source, taille de sortie (jamais plus haute que la source), débit vidéo, nombre de pistes, octets (fenêtre du tampon + 7 %), `file_bytes_per_minute` (taille d'un fichier par minute, RPL-11 : débit vidéo + 192 kb/s par piste + 1 % de conteneur). Réglages alerte au-delà de 40 % de la RAM disponible et propose le tampon sur disque | | `list_clips { query }` | 6 ✅ | clips de la bibliothèque (index SQLite) : filtres `game`, `kind` (`replay` / `recording`, d'après le nom), `favorites`, `since_ms`, `search` (nom ou jeu), tri `newest` / `oldest` / `largest` / `longest` / `name`, `limit` ; seuls les clips du dossier courant (racine + un niveau). Chaque clip : `path`, `name`, `game`, `kind`, `modified_ms`, `size_bytes`, `duration_secs`, `fps` (cadence vidéo moyenne, pas d'image du lecteur), `tracks` (titres), `thumbnail`, `favorite` | | `clip_games` | 6 ✅ | jeux (sous-dossiers) présents dans la bibliothèque | +| `get_clip { path }` | v1.4.1 | un clip de la bibliothèque (même forme que `list_clips`) ; fichier disparu → `file_missing` et retrait de la bibliothèque (`library-changed { removed }`), chemin inconnu → `clip_unknown`. Sert à ouvrir le lecteur depuis « Derniers clips » | | `clip_mix { path }` / `set_clip_mix { path, mix }` | v1.4 | GAL-04 : état du lecteur par clip, `{ tracks: [{ volume, mute, solo }], full_mix_chosen }` (colonne `mix` de la bibliothèque, suit le renommage ; volume borné à 0–1) ; `null` = jamais réglé. `full_mix_chosen` = la piste 1 a été coupée ou réactivée à la main, sinon le réglage `mute_full_mix` s'applique par-dessus | | `set_clip_favorite { path, favorite }` / `rename_clip { path, name }` / `trash_clip { path }` / `reveal_clip { path }` | 6 ✅ | GAL-05 : favori ; renommer le fichier (mêmes règles de nom que les dossiers de jeux, miniature renommée, favori gardé) ; Corbeille (`SHFileOperationW` + `FOF_ALLOWUNDO`) ; Explorateur sur le fichier. Un chemin absent de la bibliothèque est refusé (`clip_unknown`) | | `quota_preview { quotaGb }` | 6 ✅ | STO-04 : taille totale et clips qui seraient supprimés (plus anciens, jamais un favori ni un enregistrement en cours) | diff --git a/docs/SPEC.md b/docs/SPEC.md index 658ae64..8c32cb3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -107,7 +107,7 @@ sans lui, encodage par le processeur (ENC-06, v1.1). |---|---| | GAL-01 | Grille de clips avec miniature, durée, jeu, date, taille, type (replay / enregistrement). | | GAL-02 | Filtres (jeu, type, favoris, période), tri, recherche par nom ; fluide avec 5 000 clips. | -| GAL-03 | Lecteur intégré avec déplacement fluide, y compris sur un fichier de 4 Go ; clic ou glisser sur la barre de temps **ou sur n'importe quelle piste audio** (v1.3.1), la tête de lecture se cale sur l'entrée et la sortie à 8 px près et suit l'entrée quand on la déplace. Pendant le glisser (v1.4), le curseur, le temps et la zone jouée suivent la souris à chaque image ; l'image est chargée quand la souris s'arrête (120 ms) et au relâchement, une seule demande à la fois (la dernière position gagne) ; l'audio se recale à la fin et la lecture reprend si elle tournait. | +| GAL-03 | Lecteur intégré avec déplacement fluide, y compris sur un fichier de 4 Go ; clic ou glisser sur la barre de temps **ou sur n'importe quelle piste audio** (v1.3.1), la tête de lecture se cale sur l'entrée et la sortie à 8 px près et suit l'entrée quand on la déplace. Pendant le glisser (v1.4), le curseur, le temps et la zone jouée suivent la souris à chaque image ; l'image est chargée quand la souris s'arrête (120 ms) et au relâchement, une seule demande à la fois (la dernière position gagne) ; l'audio se recale à la fin et la lecture reprend si elle tournait. v1.4.1 : un double-clic ou Entrée sur une carte de « Derniers clips » (État) ouvre ce clip dans le lecteur ; le fermer ramène à État ; fichier disparu → message sur État et carte retirée. | | GAL-04 | Dans le lecteur : **chaque piste audio** peut être coupée, isolée (solo) ou réglée en volume. v1.4 : réglage « Couper le Mix complet à la lecture » (Général, activé par défaut) : à l'ouverture d'un clip qui a une piste Jeu ou une piste d'appli, la piste 1 est coupée (jamais si le clip n'a que le mix, ou le mix et le micro) ; elle se réactive dans le lecteur. L'état muet/solo/volume est retenu par clip. L'export et la compression ne suivent pas cette coupure automatique : la piste 1 reste dans les fichiers sauf si on l'a coupée soi-même. | | GAL-05 | Renommer, favori, supprimer (vers la Corbeille), ouvrir l'emplacement, **copier le fichier** dans le presse-papiers (glisser dans Discord). | | GAL-06 | Découpe : export **rapide** sans réencodage (sur images clés) ou **précis** (réencodage matériel). |