diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc20abc..ff2d3e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versions
## [Non publié]
+## [1.4.1] - 2026-10-28
+
+### Corrigé
+- État : on peut ouvrir un clip depuis « Derniers clips ». Double-clic ou Entrée sur une carte
+ (cartes focusables, focus visible, nom lu par un lecteur d'écran) ouvre le clip dans le lecteur
+ de la Galerie ; le fermer ramène à État. Si le fichier a disparu entre-temps, un message
+ s'affiche sur État et la carte est retirée (GAL-03).
+
## [1.4.0] - 2026-10-28
### Ajouté
diff --git a/Cargo.lock b/Cargo.lock
index 2d656f9..7708005 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3012,7 +3012,7 @@ dependencies = [
[[package]]
name = "palaclips-app"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"palaclips-engine",
"serde",
@@ -3033,7 +3033,7 @@ dependencies = [
[[package]]
name = "palaclips-cli"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"anyhow",
"clap",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "palaclips-engine"
-version = "1.4.0"
+version = "1.4.1"
dependencies = [
"crossbeam-channel",
"notify",
diff --git a/Cargo.toml b/Cargo.toml
index 218fb42..3afc762 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ default-members = ["crates/engine", "crates/cli"]
exclude = ["third_party"]
[workspace.package]
-version = "1.4.0"
+version = "1.4.1"
edition = "2024"
rust-version = "1.90"
license = "MIT"
diff --git a/app/package-lock.json b/app/package-lock.json
index 7dae4d2..bdca72e 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "palaclips-ui",
- "version": "1.4.0",
+ "version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "palaclips-ui",
- "version": "1.4.0",
+ "version": "1.4.1",
"dependencies": {
"@fontsource/barlow": "5.3.0",
"@fontsource/barlow-condensed": "5.3.0",
diff --git a/app/package.json b/app/package.json
index a976b19..e158fbf 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "palaclips-ui",
"private": true,
- "version": "1.4.0",
+ "version": "1.4.1",
"type": "module",
"scripts": {
"dev": "vite",
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
)}
+
+ {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"] {
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