diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:02 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:02 +0200 |
| commit | 2bbada7d2c5c00c5f844b6916c08bc4a9ea8cb25 (patch) | |
| tree | 2fdd617e9d51f7293e56b0e9b947f00643aeb8cd /apps | |
| parent | f8fdff7f0c1b8efdef9093c932e28e427799d6ef (diff) | |
| download | opencut-2bbada7d2c5c00c5f844b6916c08bc4a9ea8cb25.tar.gz | |
feat: freeze-duration prompt
Freeze frame now asks for the duration (decimal seconds, 0 < d <= 600)
before capture, via a promise-based zustand store mounted as an
editor-global dialog. Last-used duration persists and prefills. Cancel
aborts before capture with no commands.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/actions/__tests__/freeze-frame-store.test.ts | 98 | ||||
| -rw-r--r-- | apps/web/src/actions/components/freeze-frame-dialog.tsx | 109 | ||||
| -rw-r--r-- | apps/web/src/actions/freeze-frame-store.ts | 72 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 2 |
4 files changed, 281 insertions, 0 deletions
diff --git a/apps/web/src/actions/__tests__/freeze-frame-store.test.ts b/apps/web/src/actions/__tests__/freeze-frame-store.test.ts new file mode 100644 index 00000000..94e1566e --- /dev/null +++ b/apps/web/src/actions/__tests__/freeze-frame-store.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + DEFAULT_FREEZE_DURATION_SECONDS, + isValidFreezeDuration, + MAX_FREEZE_DURATION_SECONDS, + useFreezeFrameStore, +} from "../freeze-frame-store"; + +function resetStore() { + useFreezeFrameStore.setState({ + isOpen: false, + lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS, + resolver: null, + }); +} + +describe("freeze-frame store", () => { + beforeEach(() => { + resetStore(); + }); + + describe("isValidFreezeDuration", () => { + test("accepts positive durations up to the max, including decimals", () => { + expect(isValidFreezeDuration({ seconds: 3 })).toBe(true); + expect(isValidFreezeDuration({ seconds: 2.5 })).toBe(true); + expect(isValidFreezeDuration({ seconds: 0.1 })).toBe(true); + expect( + isValidFreezeDuration({ seconds: MAX_FREEZE_DURATION_SECONDS }), + ).toBe(true); + }); + + test("rejects non-positive, over-max, and non-finite durations", () => { + expect(isValidFreezeDuration({ seconds: 0 })).toBe(false); + expect(isValidFreezeDuration({ seconds: -1 })).toBe(false); + expect(isValidFreezeDuration({ seconds: 600.1 })).toBe(false); + expect(isValidFreezeDuration({ seconds: Number.NaN })).toBe(false); + expect(isValidFreezeDuration({ seconds: Number.POSITIVE_INFINITY })).toBe( + false, + ); + }); + }); + + describe("requestDuration", () => { + test("opens the dialog and resolves with the confirmed seconds", async () => { + const promise = useFreezeFrameStore.getState().requestDuration(); + expect(useFreezeFrameStore.getState().isOpen).toBe(true); + + useFreezeFrameStore.getState().confirmDuration({ seconds: 5 }); + + await expect(promise).resolves.toBe(5); + expect(useFreezeFrameStore.getState().isOpen).toBe(false); + }); + + test("remembers the confirmed duration as the last-used value", async () => { + const promise = useFreezeFrameStore.getState().requestDuration(); + useFreezeFrameStore.getState().confirmDuration({ seconds: 5 }); + await promise; + + expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(5); + }); + + test("resolves null on cancel and keeps the last-used duration", async () => { + useFreezeFrameStore.setState({ lastDurationSeconds: 7 }); + + const promise = useFreezeFrameStore.getState().requestDuration(); + expect(useFreezeFrameStore.getState().isOpen).toBe(true); + + useFreezeFrameStore.getState().cancelDuration(); + + await expect(promise).resolves.toBeNull(); + expect(useFreezeFrameStore.getState().isOpen).toBe(false); + expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(7); + }); + + test("ignores an invalid confirm and leaves the dialog open", () => { + useFreezeFrameStore.getState().requestDuration(); + + useFreezeFrameStore.getState().confirmDuration({ seconds: 0 }); + useFreezeFrameStore.getState().confirmDuration({ seconds: 601 }); + + expect(useFreezeFrameStore.getState().isOpen).toBe(true); + expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe( + DEFAULT_FREEZE_DURATION_SECONDS, + ); + }); + + test("a new request supersedes a pending one by cancelling it", async () => { + const first = useFreezeFrameStore.getState().requestDuration(); + const second = useFreezeFrameStore.getState().requestDuration(); + + await expect(first).resolves.toBeNull(); + expect(useFreezeFrameStore.getState().isOpen).toBe(true); + + useFreezeFrameStore.getState().confirmDuration({ seconds: 2 }); + await expect(second).resolves.toBe(2); + }); + }); +}); diff --git a/apps/web/src/actions/components/freeze-frame-dialog.tsx b/apps/web/src/actions/components/freeze-frame-dialog.tsx new file mode 100644 index 00000000..a6a7e0ae --- /dev/null +++ b/apps/web/src/actions/components/freeze-frame-dialog.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + isValidFreezeDuration, + MAX_FREEZE_DURATION_SECONDS, + useFreezeFrameStore, +} from "@/actions/freeze-frame-store"; + +export function FreezeFrameDialog() { + const isOpen = useFreezeFrameStore((state) => state.isOpen); + const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration); + + return ( + <Dialog + open={isOpen} + onOpenChange={(open) => { + if (!open) { + cancelDuration(); + } + }} + > + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Freeze frame</DialogTitle> + <DialogDescription> + How long should the frozen frame stay on screen? + </DialogDescription> + </DialogHeader> + {isOpen && <FreezeFrameForm />} + </DialogContent> + </Dialog> + ); +} + +function FreezeFrameForm() { + const lastDurationSeconds = useFreezeFrameStore( + (state) => state.lastDurationSeconds, + ); + const confirmDuration = useFreezeFrameStore((state) => state.confirmDuration); + const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration); + const [value, setValue] = useState(String(lastDurationSeconds)); + + const seconds = Number(value); + const showError = value.trim() !== "" && !isValidFreezeDuration({ seconds }); + const canConfirm = isValidFreezeDuration({ seconds }); + + const handleConfirm = () => { + if (canConfirm) { + confirmDuration({ seconds }); + } + }; + + return ( + <> + <DialogBody className="gap-3"> + <Label htmlFor="freeze-frame-duration">Duration (seconds)</Label> + <Input + id="freeze-frame-duration" + value={value} + inputMode="decimal" + aria-invalid={showError} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleConfirm(); + } + }} + placeholder="e.g. 2.5" + /> + {showError && ( + <p className="text-destructive text-xs"> + Enter a duration greater than 0 and at most{" "} + {MAX_FREEZE_DURATION_SECONDS} seconds. + </p> + )} + </DialogBody> + + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + cancelDuration(); + }} + > + Cancel + </Button> + <Button onClick={handleConfirm} disabled={!canConfirm}> + Freeze + </Button> + </DialogFooter> + </> + ); +} diff --git a/apps/web/src/actions/freeze-frame-store.ts b/apps/web/src/actions/freeze-frame-store.ts new file mode 100644 index 00000000..17102780 --- /dev/null +++ b/apps/web/src/actions/freeze-frame-store.ts @@ -0,0 +1,72 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const DEFAULT_FREEZE_DURATION_SECONDS = 3; +export const MAX_FREEZE_DURATION_SECONDS = 600; + +export function isValidFreezeDuration({ + seconds, +}: { + seconds: number; +}): boolean { + return ( + Number.isFinite(seconds) && + seconds > 0 && + seconds <= MAX_FREEZE_DURATION_SECONDS + ); +} + +type DurationResolver = ({ seconds }: { seconds: number | null }) => void; + +interface FreezeFrameState { + isOpen: boolean; + lastDurationSeconds: number; + resolver: DurationResolver | null; + requestDuration: () => Promise<number | null>; + confirmDuration: ({ seconds }: { seconds: number }) => void; + cancelDuration: () => void; +} + +export const useFreezeFrameStore = create<FreezeFrameState>()( + persist( + (set, get) => ({ + isOpen: false, + lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS, + resolver: null, + + requestDuration: () => + new Promise<number | null>((resolve) => { + // A new invocation supersedes any pending one: resolve it as + // cancelled so its handler aborts instead of hanging. + get().resolver?.({ seconds: null }); + set({ + isOpen: true, + resolver: ({ seconds }) => resolve(seconds), + }); + }), + confirmDuration: ({ seconds }) => { + if (!isValidFreezeDuration({ seconds })) { + return; + } + get().resolver?.({ seconds }); + set({ + isOpen: false, + resolver: null, + lastDurationSeconds: seconds, + }); + }, + cancelDuration: () => { + get().resolver?.({ seconds: null }); + set({ isOpen: false, resolver: null }); + }, + }), + { + name: "freeze-frame-store", + partialize: (state) => ({ + lastDurationSeconds: state.lastDurationSeconds, + }), + }, + ), +); diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 680dfff4..b3f1a933 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -14,6 +14,7 @@ import { EditorHeader } from "@/components/editor/editor-header"; import { EditorProvider } from "@/components/providers/editor-provider"; import { Onboarding } from "@/components/editor/onboarding"; import { MigrationDialog } from "@/project/components/migration-dialog"; +import { FreezeFrameDialog } from "@/actions/components/freeze-frame-dialog"; import { usePanelStore } from "@/editor/panel-store"; import { usePasteMedia } from "@/media/use-paste-media"; import { MobileGate } from "@/components/editor/mobile-gate"; @@ -50,6 +51,7 @@ export default function Editor() { </div> <Onboarding /> <MigrationDialog /> + <FreezeFrameDialog /> <ChangelogNotification /> </div> </EditorProvider> |
