Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 46e773698d4102c284d30369
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Recorder.tsx
// The recorder in the landing hero: pick what to record, record it, then watch, trim and download it.
// Recordings are made and kept on this device; nothing is sent anywhere.
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { CAN_RECORD, CAN_SCREEN, CaptureProblem, FORMATS, camera, open, record, type Corner, type Format, type Mode, type Sources, type Take } from "./lib/capture";
import type * as Media from "./lib/media";
import { allRecordings, chunksOf, deleteRecording, getFile, keepStored, lockName, putRecording, saveRecording, type Recording } from "./lib/store";
import { clock, save, size, toast } from "./ui";
let mediaLoad: Promise<typeof Media> | null = null;
function media() {
mediaLoad ??= import("./lib/media").catch((err) => {
mediaLoad = null;
throw err;
});
return mediaLoad;
}
type Settings = { mode: Mode; camera: boolean; mic: boolean; screenAudio: boolean; corner: Corner; format: Format; micId: string; camId: string };
const DEFAULTS: Settings = { mode: CAN_SCREEN ? "screen" : "camera", camera: false, mic: true, screenAudio: false, corner: "br", format: FORMATS[0] || "webm", micId: "", camId: "" };
const SETTINGS_KEY = "record:settings";
function loadSettings(): Settings {
try {
const s: Settings = { ...DEFAULTS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
if (s.mode === "screen" && !CAN_SCREEN) s.mode = "camera";
if (!FORMATS.includes(s.format)) s.format = DEFAULTS.format;
return s;
} catch {
return DEFAULTS;
}
}
type Phase =
| { kind: "setup" }
| { kind: "countdown"; left: number }
| { kind: "recording"; paused: boolean }
| { kind: "saving"; progress: number }
| { kind: "review"; rec: Recording; file: Blob };
const MODE_LABEL: Record<Mode, string> = { screen: "Screen", camera: "Camera", audio: "Audio" };
const KIND_NAME: Record<Mode, string> = { screen: "Screen recording", camera: "Camera recording", audio: "Audio recording" };
function defaultName(mode: Mode) {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${KIND_NAME[mode]} ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}.${p(d.getMinutes())}`;
}
function fileName(rec: Recording) {
const ext = rec.type.includes("mp4") ? (rec.type.startsWith("audio") ? "m4a" : "mp4") : "webm";
return `${rec.name.replace(/[\\/:*?"<>|]+/g, "-").trim() || "recording"}.${ext}`;
}
const formatName = (type: string) => (type.includes("mp4") ? "MP4" : "WebM");
const stopAll = (s: MediaStream) => s.getTracks().forEach((t) => t.stop());
/** Rebuilds a cut-off recording from its saved pieces and returns its name, or drops it when nothing readable was saved. */
async function recover(id: string): Promise<string | null> {
const rec = (await allRecordings()).find((r) => r.id === id);
if (!rec?.partial) return null;
const pieces = await chunksOf(id);
let made: { blob: Blob; duration: number };
try {
made = await (await media()).remake(new Blob(pieces, { type: rec.type }), null, () => {});
if (!made.duration) throw new Error("no media");
} catch {
await deleteRecording(id);
return null;
}
await saveRecording({ ...rec, duration: made.duration, size: made.blob.size, partial: false }, made.blob);
return rec.name;
}
export function Recorder() {
const [settings, setSettings] = useState(loadSettings);
const [phase, setPhase] = useState<Phase>({ kind: "setup" });
const [problem, setProblem] = useState<string | null>(null);
const [list, setList] = useState<Recording[]>([]);
const [cam, setCam] = useState<MediaStream | null>(null);
// The camera preview starts only after the person asks for it on this visit, never by itself on load.
const [camWanted, setCamWanted] = useState(false);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [starting, setStarting] = useState(false);
const sources = useRef<Sources | null>(null);
const current = useRef<{ take: Take; rec: Recording } | null>(null);
const corner = useRef(settings.corner);
corner.current = settings.corner;
const update = (patch: Partial<Settings>) => setSettings((s) => ({ ...s, ...patch }));
useEffect(() => {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
// Private windows may refuse storage; settings then last for this visit only.
}
}, [settings]);
const refresh = useCallback(async () => {
const all = await allRecordings().catch(() => [] as Recording[]);
setList(all.filter((r) => !r.partial).sort((a, b) => b.created - a.created));
}, []);
const listDevices = useCallback(() => {
void navigator.mediaDevices?.enumerateDevices().then((d) => setDevices(d.filter((x) => x.label && x.deviceId)), () => {});
}, []);
// Put back recordings that were cut off by a closed or crashed tab, then show the list. Each one waits
// for its lock, which a tab holds while it records: free at once if that tab is gone, or once another
// open tab finishes the recording (and then there is nothing to put back).
useEffect(() => {
void refresh();
if (navigator.locks) {
void allRecordings().then((all) => {
for (const r of all.filter((r) => r.partial)) {
void navigator.locks.request(lockName(r.id), async () => {
const name = await recover(r.id).catch(() => null);
if (name) toast(`Recovered "${name}", which was cut off.`);
void refresh();
});
}
}, () => {});
}
listDevices();
navigator.mediaDevices?.addEventListener("devicechange", listDevices);
return () => navigator.mediaDevices?.removeEventListener("devicechange", listDevices);
}, [refresh, listDevices]);
const needsCam = settings.mode === "camera" || (settings.mode === "screen" && settings.camera);
useEffect(() => {
if (phase.kind !== "setup" || !needsCam || !camWanted) return;
let stream: MediaStream | null = null;
let gone = false;
camera(settings.camId).then(
(s) => {
if (gone) return stopAll(s);
stream = s;
setCam(s);
listDevices();
},
(err: Error) => {
if (gone) return;
setProblem(err.message);
setCamWanted(false);
},
);
return () => {
gone = true;
if (stream) stopAll(stream);
setCam(null);
};
}, [phase.kind, needsCam, camWanted, settings.camId, listDevices]);
useEffect(() => {
const working = phase.kind !== "setup" && phase.kind !== "review";
document.body.classList.toggle("app-mode", phase.kind !== "setup");
if (phase.kind !== "setup") window.scrollTo(0, 0);
if (!working) return;
const warn = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [phase.kind]);
const finish = async (raw: Blob) => {
const now = current.current;
current.current = null;
sources.current?.release();
sources.current = null;
if (!now) return;
const { rec, take } = now;
if (!raw.size) {
void deleteRecording(rec.id).catch(() => {});
setProblem("Nothing was recorded. Try again.");
setPhase({ kind: "setup" });
return;
}
setPhase({ kind: "saving", progress: 0 });
let file = raw;
let duration = take.elapsed();
try {
({ blob: file, duration } = await (await media()).remake(raw, null, (progress) => setPhase({ kind: "saving", progress })));
} catch {
// Keep the recorder's own file: it plays, though some players may not show its length.
}
const done: Recording = { ...rec, duration, size: file.size, partial: false };
try {
await saveRecording(done, file);
keepStored();
// The stored copy lives on disk; the one just made may be held in memory.
file = (await getFile(done.id)) ?? file;
} catch {
setProblem("This browser could not keep the recording on this device. Download it before you leave this page.");
}
setPhase({ kind: "review", rec: done, file });
void refresh();
};
const cancel = () => {
sources.current?.release();
sources.current = null;
setPhase({ kind: "setup" });
};
const start = async () => {
if (starting) return;
setProblem(null);
setStarting(true);
// Phones may not let the same camera be opened twice, so the preview lets go of it first.
if (settings.mode === "camera" && cam) {
stopAll(cam);
setCam(null);
}
let src: Sources;
try {
src = await open({ ...settings, corner: () => corner.current });
} catch (err) {
setProblem(err instanceof CaptureProblem ? err.message : "Recording could not start in this browser.");
return;
} finally {
setStarting(false);
}
sources.current = src;
src.onEnded(() => (current.current ? current.current.take.stop() : cancel()));
if (src.noScreenSound) toast("No screen sound was shared. In Chrome or Edge, turn on the audio switch in the picker to include it.");
setPhase({ kind: "countdown", left: 3 });
};
const begin = () => {
const src = sources.current;
if (!src) return;
const id = crypto.randomUUID();
const take = record(src.stream, settings.mode, settings.format, id);
const rec: Recording = { id, name: defaultName(settings.mode), created: Date.now(), mode: settings.mode, type: take.type, duration: 0, size: 0, partial: true };
// The entry recovery uses if this tab closes before the recording is saved.
void putRecording(rec).catch(() => {});
current.current = { take, rec };
void take.done.then(finish);
setPhase({ kind: "recording", paused: false });
};
useEffect(() => {
if (phase.kind !== "countdown") return;
if (phase.left === 0) return begin();
const t = window.setTimeout(() => setPhase({ kind: "countdown", left: phase.left - 1 }), 1000);
return () => window.clearTimeout(t);
// begin reads refs, and settings cannot change during the countdown.
}, [phase]);
const openRec = async (rec: Recording) => {
const file = await getFile(rec.id).catch(() => undefined);
if (!file) return toast("That recording could not be opened.");
setProblem(null);
setPhase({ kind: "review", rec, file });
};
const download = async (rec: Recording) => {
const file = await getFile(rec.id).catch(() => undefined);
if (file) save(file, fileName(rec));
else toast("That recording could not be opened.");
};
const remove = async (rec: Recording) => {
await deleteRecording(rec.id).catch(() => {});
if (phase.kind === "review" && phase.rec.id === rec.id) setPhase({ kind: "setup" });
toast(`Deleted "${rec.name}".`);
void refresh();
};
const library = document.getElementById("library");
const showLibrary = library && list.length > 0 && (phase.kind === "setup" || phase.kind === "review");
if (!CAN_RECORD) {
return (
<div className="card recorder">
<p className="notice">This browser cannot record. Open Record in a recent Chrome, Edge, Firefox or Safari.</p>
</div>
);
}
return (
<>
{phase.kind === "setup" && (
<Setup
settings={settings}
update={update}
cam={cam}
askCamera={() => { setProblem(null); setCamWanted(true); }}
devices={devices}
problem={problem}
starting={starting}
onStart={start}
/>
)}
{(phase.kind === "countdown" || phase.kind === "recording") && (
<Live
phase={phase}
mode={settings.mode}
bubble={settings.mode === "screen" && settings.camera}
corner={settings.corner}
setCorner={(c) => update({ corner: c })}
sources={sources.current}
take={current.current?.take ?? null}
onSkip={() => setPhase({ kind: "countdown", left: 0 })}
onCancel={cancel}
onPause={() => { current.current?.take.pause(); setPhase({ kind: "recording", paused: true }); }}
onResume={() => { current.current?.take.resume(); setPhase({ kind: "recording", paused: false }); }}
onStop={() => current.current?.take.stop()}
/>
)}
{phase.kind === "saving" && (
<div className="card live">
<div className="live-saving">
<span className="spinner" aria-hidden="true" />
<p className="live-title">Saving your recording</p>
<span className="bar" aria-hidden="true"><span style={{ width: `${Math.round(phase.progress * 100)}%` }} /></span>
</div>
</div>
)}
{phase.kind === "review" && (
<Review
key={phase.rec.id}
rec={phase.rec}
file={phase.file}
problem={problem}
onBack={() => { setProblem(null); setPhase({ kind: "setup" }); }}
onChange={(rec) => { setPhase({ kind: "review", rec, file: phase.file }); void refresh(); }}
onCopy={(rec, file) => { setPhase({ kind: "review", rec, file }); void refresh(); }}
onDelete={() => remove(phase.rec)}
/>
)}
{showLibrary &&
createPortal(
<Library list={list} openId={phase.kind === "review" ? phase.rec.id : null} onOpen={openRec} onDownload={download} onDelete={remove} />,
library,
)}
</>
);
}
/* Setup: what to record, and how. */
function Setup(props: {
settings: Settings;
update: (patch: Partial<Settings>) => void;
cam: MediaStream | null;
askCamera: () => void;
devices: MediaDeviceInfo[];
problem: string | null;
starting: boolean;
onStart: () => void;
}) {
const { settings, update, cam, devices, problem } = props;
const modes: Mode[] = CAN_SCREEN ? ["screen", "camera", "audio"] : ["camera", "audio"];
const mics = devices.filter((d) => d.kind === "audioinput");
const cams = devices.filter((d) => d.kind === "videoinput");
const usesMic = settings.mode === "audio" || settings.mic;
const usesCam = settings.mode === "camera" || (settings.mode === "screen" && settings.camera);
const choose = (mode: Mode) => {
update({ mode });
if (mode === "camera") props.askCamera();
};
return (
<div className="card recorder">
<div className="segmented modes" role="radiogroup" aria-label="What to record">
{modes.map((m) => (
<button key={m} type="button" role="radio" aria-checked={settings.mode === m} onClick={() => choose(m)}>
<ModeIcon mode={m} />
{MODE_LABEL[m]}
</button>
))}
</div>
<Stage settings={settings} cam={cam} onCorner={(c) => update({ corner: c })} onAskCamera={props.askCamera} />
<div className="options">
{settings.mode === "screen" && (
<Switch
label="Camera bubble"
hint="Your camera in a corner of the video"
checked={settings.camera}
onChange={(on) => {
update({ camera: on });
if (on) props.askCamera();
}}
/>
)}
{settings.mode !== "audio" && <Switch label="Microphone" hint="Your voice" checked={settings.mic} onChange={(on) => update({ mic: on })} />}
{settings.mode === "screen" && (
<Switch label="Sound from the screen" hint="A tab's or your computer's sound, where the browser allows it" checked={settings.screenAudio} onChange={(on) => update({ screenAudio: on })} />
)}
{usesCam && cams.length > 1 && (
<label className="field">
<span>Camera</span>
<select className="select" value={settings.camId} onChange={(e) => update({ camId: e.target.value })}>
<option value="">Default camera</option>
{cams.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
</select>
</label>
)}
{usesMic && mics.length > 1 && (
<label className="field">
<span>Microphone</span>
<select className="select" value={settings.micId} onChange={(e) => update({ micId: e.target.value })}>
<option value="">Default microphone</option>
{mics.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
</select>
</label>
)}
{FORMATS.length > 1 && (
<div className="opt">
<span className="opt-text">
<span className="opt-label">Save as</span>
<span className="opt-hint">{settings.format === "mp4" ? "MP4 plays almost everywhere" : "WebM is open and smaller"}</span>
</span>
<div className="segmented segmented-sm" role="radiogroup" aria-label="File type">
{FORMATS.map((f) => (
<button key={f} type="button" role="radio" aria-checked={settings.format === f} onClick={() => update({ format: f })}>
{f === "mp4" ? "MP4" : "WebM"}
</button>
))}
</div>
</div>
)}
</div>
{problem && <p className="notice notice-error" role="alert">{problem}</p>}
<button className="btn btn-lg btn-block start" type="button" onClick={props.onStart} disabled={props.starting}>
<span className="rec-dot" aria-hidden="true" />
Start recording
</button>
<p className="hint center">
No time limit. {FORMATS.length === 1 ? `This browser saves ${FORMATS[0] === "mp4" ? "MP4" : "WebM"}. ` : ""}Your recordings stay on this device.
</p>
{!CAN_SCREEN && (
<p className="notice small">
Phone browsers cannot record the screen, so Record offers your camera and your voice here. To record your phone's screen, use its built-in screen recorder.
</p>
)}
</div>
);
}
function Stage({ settings, cam, onCorner, onAskCamera }: { settings: Settings; cam: MediaStream | null; onCorner: (c: Corner) => void; onAskCamera: () => void }) {
if (settings.mode === "audio") {
return (
<div className="stage stage-audio" aria-hidden="true">
<svg viewBox="0 0 120 60" className="wave">
{[8, 18, 30, 22, 40, 26, 14, 34, 20, 10, 24, 16].map((h, i) => (
<rect key={i} x={6 + i * 9.5} y={30 - h / 2} width="4" height={h} rx="2" />
))}
</svg>
<p className="stage-note">Records your microphone only</p>
</div>
);
}
if (settings.mode === "camera") {
return (
<div className="stage stage-camera">
{cam ? <LiveVideo stream={cam} mirror className="stage-video" /> : (
<button type="button" className="btn btn-line" onClick={onAskCamera}>Show my camera</button>
)}
</div>
);
}
const corners: Corner[] = ["tl", "tr", "bl", "br"];
const names: Record<Corner, string> = { tl: "top left", tr: "top right", bl: "bottom left", br: "bottom right" };
return (
<div className="stage stage-screen">
<img className="art stage-art" src="/art/card-record.webp" alt="" width="720" height="631" />
<p className="stage-note">Your screen, a window or a tab</p>
{settings.camera &&
corners.map((c) =>
c === settings.corner ? (
<div key={c} className={`bubble bubble-${c}`}>
{cam ? <LiveVideo stream={cam} mirror className="bubble-video" /> : (
<button type="button" className="bubble-ask" onClick={onAskCamera} aria-label="Show my camera" title="Show my camera"><ModeIcon mode="camera" /></button>
)}
</div>
) : (
<button key={c} type="button" className={`bubble bubble-spot bubble-${c}`} onClick={() => onCorner(c)} aria-label={`Move the camera bubble to the ${names[c]}`} />
),
)}
</div>
);
}
function LiveVideo({ stream, mirror, className }: { stream: MediaStream; mirror?: boolean; className: string }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (ref.current) ref.current.srcObject = stream;
}, [stream]);
return <video ref={ref} className={`${className}${mirror ? " mirror" : ""}`} autoPlay muted playsInline />;
}
function Switch({ label, hint, checked, onChange }: { label: string; hint: string; checked: boolean; onChange: (on: boolean) => void }) {
return (
<label className="opt">
<span className="opt-text">
<span className="opt-label">{label}</span>
<span className="opt-hint">{hint}</span>
</span>
<input type="checkbox" role="switch" className="switch" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</label>
);
}
/* Live: the countdown, then the recording itself. */
function Live(props: {
phase: { kind: "countdown"; left: number } | { kind: "recording"; paused: boolean };
mode: Mode;
bubble: boolean;
corner: Corner;
setCorner: (c: Corner) => void;
sources: Sources | null;
take: Take | null;
onSkip: () => void;
onCancel: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
const { phase, take, sources } = props;
const [, tick] = useState(0);
const [level, setLevel] = useState<number | null>(null);
useEffect(() => {
const t = window.setInterval(() => {
tick((n) => n + 1);
setLevel(sources?.level() ?? null);
}, 100);
return () => window.clearInterval(t);
}, [sources]);
const elapsed = take?.elapsed() ?? 0;
const paused = phase.kind === "recording" && phase.paused;
// The tab's title shows the countdown and the time, so it can be read from another tab.
useEffect(() => {
const before = document.title;
return () => { document.title = before; };
}, []);
useEffect(() => {
document.title = phase.kind === "countdown" ? `Starting in ${phase.left} · Record` : `${paused ? "Paused" : "Recording"} ${clock(elapsed)} · Record`;
});
if (phase.kind === "countdown") {
return (
<div className="card live">
<p className="eyebrow">Get ready</p>
<p className="countdown" aria-live="assertive">{phase.left || ""}</p>
<div className="live-actions">
<button className="btn btn-line" type="button" onClick={props.onCancel}>Cancel</button>
<button className="btn" type="button" onClick={props.onSkip}>Start now</button>
</div>
</div>
);
}
return (
<div className="card live">
<p className={`live-status${paused ? " is-paused" : ""}`}>
<span className="rec-dot" aria-hidden="true" />
{paused ? "Paused" : "Recording"}
</p>
<p className="live-time" role="timer">{clock(elapsed)}</p>
{sources?.camera && <LiveVideo stream={sources.camera} mirror className="live-video" />}
{level !== null && (
<div className="meter" aria-hidden="true"><span style={{ width: `${Math.round(level * 100)}%` }} /></div>
)}
{props.mode === "screen" && (
<p className="live-note">Switch to what you want to show. Come back to this tab to pause or stop, or stop from the browser's sharing bar.</p>
)}
{props.bubble && (
<div className="corner-pick">
<span className="label">Camera bubble</span>
<div className="segmented segmented-sm" role="radiogroup" aria-label="Camera bubble corner">
{(["tl", "tr", "bl", "br"] as Corner[]).map((c) => (
<button key={c} type="button" role="radio" aria-checked={props.corner === c} onClick={() => props.setCorner(c)} aria-label={{ tl: "Top left", tr: "Top right", bl: "Bottom left", br: "Bottom right" }[c]}>
<CornerIcon corner={c} />
</button>
))}
</div>
</div>
)}
<div className="live-actions">
{paused ? (
<button className="btn btn-line" type="button" onClick={props.onResume}><PlayIcon /> Resume</button>
) : (
<button className="btn btn-line" type="button" onClick={props.onPause}><PauseIcon /> Pause</button>
)}
<button className="btn" type="button" onClick={props.onStop}><StopIcon /> Stop and save</button>
</div>
</div>
);
}
/* Review: watch, rename, trim, download, delete. */
function Review(props: {
rec: Recording;
file: Blob;
problem: string | null;
onBack: () => void;
onChange: (rec: Recording) => void;
onCopy: (rec: Recording, file: Blob) => void;
onDelete: () => void;
}) {
const { rec, file } = props;
const [url] = useState(() => URL.createObjectURL(file));
useEffect(() => () => URL.revokeObjectURL(url), [url]);
const el = useRef<HTMLVideoElement & HTMLAudioElement>(null);
const [duration, setDuration] = useState(rec.duration);
const [range, setRange] = useState<[number, number]>([0, rec.duration]);
const [time, setTime] = useState(0);
const [name, setName] = useState(rec.name);
const [busy, setBusy] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [start, end] = range;
const trimmed = duration > 0 && (start > 0.05 || end < duration - 0.05);
const isAudio = rec.mode === "audio";
const onMeta = () => {
const d = el.current?.duration;
if (!duration && d && Number.isFinite(d)) {
setDuration(d);
setRange([0, d]);
}
};
const onPlay = () => {
const v = el.current;
if (v && trimmed && (v.currentTime < start || v.currentTime >= end - 0.05)) v.currentTime = start;
};
const onTime = () => {
const v = el.current;
if (!v) return;
setTime(v.currentTime);
if (trimmed && !v.paused && v.currentTime >= end) {
v.pause();
v.currentTime = end;
}
};
const seek = (t: number) => {
if (el.current) el.current.currentTime = t;
setTime(t);
};
const rename = () => {
const next = name.trim() || rec.name;
setName(next);
if (next === rec.name) return;
const changed = { ...rec, name: next };
void putRecording(changed).catch(() => {});
props.onChange(changed);
};
const trim = async () => {
setError(null);
setBusy(0);
try {
const out = await (await media()).remake(file, { start, end }, setBusy);
const copy: Recording = { ...rec, id: crypto.randomUUID(), name: `${name} (trimmed)`, created: Date.now(), duration: out.duration, size: out.blob.size, partial: false };
try {
await saveRecording(copy, out.blob);
toast("Trimmed copy saved. The original is still in your recordings.");
} catch {
toast("The trimmed copy could not be kept on this device. Download it now.");
}
props.onCopy(copy, out.blob);
} catch {
setError("This browser could not trim the recording. Try the latest Chrome, Edge or Firefox.");
setBusy(null);
}
};
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
const t = window.setTimeout(() => setConfirming(false), 4000);
return () => window.clearTimeout(t);
}, [confirming]);
const when = new Date(rec.created).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
return (
<div className="review">
<div className="review-head">
<button className="btn btn-quiet btn-sm back" type="button" onClick={props.onBack}><BackIcon /> New recording</button>
</div>
{props.problem && <p className="notice notice-error" role="alert">{props.problem}</p>}
<div className="review-grid">
<div className="review-main">
<label className="sr-only" htmlFor="rec-name">Name</label>
<input id="rec-name" className="review-title" value={name} onChange={(e) => setName(e.target.value)} onBlur={rename} onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} />
<p className="muted small review-meta">
{MODE_LABEL[rec.mode]} · {duration ? clock(duration) : "length unknown"} · {size(rec.size)} · {formatName(rec.type)} · {when}
</p>
{isAudio ? (
<div className="player player-audio">
<ModeIcon mode="audio" />
<audio ref={el} src={url} controls preload="metadata" onLoadedMetadata={onMeta} onPlay={onPlay} onTimeUpdate={onTime} />
</div>
) : (
<video ref={el} className="player" src={url} controls playsInline preload="auto" onLoadedMetadata={onMeta} onPlay={onPlay} onTimeUpdate={onTime} />
)}
{duration > 0 && (
<div className="trim">
<div className="spread">
<p className="label">Trim</p>
<p className="muted small">Drag the handles to cut the start and the end.</p>
</div>
<Timeline duration={duration} start={start} end={end} time={time} onChange={setRange} onSeek={seek} />
<div className="trim-row">
<button className="btn btn-line btn-sm" type="button" onClick={() => setRange([Math.min(time, end - 0.5), end])}>Start here</button>
<span className="trim-times">
{clock(start, true)} to {clock(end, true)} <span className="muted">({clock(end - start, true)})</span>
</span>
<button className="btn btn-line btn-sm" type="button" onClick={() => setRange([start, Math.max(time, start + 0.5)])}>End here</button>
</div>
{trimmed && (
<div className="trim-row trim-apply">
<button className="btn btn-quiet btn-sm" type="button" onClick={() => setRange([0, duration])} disabled={busy !== null}>Reset</button>
<button className="btn btn-sm" type="button" onClick={trim} disabled={busy !== null}>
{busy !== null ? `Trimming ${Math.round(busy * 100)}%` : "Save trimmed copy"}
</button>
</div>
)}
{error && <p className="notice notice-error">{error}</p>}
</div>
)}
</div>
<aside className="review-side">
<div className="card side-card">
<button className="btn btn-lg btn-block" type="button" onClick={() => save(file, fileName({ ...rec, name }))}><DownloadIcon /> Download {formatName(rec.type)}</button>
<p className="hint">Saved in this browser on this device until you delete it.</p>
<button
className={`btn btn-sm btn-block ${confirming ? "btn-line btn-danger" : "btn-quiet"}`}
type="button"
onClick={() => (confirming ? props.onDelete() : setConfirming(true))}
>
<TrashIcon /> {confirming ? "Delete for good?" : "Delete"}
</button>
</div>
<div className="paper share">
<p className="label">Sharing it</p>
<p>Record does not host videos. Download the file, then upload it to Google Drive, Dropbox or iCloud and share the link, or send it in Slack, WhatsApp or email.</p>
<p className="muted small">Hosting with a share link may come later, at cost.</p>
</div>
</aside>
</div>
</div>
);
}
function Timeline({ duration, start, end, time, onChange, onSeek }: { duration: number; start: number; end: number; time: number; onChange: (r: [number, number]) => void; onSeek: (t: number) => void }) {
const track = useRef<HTMLDivElement>(null);
const MIN = 0.5;
const pct = (t: number) => `${(t / duration) * 100}%`;
const at = (clientX: number) => {
const r = track.current!.getBoundingClientRect();
return Math.min(duration, Math.max(0, ((clientX - r.left) / r.width) * duration));
};
const set = (which: "start" | "end", t: number) => {
if (which === "start") onChange([Math.max(0, Math.min(t, end - MIN)), end]);
else onChange([start, Math.min(duration, Math.max(t, start + MIN))]);
};
const drag = (which: "start" | "end") => (e: ReactPointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => {
const t = at(ev.clientX);
set(which, t);
onSeek(t);
};
const up = () => {
target.removeEventListener("pointermove", move);
target.removeEventListener("pointerup", up);
target.removeEventListener("pointercancel", up);
};
target.addEventListener("pointermove", move);
target.addEventListener("pointerup", up);
target.addEventListener("pointercancel", up);
};
const keys = (which: "start" | "end") => (e: KeyboardEvent) => {
const step = e.shiftKey ? 1 : 0.1;
const now = which === "start" ? start : end;
const next = e.key === "ArrowLeft" || e.key === "ArrowDown" ? now - step : e.key === "ArrowRight" || e.key === "ArrowUp" ? now + step : e.key === "Home" ? 0 : e.key === "End" ? duration : null;
if (next === null) return;
e.preventDefault();
set(which, next);
onSeek(Math.max(0, Math.min(duration, next)));
};
return (
<div className="timeline" ref={track} onPointerDown={(e) => onSeek(at(e.clientX))}>
<div className="tl-track" />
<div className="tl-cut" style={{ left: 0, width: pct(start) }} />
<div className="tl-cut" style={{ left: pct(end), right: 0 }} />
<div className="tl-keep" style={{ left: pct(start), width: pct(end - start) }} />
<div className="tl-play" style={{ left: pct(Math.min(time, duration)) }} />
{(["start", "end"] as const).map((which) => {
const value = which === "start" ? start : end;
return (
<button
key={which}
type="button"
className={`tl-handle tl-${which}`}
style={{ left: pct(value) }}
role="slider"
aria-label={which === "start" ? "Trim start" : "Trim end"}
aria-valuemin={0}
aria-valuemax={Math.round(duration * 10) / 10}
aria-valuenow={Math.round(value * 10) / 10}
aria-valuetext={clock(value, true)}
onPointerDown={drag(which)}
onKeyDown={keys(which)}
/>
);
})}
</div>
);
}
/* Your recordings, under the hero. */
function Library({ list, openId, onOpen, onDownload, onDelete }: { list: Recording[]; openId: string | null; onOpen: (r: Recording) => void; onDownload: (r: Recording) => void; onDelete: (r: Recording) => void }) {
const total = list.reduce((n, r) => n + r.size, 0);
return (
<section className="section-tight library">
<div className="wrap">
<div className="spread library-head">
<h2 className="h2">Your recordings</h2>
<p className="muted small">{list.length === 1 ? "1 recording" : `${list.length} recordings`} on this device, {size(total)}</p>
</div>
<ul className="recs">
{list.map((r) => (
<RecRow key={r.id} rec={r} open={r.id === openId} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} onDelete={() => onDelete(r)} />
))}
</ul>
<p className="hint library-note">Recordings are kept in this browser only. Clearing your browsing data deletes them, so download the ones you want to keep.</p>
</div>
</section>
);
}
function RecRow({ rec, open, onOpen, onDownload, onDelete }: { rec: Recording; open: boolean; onOpen: () => void; onDownload: () => void; onDelete: () => void }) {
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
const t = window.setTimeout(() => setConfirming(false), 4000);
return () => window.clearTimeout(t);
}, [confirming]);
const when = new Date(rec.created).toLocaleDateString(undefined, { month: "short", day: "numeric" });
return (
<li className={`rec${open ? " is-open" : ""}`}>
<button type="button" className="rec-main" onClick={onOpen} aria-current={open || undefined}>
<span className="rec-icon"><ModeIcon mode={rec.mode} /></span>
<span className="rec-text">
<span className="rec-name">{rec.name}</span>
<span className="rec-meta">{rec.duration ? clock(rec.duration) : "length unknown"} · {size(rec.size)} · {formatName(rec.type)} · {when}</span>
</span>
</button>
<div className="rec-tools">
<button type="button" className="icon-btn" onClick={onDownload} aria-label={`Download ${rec.name}`} title="Download"><DownloadIcon /></button>
{confirming ? (
<button type="button" className="btn btn-sm btn-line btn-danger" onClick={onDelete}>Delete for good?</button>
) : (
<button type="button" className="icon-btn" onClick={() => setConfirming(true)} aria-label={`Delete ${rec.name}`} title="Delete"><TrashIcon /></button>
)}
</div>
</li>
);
}
/* Icons: 24px line drawings in the current color. */
function Icon({ children }: { children: ReactNode }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{children}
</svg>
);
}
function ModeIcon({ mode }: { mode: Mode }) {
if (mode === "screen") return <Icon><rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" /></Icon>;
if (mode === "camera") return <Icon><rect x="3" y="6" width="13" height="12" rx="2" /><path d="m16 10 5-3v10l-5-3" /></Icon>;
return <Icon><rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5 11a7 7 0 0 0 14 0M12 18v3" /></Icon>;
}
const DownloadIcon = () => <Icon><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></Icon>;
const TrashIcon = () => <Icon><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" /></Icon>;
const PauseIcon = () => <Icon><path d="M9 5v14M15 5v14" /></Icon>;
const PlayIcon = () => <Icon><path d="M7 5v14l12-7z" /></Icon>;
const StopIcon = () => <Icon><rect x="6" y="6" width="12" height="12" rx="2" /></Icon>;
const BackIcon = () => <Icon><path d="M15 6l-6 6 6 6" /></Icon>;
function CornerIcon({ corner }: { corner: Corner }) {
const cx = corner[1] === "l" ? 8 : 16;
const cy = corner[0] === "t" ? 9 : 15;
return <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><circle cx={cx} cy={cy} r="2.4" fill="currentColor" stroke="none" /></Icon>;
}
Change history
Point store links to openapps.free
Bind agent address in policy
Link OpenApps pages and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync