From 30484c21e3d0e2d67537efd948c974b0dbdbe6d5 Mon Sep 17 00:00:00 2001
From: Saurabh Kumar Bajpai <157192462+saurabhhhcodes@users.noreply.github.com>
Date: Sun, 31 May 2026 02:49:16 +0530
Subject: [PATCH] feat: add session export history
---
src/components/DownloadResult.tsx | 63 +++++++++++++++++++++++++++++--
src/components/VideoEditor.tsx | 10 ++++-
src/hooks/useVideoEditor.ts | 44 +++++++++++++++++++--
src/lib/types.ts | 7 ++++
4 files changed, 116 insertions(+), 8 deletions(-)
diff --git a/src/components/DownloadResult.tsx b/src/components/DownloadResult.tsx
index 9cb04286..e5e49845 100644
--- a/src/components/DownloadResult.tsx
+++ b/src/components/DownloadResult.tsx
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
-import { ExportResult } from "@/lib/types";
+import { ExportHistoryItem, ExportResult } from "@/lib/types";
import { formatBytes } from "@/lib/utils";
-import { Download, RotateCcw, Share2, AlertCircle, Volume2, VolumeX } from "lucide-react";
+import { Clock3, Download, RotateCcw, Share2, AlertCircle, Volume2, VolumeX } from "lucide-react";
import LottiePlayer from "./LottiePlayer";
import { NativeShareButton } from "./NativeShareButton";
import successAnim from "@/lib/lottie/success.json";
@@ -24,12 +24,22 @@ function formatExportDuration(ms: number): string {
interface Props {
result: ExportResult;
+ exportHistory: ExportHistoryItem[];
onReset: () => void;
soundOnCompletion: boolean;
onToggleSound: () => void;
}
-export default function DownloadResult({ result, onReset, soundOnCompletion, onToggleSound }: Props) {
+function formatExportedAt(timestamp: number): string {
+ return new Date(timestamp).toLocaleString(undefined, {
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export default function DownloadResult({ result, exportHistory, onReset, soundOnCompletion, onToggleSound }: Props) {
const defaultName = `reframe_${result.width}x${result.height}`;
const [name, setName] = useState(defaultName);
@@ -186,6 +196,53 @@ export default function DownloadResult({ result, onReset, soundOnCompletion, onT
Share on X
+
+ {exportHistory.length > 0 && (
+
+
+
+
+ Recent exports
+
+
+
+
+ {exportHistory.map((item) => (
+ -
+
+
+ {item.filename}
+
+
+ {formatBytes(item.result.size)}
+ ·
+ {item.result.format.toUpperCase()}
+ ·
+
+
+
+
+
+
+ Download
+
+
+ ))}
+
+
+ )}
);
}
diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index b1d2e574..849ec21a 100644
--- a/src/components/VideoEditor.tsx
+++ b/src/components/VideoEditor.tsx
@@ -199,7 +199,7 @@ function KeyboardShortcutsPanel() {
export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
- result, error, exportStartedAt, updateRecipe,
+ result, exportHistory, error, exportStartedAt, updateRecipe,
handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
videoRef,
seekTo,
@@ -681,7 +681,13 @@ export default function VideoEditor() {
{status === "done" && result && (
-
+
)}
diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts
index b3ea8283..461e734d 100644
--- a/src/hooks/useVideoEditor.ts
+++ b/src/hooks/useVideoEditor.ts
@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
-import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types";
+import { EditRecipe, ExportHistoryItem, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types";
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
@@ -9,7 +9,12 @@ import { suggestPreset } from "@/lib/presetSuggestion";
import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation";
const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser";
- const STORAGE_KEY = "reframe:recipe";
+const STORAGE_KEY = "reframe:recipe";
+const MAX_EXPORT_HISTORY = 5;
+
+function getExportFilename(result: ExportResult): string {
+ return `reframe_${result.width}x${result.height}.${result.format}`;
+}
export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve, reject) => {
@@ -171,11 +176,13 @@ export function useVideoEditor() {
const [status, setStatus] = useState("idle");
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
+ const [exportHistory, setExportHistory] = useState([]);
const [error, setError] = useState(null);
const [fileError, setFileError] = useState("");
const [exportStartedAt, setExportStartedAt] = useState(null);
const exportAbortControllerRef = useRef(null);
const exportCancelledRef = useRef(false);
+ const exportHistoryRef = useRef([]);
const videoRef = useRef(null);
const [musicFile, setMusicFile] = useState(null);
@@ -498,9 +505,32 @@ export function useVideoEditor() {
);
if (exportCancelledRef.current) return;
- setResult({
+ const completedResult = {
...exportResult,
exportDurationMs: Date.now() - startedAt,
+ };
+
+ setResult(completedResult);
+ setExportHistory((previous) => {
+ const historyResult = {
+ ...completedResult,
+ blobUrl: URL.createObjectURL(completedResult.blob),
+ };
+ const next = [
+ {
+ id: `${Date.now()}-${completedResult.width}x${completedResult.height}-${completedResult.format}`,
+ result: historyResult,
+ filename: getExportFilename(completedResult),
+ createdAt: Date.now(),
+ },
+ ...previous,
+ ];
+
+ next.slice(MAX_EXPORT_HISTORY).forEach((item) => {
+ URL.revokeObjectURL(item.result.blobUrl);
+ });
+
+ return next.slice(0, MAX_EXPORT_HISTORY);
});
setStatus("done");
} catch (err) {
@@ -573,6 +603,10 @@ export function useVideoEditor() {
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [status]);
+
+ useEffect(() => {
+ exportHistoryRef.current = exportHistory;
+ }, [exportHistory]);
useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
@@ -628,6 +662,9 @@ export function useVideoEditor() {
useEffect(() => {
return () => {
+ exportHistoryRef.current.forEach((item) => {
+ URL.revokeObjectURL(item.result.blobUrl);
+ });
terminateFFmpeg();
};
}, []);
@@ -700,6 +737,7 @@ export function useVideoEditor() {
progress,
exportStartedAt,
result,
+ exportHistory,
error,
videoRef,
seekTo,
diff --git a/src/lib/types.ts b/src/lib/types.ts
index deb816b8..5593edd1 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -68,6 +68,13 @@ export interface ExportResult {
exportDurationMs?: number;
}
+export interface ExportHistoryItem {
+ id: string;
+ result: ExportResult;
+ filename: string;
+ createdAt: number;
+}
+
export type ExportStatus =
| "idle"
| "loading-engine"