@reechy-tools/recorder
v0.1.0
Published
OBS-style screen + camera recording in the browser. Draggable PIP, shape masks, audio mixing, zero server, TypeScript-first.
Maintainers
Keywords
Readme
// Screen + camera PIP recording in 5 lines of code
const recorder = new BrowserRecorder({ screen: true, pip: { shape: 'rounded' } })
await recorder.init()
recorder.start()
// ... user records ...
const blob = await recorder.stop() // → WebM with composited screen + cameraWhy this over RecordRTC?
RecordRTC hasn't shipped a release since 2021. No TypeScript, no PIP, no React hooks, 180KB.
| Feature | RecordRTC | @reechy-tools/recorder |
|---------|:---------:|:----------------------:|
| TypeScript — native, strict | ❌ | ✅ |
| Screen + camera PIP | ❌ | ✅ Canvas compositing |
| Draggable & resizable PIP | ❌ | ✅ Pointer events + rAF |
| PIP shape masks (circle, rounded, square) | ❌ | ✅ |
| Audio mixing without crashing MediaRecorder | ❌ | ✅ AudioContext graph |
| Camera switch with freeze-frame (no flash) | ❌ | ✅ |
| Add screen share mid-recording | ❌ | ✅ |
| Max duration with auto-stop | ❌ | ✅ |
| React hooks (useRecorder, usePip) | ❌ | ✅ |
| Tailwind-ready data-* state attributes | ❌ | ✅ |
| Structured errors with recovery hints | ❌ | ✅ |
| AI-friendly (CLAUDE.md, llms.txt) | ❌ | ✅ |
| Bundle size | ~180KB | ~10KB gzipped |
| Last updated | 2021 | Active |
Install
npm install @reechy-tools/recorder
# pnpm add @reechy-tools/recorder
# yarn add @reechy-tools/recorderReact hooks are in a separate subpath — React is a peer dependency:
import { useRecorder, usePip } from '@reechy-tools/recorder/react'Quick Start — Vanilla JS
import { BrowserRecorder } from '@reechy-tools/recorder'
const recorder = new BrowserRecorder({
camera: { width: 1280, height: 720, facingMode: 'user' },
screen: true, // enable screen share + PIP
pip: { shape: 'rounded', mirrored: true },
audio: true,
countdown: 3, // 3-second countdown before recording
codec: 'auto', // VP9 → VP8 → H264 → MP4
maxDuration: 300, // auto-stop at 5 minutes
})
// 1. Request permissions (call on user gesture)
await recorder.init()
// 2. Bind to video elements
cameraVideoEl.srcObject = recorder.getCameraStream()
// 3. Listen to events
recorder.on('state-change', (state) => console.log(state))
recorder.on('countdown', (n) => showCountdown(n))
recorder.on('duration', (ms) => updateTimer(ms))
recorder.on('duration-limit', () => console.log('Time is up — auto-stopped'))
recorder.on('error', (err) => console.error(err.code, err.recovery))
// 4. Start (with countdown)
recorder.start()
// 5. Mid-recording controls
recorder.toggleMic() // mute/unmute
recorder.toggleCamera() // hide/show camera
await recorder.toggleScreenShare() // add/remove screen share
await recorder.switchCamera() // front ↔ back (mobile)
recorder.setPipPosition({ x: 25, y: 75, width: 30 })
recorder.setPipShape('circle')
// 6. Stop → Blob
const blob = await recorder.stop() // → Blob (WebM)
const url = URL.createObjectURL(blob)Quick Start — React
import { useRef } from 'react'
import { useRecorder, usePip } from '@reechy-tools/recorder/react'
export function RecordingPage() {
const containerRef = useRef<HTMLDivElement>(null)
const recorder = useRecorder({
screen: true,
pip: { shape: 'rounded', mirrored: true },
maxDuration: 300,
})
const pip = usePip({
containerRef,
initialPosition: { x: 75, y: 75, width: 22 },
onPositionChange: (pos) => recorder.setPipPosition(pos),
})
const handleStop = async () => {
const blob = await recorder.stop()
const url = URL.createObjectURL(blob)
// upload, play back, or download
}
return (
// Spread dataAttrs → Tailwind can target [data-recording], [data-paused], etc.
<div
ref={containerRef}
{...recorder.dataAttrs}
className="relative aspect-video w-full
[&[data-recording]]:ring-2 [&[data-recording]]:ring-red-500
[&[data-paused]]:opacity-60"
>
{/* Screen share background */}
{recorder.screenStream && (
<video
ref={el => { if (el) el.srcObject = recorder.screenStream }}
autoPlay muted playsInline
className="h-full w-full object-contain"
/>
)}
{/* Draggable camera PIP */}
{recorder.cameraStream && recorder.hasScreenShare && (
<div {...pip.dragProps} style={{ ...pip.style, borderRadius: 12, overflow: 'hidden' }}>
<video
ref={el => { if (el) el.srcObject = recorder.cameraStream }}
autoPlay muted playsInline
className="h-full w-full object-cover [transform:scaleX(-1)]"
/>
<div {...pip.resizeProps} />
</div>
)}
{/* Camera only (no screen share) */}
{recorder.cameraStream && !recorder.hasScreenShare && (
<video
ref={el => { if (el) el.srcObject = recorder.cameraStream }}
autoPlay muted playsInline
className="h-full w-full object-cover [transform:scaleX(-1)]"
/>
)}
{/* Countdown overlay */}
{recorder.countdown !== null && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[120px] font-bold text-white">{recorder.countdown}</span>
</div>
)}
{/* Controls */}
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2">
{recorder.state === 'idle' && <button onClick={recorder.init}>Allow Camera</button>}
{recorder.state === 'ready' && <button onClick={recorder.start}>Record</button>}
{recorder.isRecording && <>
<button onClick={recorder.pause}>Pause</button>
<button onClick={handleStop}>Stop</button>
<button onClick={recorder.toggleScreenShare}>
{recorder.hasScreenShare ? 'Stop Screen' : 'Share Screen'}
</button>
</>}
</div>
</div>
)
}API Reference
new BrowserRecorder(config?)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| camera | CameraConfig \| boolean | true | Camera constraints or false to disable |
| screen | boolean | false | Enable screen capture (getDisplayMedia) |
| pip.position | PipPosition | bottom-right | PIP position as percentages of container |
| pip.shape | PipShape | 'rounded' | circle, rounded, square, or none |
| pip.mirrored | boolean | true | Mirror camera horizontally |
| audio | AudioConfig \| boolean | true | Audio constraints or false to disable |
| countdown | number | 3 | Countdown seconds before recording (0 to skip) |
| codec | CodecPreference | 'auto' | vp9, vp8, h264, av1, auto |
| frameRate | number | 30 | Canvas recording frame rate |
| maxDuration | number | undefined | Max recording seconds — auto-stops + emits duration-limit |
PipPosition
{ x: number, y: number, width: number }
// All values are percentages (0–100) relative to the container
// x, y = center point of the PIP
// width = PIP width (height derived from camera aspect ratio)Events
recorder.on('state-change', (state: RecorderState) => void) // idle|ready|countdown|recording|paused|stopping
recorder.on('data', (blob: Blob) => void) // raw MediaRecorder data chunks
recorder.on('error', (err: RecorderError) => void)
recorder.on('countdown', (seconds: number) => void) // 3, 2, 1
recorder.on('duration', (ms: number) => void) // fires every 100ms
recorder.on('duration-limit', () => void) // maxDuration reached, auto-stop imminent
recorder.on('camera-change', (enabled: boolean) => void) // toggleCamera() result
recorder.on('screen-change', (stream: MediaStream | null) => void) // screen share started/stopped
recorder.on('screen-ended', () => void) // user stopped sharing via browser UIuseRecorder() return — React
All the above, plus:
recorder.dataAttrs // spread on your container element
// {
// 'data-state': 'idle' | 'ready' | 'countdown' | 'recording' | 'paused' | 'stopping'
// 'data-recording'?: '' // present only when recording
// 'data-paused'?: ''
// 'data-countdown'?: ''
// 'data-has-screen'?: ''
// 'data-error'?: ''
// 'data-duration-limit'?: ''
// }Error codes
Every RecorderError has code, message, and recovery (user-facing hint).
| Code | When |
|------|------|
| CAMERA_PERMISSION_DENIED | User denied camera access |
| CAMERA_NOT_FOUND | No camera device available |
| CAMERA_IN_USE | Camera already in use by another app |
| CAMERA_SWITCH_FAILED | Failed to switch front/back camera |
| SCREEN_SHARE_DENIED | User cancelled screen picker |
| SCREEN_SHARE_NOT_SUPPORTED | Mobile or old browser |
| CODEC_NOT_SUPPORTED | No supported codec found |
| RECORDER_NOT_INITIALIZED | init() not called before start() |
| ALREADY_RECORDING | start() called while already recording |
| NOT_RECORDING | stop()/pause() called when not recording |
| AUDIO_CONTEXT_FAILED | AudioContext creation failed (iOS needs user gesture) |
| CANVAS_CAPTURE_NOT_SUPPORTED | Safari — use a supported browser |
Browser Support
| Browser | Camera | Screen | PIP | |---------|:------:|:------:|:---:| | Chrome 88+ | ✅ | ✅ | ✅ | | Firefox 84+ | ✅ | ✅ | ✅ | | Edge 88+ | ✅ | ✅ | ✅ | | Safari 15+ | ✅ | ❌ | ❌ | | iOS Safari | ✅ | ❌ | ❌ | | Chrome Android | ✅ | ❌ | ❌ |
Examples
examples/vanilla/— plain HTML + JS, no build stepexamples/react/— React withuseRecorder+usePip
Run locally:
git clone https://github.com/reechy-tools/recorder
cd recorder && npm install
npm run dev # builds + serves at http://localhost:4321
# open http://localhost:4321/examples/vanilla/Made with @reechy-tools/recorder?
Add this badge to your project README:
[](https://github.com/reechy-tools/recorder)Used By
Built and battle-tested at Reechy — browser-based video pitch recorder for sales, hiring, and async communication. This library is extracted directly from Reechy's production codebase.
Contributing
PRs welcome. Please open an issue first for significant changes.
git clone https://github.com/reechy-tools/recorder
cd recorder && npm install
npm run dev # tsup watch + local server at :4321
npm test # vitest (24 tests)
npm run build # production build → dist/License
MIT © Reechy
