record-free.openapps.xyz

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.
Owned app · rev 1c51a6b34b9a
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.
← All files
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
1c51a6b34b · 1 day ago
Bind agent address in policy
50f7c610b7 · 1 day ago
Link OpenApps pages and add contribution strip
99dc7c6d18 · 1 day ago
Bounded source sync
16c8400957 · 3 days ago
Bounded source sync
c07b7aea64 · 3 days ago
Bounded source sync
f19b8997d3 · 3 days ago