Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src-tauri/src/clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ impl EngineSlot {
self.with_library(|library| library.games())
}

pub fn get_clip(&self, path: &str) -> Result<Clip, Localized> {
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<Option<ClipMix>, Localized> {
let path = self.known_clip(path)?;
let stored = self.with_library(|library| library.mix(&path))?;
Expand Down
7 changes: 6 additions & 1 deletion app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -333,6 +333,11 @@ pub async fn clip_games(slot: State<'_, EngineSlot>) -> Result<Vec<String>, Loca
slot.clip_games()
}

#[tauri::command]
pub async fn get_clip(slot: State<'_, EngineSlot>, path: String) -> Result<Clip, Localized> {
slot.get_clip(&path)
}

#[tauri::command]
pub async fn clip_mix(
slot: State<'_, EngineSlot>,
Expand Down
1 change: 1 addition & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 30 additions & 4 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -43,6 +46,8 @@ function Shell() {
const [view, setView] = useState<View>("status");
const [section, setSection] = useState<SectionId>("replay");
const [qualityFocus, setQualityFocus] = useState(0);
const [request, setRequest] = useState<ClipRequest | null>(null);
const [statusNotice, setStatusNotice] = useState<string | null>(null);
const engine = useEngine();
const [onboarding, setOnboarding] = useState<boolean | null>(null);

Expand All @@ -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);
Expand All @@ -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 (
Expand All @@ -97,6 +117,9 @@ function Shell() {
</p>
)}
<Alerts alerts={engine.status?.alerts ?? []} onOpen={open} />
<p role="status" className={statusNotice ? "border-b border-line bg-surface-2 px-5 py-3 text-[13px] text-ink" : "sr-only"}>
{statusNotice}
</p>
<div className="grid min-h-60 flex-1 grid-cols-[minmax(0,1fr)_minmax(260px,320px)] grid-rows-[minmax(0,1fr)] max-[1000px]:min-h-0 max-[1000px]:flex-none max-[1000px]:grid-cols-1 max-[1000px]:grid-rows-none">
<div className="flex min-w-0 flex-col overflow-y-auto border-r border-line max-[1000px]:overflow-visible max-[1000px]:border-r-0">
<Roster
Expand All @@ -113,6 +136,7 @@ function Shell() {
labels={engine.config?.audio ?? null}
freshClip={engine.freshClip}
saveKeys={engine.saveKeys}
onOpen={openClip}
/>
</div>
</>
Expand All @@ -121,6 +145,8 @@ function Shell() {
<Gallery
config={engine.config}
saveKeys={engine.saveKeys}
clipToOpen={request}
onReturn={(next) => open({ view: next })}
onCompress={(requests: DraftRequest[]) => {
addDrafts(requests);
open({ view: "compress" });
Expand Down
40 changes: 36 additions & 4 deletions app/src/components/RecentClips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
<li
data-fresh={fresh}
className="clip-card overflow-hidden rounded-md border border-line bg-surface"
className={`clip-card relative overflow-hidden rounded-md border border-line bg-surface ${
onOpen ? "hover:border-line-strong hover:bg-surface-hover" : ""
}`}
>
<div className="relative aspect-video bg-meter-ground">
{clip.thumbnail ? (
Expand Down Expand Up @@ -53,6 +58,25 @@ export function ClipCard({
))}
</ul>
</div>
{onOpen && (
<button
type="button"
data-open
aria-label={t("clips.card", {
game: clip.game || t("clips.fallbackName"),
date: `${dayLabel(clip.modified_ms)} ${timeOfDay(clip.modified_ms)}`,
duration: clock(clip.duration_secs),
tracks: t("clips.tracks", { count: clip.tracks.length, names: clip.tracks.join(", ") }),
})}
aria-keyshortcuts="Enter"
title={t("clips.openHint")}
onClick={(event) => {
if (event.detail === 0) onOpen();
}}
onDoubleClick={onOpen}
className="absolute inset-0 rounded-md"
/>
)}
</li>
);
}
Expand All @@ -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 (
<section
aria-labelledby="clips-title"
tabIndex={0}
className="flex min-w-0 flex-col gap-3 overflow-y-auto px-5 pt-5 pb-6 -outline-offset-2 max-[1000px]:overflow-visible"
className="flex min-w-0 flex-col gap-3 overflow-y-auto px-5 pt-5 pb-6 max-[1000px]:overflow-visible"
>
<h2 id="clips-title" className="text-[17px] font-semibold">
{t("clips.title")}
Expand All @@ -99,7 +124,14 @@ export function RecentClips({
) : (
<ul className="grid grid-cols-1 gap-3 max-[1000px]:grid-cols-[repeat(auto-fill,minmax(220px,1fr))]">
{clips.map((clip) => (
<ClipCard key={clip.path} clip={clip} appOrder={appOrder} labels={labels} fresh={clip.path === freshClip} />
<ClipCard
key={clip.path}
clip={clip}
appOrder={appOrder}
labels={labels}
fresh={clip.path === freshClip}
onOpen={() => onOpen(clip.path)}
/>
))}
</ul>
)}
Expand Down
34 changes: 33 additions & 1 deletion app/src/components/gallery/Gallery.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -82,6 +89,8 @@ export function Gallery({
const [renaming, setRenaming] = useState<string | null>(null);
const [renameError, setRenameError] = useState<string | null>(null);
const [open, setOpen] = useState<Clip | null>(null);
const [opening, setOpening] = useState(clipToOpen !== null);
const [returnTo, setReturnTo] = useState<View>("gallery");
const scroller = useRef<HTMLDivElement>(null);
const request = useRef(0);
const trashQueue = useRef<PendingTrash | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -233,6 +259,10 @@ export function Gallery({
appOrder={appOrder}
onBack={() => {
setOpen(null);
if (returnTo !== "gallery" && onReturn) {
onReturn(returnTo);
return;
}
load();
setRestoreFocus((current) => current + 1);
}}
Expand All @@ -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";

Expand Down
21 changes: 21 additions & 0 deletions app/src/lib/destination.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions app/src/lib/destination.ts
Original file line number Diff line number Diff line change
@@ -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";
}
4 changes: 4 additions & 0 deletions app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ export function clipGames(): Promise<string[]> {
return invoke<string[]>("clip_games");
}

export function getClip(path: string): Promise<Clip> {
return invoke<Clip>("get_clip", { path });
}

export interface ClipMix {
tracks: { volume: number; mute: boolean; solo: boolean }[];
full_mix_chosen: boolean;
Expand Down
2 changes: 2 additions & 0 deletions app/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions app/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading