Hooks

useMediaRecorder

MediaRecorder as a hook — start / pause / resume / stop, a live duration that excludes pauses, a container negotiated against what the browser really supports, permission and unsupported as first-class states, and every track released on unmount.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/* -------------------------------------------------------------------------- *
 * Containers
 * -------------------------------------------------------------------------- */

/**
 * Probed in order when the acquired stream carries no video track.
 *
 * Never assume a container. Chromium writes WebM/Opus and cannot write MP4 audio,
 * Safari writes MP4/AAC and rejects every WebM string, and Firefox takes plain
 * `audio/webm` while refusing some explicit codec spellings of the same thing.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-media-recorder.json

Prompt

Build a React + TypeScript "useMediaRecorder" hook (React only — no npm
dependencies; browser MediaStream Recording API).

Contract
- `useMediaRecorder({ constraints = { audio: true }, mimeTypes, bitsPerSecond,
  timeSliceMs = 1000, maxDurationMs, onStop, onError } = {})`.
- Returns `{ status, isSupported, permission, stream, durationMs, sizeBytes,
  mimeType, recording, error, start, pause, resume, stop, clear }`. The five
  commands are referentially stable — every option and callback is read out of a
  latest-ref at call time, never captured in a dependency array — so an inline
  `constraints` literal and an inline `onStop` arrow are safe, and the commands
  can be handed to memoized children.
- `status` is `unsupported | idle | requesting | recording | paused | stopping |
  error`. `unsupported` dominates everything (there is nothing to be idle about
  in a browser that cannot record); `requesting` is the window while the
  permission prompt is up; `stopping` is the gap between `stop()` and the final
  chunk; `idle` with a non-null `recording` is the preview state.
- `recording` is `{ blob, url, mimeType, size, durationMs, reason }` where
  `reason` is `user | max-duration | track-ended | error`. `url` is an object URL
  the hook owns and revokes — on the next `start()`, on `clear()`, and on unmount.
  A consumer that outlives the hook keeps `blob` and mints its own URL.
- `error` is `{ kind, name, message, recoverable }`. `kind` is one of
  `unsupported`, `insecure-context`, `permission-denied`, `no-device`,
  `device-busy`, `capture-failed`, `recorder-failed`, `empty-recording`; `name`
  is the DOMException name when the platform supplied one; `message` is the
  platform's own text or the built-in English explanation. Branch UI copy on
  `kind` / `recoverable`, never on `message`, which is unlocalised prose that
  changes between versions.
- `permission` is `unknown | granted | denied` and reports only what this hook
  has *observed*. It never queries `navigator.permissions` and never raises a
  prompt outside `start()`.
- Export the pieces a consumer needs to build its own support matrix rather than
  a second copy that drifts: `isMimeTypeSupported`, `pickSupportedMimeType`,
  `extensionForMimeType`, `classifyMediaRecorderError(error, fallbackKind)`,
  `MEDIA_RECORDER_ERROR_KINDS`, `MEDIA_RECORDER_ERROR_GUIDE`, and the two default
  candidate lists.

Behavior
- Capability detection runs through `useSyncExternalStore(noopSubscribe, detect,
  () => true)`, never during render — the server has no `navigator`. The server
  snapshot is deliberately optimistic: a pessimistic one would flash "your browser
  cannot record" at every visitor whose browser can.
- NEVER start on mount. `start()` is what raises the permission prompt and it
  belongs inside a user gesture; a page that grabs the microphone on load spends
  its one prompt before anyone knows why. `start()` also re-probes support at call
  time and resolves to an `unsupported` error instead of throwing — or
  `insecure-context` when `window.isSecureContext` is false, because on an http://
  page the API is missing *because* of the origin, and telling that visitor to
  change browsers is wrong advice.
- `start()` is latched by a ref that is read AND written synchronously before any
  await. A double click, or a held Enter key, would otherwise raise two permission
  prompts and leak the stream of whichever request loses. The latch is released by
  the finalizer and by every failure path.
- Cancelling matters as much as starting: `stop()` called while `status` is
  `requesting` bumps a start token and returns to `idle`, and the stream that
  resolves afterwards is orphaned — it stops its own tracks on arrival. The same
  token is bumped on unmount, which is the only defence against a prompt that
  outlives its component and leaves the recording indicator lit until the tab is
  closed.
- The container is negotiated, never assumed. Candidates are probed with
  `MediaRecorder.isTypeSupported` (wrapped in try/catch: some engines throw on a
  malformed type string rather than answering false) and the first survivor is
  passed to the constructor. If none survive, the recorder is built with no
  `mimeType` at all and the browser's own choice is reported back through
  `mimeType`. A constructor that throws anyway is retried once with no options
  before the take is failed. Which list is probed follows the tracks that were
  actually handed over (`stream.getVideoTracks().length > 0`), not what was asked
  for — a camera request answered with audio only must not be put in a video
  container. Chromium writes WebM and cannot write MP4 audio; Safari does the
  reverse; a hard-coded "audio/webm;codecs=opus" is the classic Safari throw.
- Duration is derived from an instant the caller captured, never from a clock read
  during render (which would differ between SSR and hydration, and between two
  renders of the same state). Handlers read `performance.now()` once and pass it
  down: `openSegment(at)` on start and resume, `freezeDuration(at)` — idempotent —
  on pause and stop. The 200 ms tick recomputes `accumulated + (now - segmentStart)`
  rather than adding a fixed step, because a backgrounded tab throttles the
  interval to once a second or worse and a stepping counter silently under-reports
  every one of those seconds. Paused stretches never enter the total, so the number
  the user watched is the number the blob is labelled with.
- `maxDurationMs` is enforced from inside the tick and hands the *same* instant
  that tripped it to the stop path, so the saved duration is the one that was on
  screen rather than one tick more.
- Every track gets an `ended` listener. A device that is unplugged, revoked from
  the browser's own indicator, or taken by another application leaves
  `MediaRecorder` happily running against a dead track, recording nothing; the
  take ends there with `reason: "track-ended"` so a consumer can tell it apart
  from a user stop.
- A recorder `error` event does not throw the take away. The classified failure is
  parked, the take is finalized, and if any bytes arrived they are still handed
  back: `recording` is populated *and* `error` is set, with `reason: "error"`.
  Discarding ten minutes of audio over a failed last second is not a decision a
  hook gets to make. A take that produced zero bytes is the opposite case — it
  fails as `empty-recording` and never reaches `recording`, because a blob no
  player can open is worse than an honest failure.
- Cleanup is total and runs on unmount as well as at the end of every take: the
  interval is cleared, the recorder's `dataavailable` / `stop` / `error` listeners
  and the tracks' `ended` listeners are detached *first* (so a late event cannot
  write into a dead component or mint an object URL nothing will revoke), the
  recorder is stopped, every track is stopped — which is what actually puts the
  tab's microphone indicator and the OS recording light out — and the object URL
  is revoked. StrictMode's mount → cleanup → mount is survivable because nothing
  is acquired on mount and the mounted flag is set in the effect body, not only
  cleared in cleanup.

Rendering & styling
- The hook renders nothing; the consumer owns the UI. Recommended transport: ONE
  button that morphs (Record → Cancel the request → Stop → Record again) rather
  than swapping elements, so focus is never dropped when the state changes, plus a
  Pause/Resume button and a Discard that only exists once there is a take.
- Keyboard map: the transport is a plain `<button>`, so Space and Enter activate
  it and nothing else needs binding. Controls that go unavailable while the user
  may be standing on them use `aria-disabled` plus a guard in the handler, never
  the native `disabled` attribute — the browser blurs a control the instant it is
  disabled and focus falls back to `<body>`. Discard unmounts itself, so it moves
  focus to the transport button *before* calling `clear()`.
- ARIA contract: a `role="status" aria-live="polite"` line that is mounted from
  the first paint (a live region inserted together with its text announces
  nothing) carrying one sentence per state — "Recording, 0:12.4 so far", "Paused
  at 0:12.4", "Take ready: 0:12.4, 96.2 KB". The pulsing red dot is decorative and
  `aria-hidden`; it uses `motion-safe:animate-pulse` so reduced motion keeps the
  meaning without the animation, and the state is never conveyed by the dot alone.
  A live `<video srcObject={stream}>` preview is `muted` (an unmuted preview feeds
  the microphone back into the speakers and howls) with `playsInline` and a real
  `aria-label`; the download is a real `<a download href={recording.url}>` whose
  extension comes from the container that was produced, never from the one that
  was requested.
- Semantic tokens only: `bg-card`, `bg-muted`, `text-muted-foreground`, `border`,
  `bg-destructive/10` + `text-destructive` for the failure panel. Body copy on a
  tinted panel is `text-foreground`, not `text-muted-foreground` — measured on the
  light theme, muted copy over `bg-destructive/10` lands at 4.1:1, under AA. Never
  render a "ready" affordance while `status` is `unsupported` or `error`.

Customization levers
- `constraints` — the one axis that decides everything downstream: `{ audio: true }`
  for a voice note, `{ audio: true, video: true }` for a video reply, an exact
  `deviceId` for a device picker, `{ audio: { echoCancellation: false } }` for
  music. Swap `getUserMedia` for `getDisplayMedia` in the one line that acquires
  the stream and the same machine records the screen.
- `mimeTypes` — override the probe order to prefer a container your backend
  transcodes cheaply; keep at least one plain `audio/webm` / `video/mp4` fallback
  in the list so Safari and Firefox are still served.
- `timeSliceMs` — 1000 keeps `sizeBytes` moving during the take; `0` asks for a
  single chunk at stop. Lower it (200-500 ms) and push each `dataavailable` chunk
  straight at an upload socket for streaming transcription.
- `maxDurationMs` — the safety cap. 60 s for a voice message, 10 s for a video
  reply, omitted for a lecture recorder.
- `bitsPerSecond` — the file-size lever, worth setting when the take is uploaded
  over a metered connection.
- `onStop` / `onError` — where the upload, the analytics event and the toast hang
  off. Gate the toast on `error.recoverable`: a retry button on a denied
  permission can only fail again, so those get the site-settings walkthrough
  instead.
- Want hold-to-talk instead of a toggle? Call `start()` on `pointerdown` and
  `stop()` on `pointerup`, and keep a `pointermove` guard for `e.buttons === 0` so
  a pointer released outside the button still ends the take.

Concepts

  • One-shot start latch — a ref read and written synchronously at the top of start(), before anything asynchronous. Two clicks in one tick would otherwise open two permission prompts and leak the stream belonging to the loser; the same latch is what makes start() idempotent for the whole length of a take.
  • Elapsed from the segment start stamp — every duration comes from an instant the handler captured and passed down, never from a clock read during render. The tick recomputes accumulated + (now - segmentStart) instead of adding a step per tick, which is the only reading that survives a backgrounded tab, and pausing folds the running segment into the total so paused stretches are excluded from the number the blob is finally labelled with.
  • Container negotiation — candidates are probed with isTypeSupported and the first survivor wins; when none do, the recorder is built with no mimeType and the browser's choice is reported back. Which list is probed follows the tracks actually handed over, not the ones requested — and the download extension follows the container that was produced, so an MP4 blob never leaves as a .webm file.
  • Track ended as a stop signal — an unplugged microphone, a permission revoked from the browser's own indicator, or another application seizing the device all end the track while MediaRecorder keeps running against nothing. Listening for ended turns that into a finished take with reason: "track-ended", instead of a recording that is silent for the half hour nobody noticed.
  • Permission as an observed statepermission reports what start() learned, never what a query said: the hook raises no prompt of its own and touches navigator.permissions never, so it can sit in a page without spending anyone's one-shot permission budget.
  • Object URL ledger — exactly one object URL exists per finished take, minted in the finalizer and revoked on the next start(), on clear(), and on unmount. Handlers are detached before the recorder is stopped so a late event can never mint one that nothing will ever revoke.

On This Page