@brett_lamy/docstream 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +31 -4
  2. package/dist/demo/DemoViewer.d.ts +8 -0
  3. package/dist/demo/DemoViewer.d.ts.map +1 -1
  4. package/dist/demo/DemoViewer.js +74 -1
  5. package/dist/demo/DemoViewer.js.map +1 -1
  6. package/dist/demo/PathBreadcrumb.d.ts +54 -0
  7. package/dist/demo/PathBreadcrumb.d.ts.map +1 -0
  8. package/dist/demo/PathBreadcrumb.js +245 -0
  9. package/dist/demo/PathBreadcrumb.js.map +1 -0
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/replay/ReplayPlayer.d.ts +64 -0
  15. package/dist/replay/ReplayPlayer.d.ts.map +1 -0
  16. package/dist/replay/ReplayPlayer.js +211 -0
  17. package/dist/replay/ReplayPlayer.js.map +1 -0
  18. package/dist/replay/ReplayPreview.d.ts +24 -12
  19. package/dist/replay/ReplayPreview.d.ts.map +1 -1
  20. package/dist/replay/ReplayPreview.js +37 -32
  21. package/dist/replay/ReplayPreview.js.map +1 -1
  22. package/dist/replay/ReplayStageImpl.js +1 -1
  23. package/dist/replay/ReplayStageImpl.js.map +1 -1
  24. package/dist/replay/demo/index.d.ts +3 -0
  25. package/dist/replay/demo/index.d.ts.map +1 -0
  26. package/dist/replay/demo/index.js +66 -0
  27. package/dist/replay/demo/index.js.map +1 -0
  28. package/dist/replay/index.d.ts +2 -2
  29. package/dist/replay/index.d.ts.map +1 -1
  30. package/dist/replay/index.js +1 -1
  31. package/dist/replay/index.js.map +1 -1
  32. package/dist/replay/types.js +1 -1
  33. package/dist/styles.css +791 -79
  34. package/package.json +7 -1
  35. package/src/demo/DemoViewer.tsx +94 -2
  36. package/src/demo/PathBreadcrumb.tsx +367 -0
  37. package/src/index.ts +2 -1
  38. package/src/replay/ReplayPlayer.tsx +430 -0
  39. package/src/replay/ReplayPreview.tsx +111 -85
  40. package/src/replay/ReplayStageImpl.tsx +1 -1
  41. package/src/replay/demo/index.ts +67 -0
  42. package/src/replay/index.ts +6 -1
  43. package/src/replay/types.ts +1 -1
  44. package/src/styles.css +791 -79
  45. package/dist/replay/NativeReplay.d.ts +0 -9
  46. package/dist/replay/NativeReplay.d.ts.map +0 -1
  47. package/dist/replay/NativeReplay.js +0 -175
  48. package/dist/replay/NativeReplay.js.map +0 -1
  49. package/src/replay/NativeReplay.tsx +0 -240
@@ -0,0 +1,430 @@
1
+ /* The player behind `ReplayPreview`: an rrweb session replay in a browser frame. The headless `ReplayStage`
2
+ plays the recording (rrweb loads lazily, on first render); this adds the chrome around it: a URL bar that
3
+ follows the recording's navigations, play/pause, a scrubber with the recording's moments on a rail above
4
+ it, a speed control, fullscreen, and a drawn cursor whose clicks ripple. Until the player is ready a
5
+ poster (a page-shaped skeleton by default) holds the frame's exact size, so nothing jumps.
6
+
7
+ Plain React + plain CSS (`@layer docstream.replay` in styles.css, `[data-slot]` hooks). Keys (focus inside
8
+ the player): Space / K play and pause, ← → seek 5 s, J / L 10 s, < > speed, F fullscreen, Home / End jump
9
+ to the ends. Everything drawn over the page is a function of the playhead, so scrubbing backwards replays
10
+ the ripples and the URL too. */
11
+ import {
12
+ memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
13
+ type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref,
14
+ } from 'react'
15
+ import { useReducedMotion } from 'framer-motion'
16
+ import { Lock, Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
17
+ import { ReplayStage } from './ReplayStage'
18
+ import { getReplayMarkers, getReplayMeta, getReplayPointerTrack, replayPointerAt } from './markers'
19
+ import type { ReplayMarker, ReplayMarkerKind, ReplayPointerTrack } from './markers'
20
+ import type { ReplayStageHandle } from './stage-types'
21
+ import type { ReplayEvent } from './types'
22
+
23
+ const TRAFFIC_LIGHTS = ['#FF5F57', '#FEBC2E', '#28C840'] as const
24
+ export const DEFAULT_PLAYER_SPEEDS = [1, 2, 4, 0.5]
25
+ const RIPPLE_MS = 650
26
+
27
+ export type ReplayChrome = 'browser' | 'minimal' | 'none'
28
+
29
+ export interface ReplayPreviewHandle {
30
+ play(offset?: number): void
31
+ pause(offset?: number): void
32
+ /** Jump to `ms` from the start, keeping the play state. */
33
+ seek(ms: number): void
34
+ toggle(): void
35
+ getCurrentTime(): number
36
+ }
37
+
38
+ /** Options shared by every `ReplayPreview` (an `events` player or a `source` embed). */
39
+ export interface ReplayPlayerOptions {
40
+ /** Address shown in the URL bar (default: the recording's own URL, following its navigations). */
41
+ url?: string
42
+ /** Accessible name of the player (default "Session replay"). */
43
+ title?: string
44
+ /** `browser` (default): window buttons, URL bar, controls. `minimal`: the page and its controls in a rounded
45
+ * card. `none`: no frame, fill the parent (a hero, a lightbox). */
46
+ chrome?: ReplayChrome
47
+ /** macOS window buttons in the browser chrome (default true). */
48
+ trafficLights?: boolean
49
+ /** Play/pause, scrubber, speed and fullscreen (default true). */
50
+ controls?: boolean
51
+ autoplay?: boolean
52
+ loop?: boolean
53
+ /** Where the player starts, in ms from the start. */
54
+ initialTime?: number
55
+ /** Speeds the speed button cycles through; the first is the initial speed (default 1, 2, 4, 0.5). */
56
+ speeds?: number[]
57
+ /** Markers on the scrubber rail: `true` (default) reads them from the recording, `false` hides the rail,
58
+ * or pass your own. */
59
+ markers?: boolean | ReplayMarker[]
60
+ /** Which recorded marker kinds to show (default all). */
61
+ markerKinds?: ReplayMarkerKind[]
62
+ /** Draw the recorded pointer and click ripples (default true). */
63
+ cursor?: boolean
64
+ /** `width` (default): the page scales to the frame's width and sets its height. `contain`: fill the frame's
65
+ * height (give it one) and letterbox the page. */
66
+ fit?: 'width' | 'contain'
67
+ /** Shown until the player is ready (default: a page-shaped skeleton). */
68
+ poster?: ReactNode
69
+ /** Mount the player only once the frame nears the viewport (default true). */
70
+ lazy?: boolean
71
+ /** Imperative control (play, pause, seek). */
72
+ playerRef?: Ref<ReplayPreviewHandle | null>
73
+ onTimeUpdate?: (ms: number) => void
74
+ onPlayingChange?: (playing: boolean) => void
75
+ onMarkerPress?: (marker: ReplayMarker) => void
76
+ className?: string
77
+ style?: CSSProperties
78
+ }
79
+
80
+ export function formatReplayTime(ms: number) {
81
+ const s = Math.max(0, Math.floor(ms / 1000))
82
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
83
+ }
84
+
85
+ const MARKER_NOUN: Record<ReplayMarkerKind, string> = {
86
+ click: 'Click', error: 'Error', network: 'Request failed', navigation: 'Navigation', custom: 'Checkpoint',
87
+ }
88
+
89
+ export function ReplayPlayer({
90
+ events, url, title = 'Session replay', chrome = 'browser', trafficLights = true, controls = true,
91
+ autoplay = false, loop = false, initialTime = 0, speeds = DEFAULT_PLAYER_SPEEDS, markers = true, markerKinds,
92
+ cursor = true, fit = 'width', poster, lazy = true, playerRef, onTimeUpdate, onPlayingChange, onMarkerPress,
93
+ className, style,
94
+ }: ReplayPlayerOptions & { events: ReplayEvent[] }) {
95
+ const rootRef = useRef<HTMLDivElement>(null)
96
+ const stage = useRef<ReplayStageHandle>(null)
97
+ const reduced = useReducedMotion()
98
+ const meta = useMemo(() => getReplayMeta(events), [events])
99
+ const track = useMemo(() => getReplayPointerTrack(events), [events])
100
+ const recorded = useMemo(() => getReplayMarkers(events), [events])
101
+ const shown = useMemo(() => {
102
+ const list = Array.isArray(markers) ? markers : markers ? recorded : []
103
+ return markerKinds ? list.filter((m) => markerKinds.includes(m.kind)) : list
104
+ }, [markers, recorded, markerKinds])
105
+
106
+ const [visible, setVisible] = useState(!lazy)
107
+ const [ready, setReady] = useState(false)
108
+ const [posterGone, setPosterGone] = useState(false)
109
+ const [time, setTime] = useState(initialTime)
110
+ const [playing, setPlaying] = useState(false)
111
+ const [finished, setFinished] = useState(false)
112
+ const [speedIndex, setSpeedIndex] = useState(0)
113
+ const [fullscreen, setFullscreen] = useState(false)
114
+ const duration = meta.duration
115
+ const speedList = speeds.length ? speeds : DEFAULT_PLAYER_SPEEDS
116
+ const speed = speedList[speedIndex % speedList.length] ?? 1
117
+
118
+ // Lazy mount: start the player (and fetch rrweb) once the frame is near the viewport.
119
+ useEffect(() => {
120
+ if (visible) return
121
+ const el = rootRef.current
122
+ if (!el || typeof IntersectionObserver === 'undefined') return setVisible(true)
123
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setVisible(true), { rootMargin: '240px' })
124
+ io.observe(el)
125
+ return () => io.disconnect()
126
+ }, [visible])
127
+
128
+ useEffect(() => {
129
+ const on = () => setFullscreen(document.fullscreenElement === rootRef.current)
130
+ document.addEventListener('fullscreenchange', on)
131
+ return () => document.removeEventListener('fullscreenchange', on)
132
+ }, [])
133
+
134
+ // The poster dissolves (CSS) once the page is ready, then leaves the tree.
135
+ useEffect(() => {
136
+ if (!ready) return setPosterGone(false)
137
+ const timer = setTimeout(() => setPosterGone(true), reduced ? 120 : 260)
138
+ return () => clearTimeout(timer)
139
+ }, [ready, reduced])
140
+
141
+ const handle = useMemo<ReplayPreviewHandle>(() => ({
142
+ play: (offset) => { setFinished(false); stage.current?.play(offset) },
143
+ pause: (offset) => stage.current?.pause(offset),
144
+ seek: (ms) => { setFinished(false); stage.current?.seek(ms) },
145
+ toggle: () => { setFinished(false); stage.current?.toggle() },
146
+ getCurrentTime: () => stage.current?.getCurrentTime() ?? 0,
147
+ }), [])
148
+ useImperativeHandle(playerRef, () => handle, [handle])
149
+
150
+ const onTime = useCallback((ms: number) => { setTime(ms); onTimeUpdate?.(ms) }, [onTimeUpdate])
151
+ const onPlaying = useCallback((p: boolean) => {
152
+ setPlaying(p)
153
+ if (p) setFinished(false)
154
+ onPlayingChange?.(p)
155
+ }, [onPlayingChange])
156
+
157
+ const toggle = () => handle.toggle()
158
+ const seekBy = (d: number) => handle.seek(Math.max(0, Math.min(duration, time + d)))
159
+ const cycleSpeed = (d = 1) => setSpeedIndex((i) => (i + d + speedList.length) % speedList.length)
160
+ const toggleFullscreen = () => {
161
+ const el = rootRef.current
162
+ if (!el) return
163
+ if (document.fullscreenElement) void document.exitFullscreen?.().catch(() => {})
164
+ else void el.requestFullscreen?.().catch(() => {})
165
+ }
166
+ const jumpTo = (m: ReplayMarker) => {
167
+ handle.seek(Math.max(0, m.time - 250))
168
+ onMarkerPress?.(m)
169
+ }
170
+
171
+ const onKeyDown = (e: ReactKeyboardEvent) => {
172
+ const target = e.target as HTMLElement
173
+ if (e.metaKey || e.ctrlKey || e.altKey || target.closest('input:not([type=range]), textarea, [contenteditable=true]')) return
174
+ const onSlider = target.matches('input[type=range]')
175
+ const run = (fn: () => void) => { e.preventDefault(); fn() }
176
+ switch (e.key) {
177
+ case ' ': case 'k': case 'K': if (target.closest('button') && e.key === ' ') return; return run(toggle)
178
+ case 'ArrowLeft': if (onSlider) return; return run(() => seekBy(-5000))
179
+ case 'ArrowRight': if (onSlider) return; return run(() => seekBy(5000))
180
+ case 'j': case 'J': return run(() => seekBy(-10000))
181
+ case 'l': case 'L': return run(() => seekBy(10000))
182
+ case 'Home': if (onSlider) return; return run(() => handle.seek(0))
183
+ case 'End': if (onSlider) return; return run(() => handle.seek(duration))
184
+ case '>': case '.': return run(() => cycleSpeed(1))
185
+ case '<': case ',': return run(() => cycleSpeed(-1))
186
+ case 'f': case 'F': return run(toggleFullscreen)
187
+ }
188
+ }
189
+
190
+ // The URL bar follows the recording: the latest navigation at or before the playhead.
191
+ const href = useMemo(() => {
192
+ if (url) return url
193
+ let current = meta.href ?? ''
194
+ for (const m of recorded) if (m.kind === 'navigation' && m.time <= time && m.detail) current = m.detail
195
+ return current
196
+ }, [url, meta.href, recorded, time])
197
+
198
+ const w = meta.width || 1280
199
+ const h = meta.height || 800
200
+ const contain = fit === 'contain' || fullscreen
201
+
202
+ return (
203
+ <div
204
+ ref={rootRef}
205
+ className={className ? `replay-preview ${className}` : 'replay-preview'}
206
+ data-slot="replay-preview"
207
+ data-replay-preview="rrweb"
208
+ data-chrome={chrome}
209
+ data-fit={contain ? 'contain' : 'width'}
210
+ data-state={!ready ? 'loading' : playing ? 'playing' : 'paused'}
211
+ data-fullscreen={fullscreen || undefined}
212
+ role="group"
213
+ aria-label={title}
214
+ aria-busy={!ready || undefined}
215
+ tabIndex={-1}
216
+ onKeyDown={onKeyDown}
217
+ // A click anywhere in the player (the page, the frame) makes its keys live, without a focus ring.
218
+ onPointerDown={() => {
219
+ const root = rootRef.current
220
+ if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true })
221
+ }}
222
+ style={style}
223
+ >
224
+ {chrome === 'browser' ? <BrowserBar href={href} trafficLights={trafficLights} loading={!ready && meta.playable} /> : null}
225
+
226
+ <div data-slot="replay-preview-stage" style={{ '--replay-ar': `${w} / ${h}` } as CSSProperties}>
227
+ {visible && meta.playable ? (
228
+ <ReplayStage
229
+ ref={stage}
230
+ events={events}
231
+ fit="contain"
232
+ speed={speed}
233
+ loop={loop}
234
+ autoplay={autoplay}
235
+ initialTime={initialTime}
236
+ showMouse={false}
237
+ className="replay-preview__page"
238
+ overlay={cursor ? <PointerOverlay track={track} time={time} reduced={!!reduced} /> : null}
239
+ onReady={() => setReady(true)}
240
+ onTimeUpdate={onTime}
241
+ onPlayingChange={onPlaying}
242
+ onFinish={() => setFinished(true)}
243
+ />
244
+ ) : null}
245
+
246
+ {!posterGone ? (
247
+ <div data-slot="replay-preview-poster" data-leaving={ready || undefined}>
248
+ {poster ?? <PagePoster empty={!meta.playable} />}
249
+ </div>
250
+ ) : null}
251
+
252
+ {controls ? (
253
+ <div data-slot="replay-preview-center" data-show={(ready && !playing) || undefined}>
254
+ <button
255
+ type="button"
256
+ tabIndex={-1}
257
+ aria-label={finished ? 'Replay from the start' : 'Play'}
258
+ // The overlay leaves as playback starts; keep focus (and the keys) in the player.
259
+ onClick={() => { rootRef.current?.focus({ preventScroll: true }); toggle() }}
260
+ >
261
+ {finished ? <RotateCcw size={22} strokeWidth={2.4} /> : <Play size={22} strokeWidth={2.4} fill="currentColor" />}
262
+ </button>
263
+ </div>
264
+ ) : null}
265
+ </div>
266
+
267
+ {controls ? (
268
+ <div data-slot="replay-preview-controls">
269
+ <button type="button" data-slot="replay-preview-play" aria-label={playing ? 'Pause' : 'Play'} onClick={toggle} disabled={!ready}>
270
+ {playing ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}
271
+ </button>
272
+ <span data-slot="replay-preview-time">
273
+ <span>{formatReplayTime(time)}</span> / {formatReplayTime(duration)}
274
+ </span>
275
+ <Scrubber
276
+ time={time}
277
+ duration={duration}
278
+ markers={shown}
279
+ disabled={!ready}
280
+ onSeek={(ms) => handle.seek(ms)}
281
+ onMarker={jumpTo}
282
+ />
283
+ <button type="button" data-slot="replay-preview-speed" aria-label={`Playback speed ${speed}×`} onClick={() => cycleSpeed(1)}>
284
+ {`${speed}×`}
285
+ </button>
286
+ <button type="button" data-slot="replay-preview-fullscreen" aria-label={fullscreen ? 'Exit full screen' : 'Full screen'} onClick={toggleFullscreen}>
287
+ {fullscreen ? <Minimize size={15} /> : <Maximize size={15} />}
288
+ </button>
289
+ </div>
290
+ ) : null}
291
+ </div>
292
+ )
293
+ }
294
+
295
+ /* ── parts ───────────────────────────────────────────────────────────────────────── */
296
+
297
+ export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
298
+ const shown = href.replace(/^https?:\/\//, '')
299
+ const slash = shown.indexOf('/')
300
+ const host = slash < 0 ? shown : shown.slice(0, slash)
301
+ const path = slash < 0 ? '' : shown.slice(slash)
302
+ return (
303
+ <div data-slot="replay-preview-bar">
304
+ {trafficLights ? (
305
+ <span aria-hidden="true" data-slot="replay-preview-lights">
306
+ {TRAFFIC_LIGHTS.map((c) => <span key={c} style={{ '--light': c } as CSSProperties} />)}
307
+ </span>
308
+ ) : null}
309
+ <div data-slot="replay-preview-address">
310
+ <span data-slot="replay-preview-lock" aria-hidden="true">
311
+ {loading ? <span data-slot="replay-preview-spinner" /> : <Lock size={10} strokeWidth={2.5} />}
312
+ </span>
313
+ <span data-slot="replay-preview-url" title={href}>
314
+ <span>{host}</span>
315
+ <span>{path}</span>
316
+ </span>
317
+ </div>
318
+ {trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
319
+ </div>
320
+ )
321
+ }
322
+
323
+ /** The recorded pointer and its click ripples, in recorded-viewport coordinates. */
324
+ const PointerOverlay = memo(function PointerOverlay({ track, time, reduced }: { track: ReplayPointerTrack; time: number; reduced: boolean }) {
325
+ const at = replayPointerAt(track, time)
326
+ const ripples = track.clicks.filter((c) => time >= c.time && time - c.time < RIPPLE_MS)
327
+ return (
328
+ <div data-slot="replay-preview-pointer" aria-hidden="true">
329
+ {ripples.map((c) => {
330
+ const p = (time - c.time) / RIPPLE_MS
331
+ const ease = 1 - (1 - p) ** 3
332
+ return (
333
+ <span
334
+ key={c.time}
335
+ data-slot="replay-preview-ripple"
336
+ style={{
337
+ left: c.x, top: c.y,
338
+ transform: `translate(-50%, -50%) scale(calc(${reduced ? 0.8 : 0.25 + ease * 1.05} / var(--replay-scale, 1)))`,
339
+ opacity: 1 - p,
340
+ }}
341
+ />
342
+ )
343
+ })}
344
+ {at ? (
345
+ <svg
346
+ data-slot="replay-preview-cursor"
347
+ viewBox="0 0 20 24" width="20" height="24"
348
+ style={{ left: at.x - 2, top: at.y - 1, transform: 'scale(calc(1 / var(--replay-scale, 1)))' }}
349
+ >
350
+ <path d="M2.5 1.5v18.2l4.7-4.5 3 6.8 3.1-1.4-3-6.6h6.5z" fill="#000" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
351
+ </svg>
352
+ ) : null}
353
+ </div>
354
+ )
355
+ })
356
+
357
+ function Scrubber({ time, duration, markers, disabled, onSeek, onMarker }: {
358
+ time: number; duration: number; markers: ReplayMarker[]; disabled: boolean
359
+ onSeek: (ms: number) => void; onMarker: (m: ReplayMarker) => void
360
+ }) {
361
+ const max = Math.max(1, duration)
362
+ const value = Math.min(time, max)
363
+ // Dots closer than ~1.5% of the timeline would sit on top of each other: step each one 10px right of the last.
364
+ const nudges = useMemo(() => {
365
+ const out = new Map<string, number>()
366
+ let last: { time: number; n: number } | null = null
367
+ for (const m of [...markers].sort((a, b) => a.time - b.time)) {
368
+ if (m.kind === 'click') continue
369
+ const n: number = last && m.time - last.time < max * 0.015 ? last.n + 1 : 0
370
+ out.set(m.id, n)
371
+ last = { time: m.time, n }
372
+ }
373
+ return out
374
+ }, [markers, max])
375
+ return (
376
+ <div data-slot="replay-preview-scrubber" data-markers={markers.length ? '' : undefined}>
377
+ {markers.length ? (
378
+ <div data-slot="replay-preview-markers">
379
+ {markers.map((m) => (
380
+ <button
381
+ key={m.id}
382
+ type="button"
383
+ data-kind={m.kind}
384
+ aria-label={`${m.label} at ${formatReplayTime(m.time)}`}
385
+ title={`${formatReplayTime(m.time)} · ${MARKER_NOUN[m.kind]}\n${m.detail ? `${m.label} — ${m.detail}` : m.label}`}
386
+ style={{ left: `calc(${(m.time / max) * 100}% + ${(nudges.get(m.id) ?? 0) * 10}px)` }}
387
+ onClick={() => onMarker(m)}
388
+ disabled={disabled}
389
+ />
390
+ ))}
391
+ </div>
392
+ ) : null}
393
+ <input
394
+ type="range"
395
+ aria-label="Playback position"
396
+ aria-valuetext={`${formatReplayTime(value)} of ${formatReplayTime(duration)}`}
397
+ min={0}
398
+ max={max}
399
+ step={10}
400
+ value={value}
401
+ disabled={disabled}
402
+ style={{ '--replay-progress': `${(value / max) * 100}%` } as CSSProperties}
403
+ onChange={(e) => onSeek(Number(e.currentTarget.value))}
404
+ />
405
+ </div>
406
+ )
407
+ }
408
+
409
+ /** A page-shaped placeholder: header, hero lines and a card grid, sized to the frame. */
410
+ function PagePoster({ empty }: { empty: boolean }) {
411
+ return (
412
+ <div data-slot="replay-preview-skeleton">
413
+ <div data-part="header">
414
+ <i style={{ width: '12%' }} />
415
+ <i style={{ width: '8%' }} />
416
+ <i style={{ width: '8%' }} />
417
+ <span />
418
+ <i data-tone="block" style={{ width: '9%' }} />
419
+ </div>
420
+ <div data-part="hero">
421
+ <i style={{ width: '38%', height: 12 }} />
422
+ <i style={{ width: '24%', height: 8 }} />
423
+ </div>
424
+ <div data-part="grid">
425
+ {[0, 1, 2, 3].map((i) => <i key={i} data-tone="block" />)}
426
+ </div>
427
+ {empty ? <span data-part="empty"><span>This recording has no page snapshot to play</span></span> : null}
428
+ </div>
429
+ )
430
+ }
@@ -1,7 +1,12 @@
1
- import { Suspense, lazy, useEffect, useState } from 'react';
1
+ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
2
+ import { BrowserBar, ReplayPlayer } from './ReplayPlayer';
3
+ import type { ReplayPlayerOptions, ReplayPreviewHandle } from './ReplayPlayer';
2
4
  import type { ReplayEvent } from './types';
3
5
  import { normalizeReplayEmbedUrl } from './url';
4
6
 
7
+ export { formatReplayTime } from './ReplayPlayer';
8
+ export type { ReplayChrome, ReplayPlayerOptions, ReplayPreviewHandle } from './ReplayPlayer';
9
+
5
10
  export interface ReplayPageSource {
6
11
  kind: 'url';
7
12
  url: string;
@@ -26,21 +31,25 @@ export type ReplaySource =
26
31
  | ReplayEventsSource
27
32
  | ReplayEventsUrlSource;
28
33
 
29
- export interface ReplayPreviewProps {
30
- /** A public Loop QA URL, a public event endpoint, or an in-memory rrweb event list. */
31
- source: ReplaySource;
32
- /** Avoid mounting the rrweb player until the editor block is visible. */
34
+ interface ReplayPreviewCommon extends ReplayPlayerOptions {
35
+ /** Keep the player unmounted (a placeholder frame) until true, e.g. until an editor block is visible. */
33
36
  active?: boolean;
34
- /** Public-page embeds default to lazy loading; native rrweb playback defaults to paused. */
35
- autoplay?: boolean;
36
- loop?: boolean;
37
- className?: string;
38
- title?: string;
39
- /** Playback speed options mirror the Loop QA player. */
40
- speeds?: number[];
41
37
  }
42
38
 
43
- const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
39
+ /** Play an rrweb recording you already hold. */
40
+ export interface ReplayPreviewEventsProps extends ReplayPreviewCommon {
41
+ events: ReplayEvent[];
42
+ source?: undefined;
43
+ }
44
+
45
+ /** Embed a public Loop QA URL, a public event endpoint, or an in-memory rrweb event list. */
46
+ export interface ReplayPreviewSourceProps extends ReplayPreviewCommon {
47
+ source: ReplaySource;
48
+ events?: undefined;
49
+ }
50
+
51
+ /** Pass `events` (the player API) or `source` (the embed API): both render the same player. */
52
+ export type ReplayPreviewProps = ReplayPreviewEventsProps | ReplayPreviewSourceProps;
44
53
 
45
54
  function sourcePage(source: ReplaySource): ReplayPageSource | null {
46
55
  if (typeof source === 'string') return { kind: 'url', url: source };
@@ -59,50 +68,64 @@ function sourceEventsUrl(source: ReplaySource): ReplayEventsUrlSource | null {
59
68
  return source.kind === 'events-url' ? source : null;
60
69
  }
61
70
 
62
- function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: string }) {
63
- const url = normalizeReplayEmbedUrl(source.url);
64
- if (!url) {
65
- return (
66
- <a className="replay-preview__invalid" href={source.url} target="_blank" rel="noreferrer">
67
- Open replay
68
- </a>
69
- );
70
- }
71
+ /** The frame around a non-player body (an iframe, a loading or error note): same root, same chrome hooks. */
72
+ function Frame({
73
+ kind, chrome, className, title, href, trafficLights = true, ar, style, children,
74
+ }: {
75
+ kind: string;
76
+ chrome: NonNullable<ReplayPlayerOptions['chrome']>;
77
+ className?: string;
78
+ title?: string;
79
+ href?: string;
80
+ trafficLights?: boolean;
81
+ ar?: string;
82
+ style?: CSSProperties;
83
+ children: ReactNode;
84
+ }) {
71
85
  return (
72
- <iframe
73
- className="replay-preview__frame"
74
- src={url}
75
- title={title ?? source.title ?? 'Replay preview'}
76
- loading="lazy"
77
- allow="autoplay; fullscreen"
78
- allowFullScreen
79
- referrerPolicy="strict-origin-when-cross-origin"
80
- />
86
+ <div
87
+ className={className ? `replay-preview ${className}` : 'replay-preview'}
88
+ data-slot="replay-preview"
89
+ data-replay-preview={kind}
90
+ data-chrome={chrome}
91
+ data-fit="width"
92
+ style={style}
93
+ {...(title ? { role: 'group', 'aria-label': title } : null)}
94
+ >
95
+ {chrome === 'browser' ? <BrowserBar href={href ?? ''} trafficLights={trafficLights} loading={false} /> : null}
96
+ <div data-slot="replay-preview-stage" style={ar ? ({ '--replay-ar': ar } as CSSProperties) : undefined}>
97
+ {children}
98
+ </div>
99
+ </div>
81
100
  );
82
101
  }
83
102
 
84
- // rrweb (and its stylesheet) load on first use of an event-based source.
85
- const LazyNativeReplay = lazy(() => import('./NativeReplay'));
86
-
87
- function NativeReplay(props: { events: ReplayEvent[]; autoplay?: boolean; loop?: boolean; speeds?: number[] }) {
103
+ function ReplayFrame({
104
+ source, title, chrome = 'minimal', className, style, trafficLights,
105
+ }: { source: ReplayPageSource } & Pick<ReplayPlayerOptions, 'title' | 'chrome' | 'className' | 'style' | 'trafficLights'>) {
106
+ const url = normalizeReplayEmbedUrl(source.url);
88
107
  return (
89
- <Suspense fallback={<div className="replay-preview__empty">Loading replay…</div>}>
90
- <LazyNativeReplay {...props} />
91
- </Suspense>
108
+ <Frame kind="iframe" chrome={chrome} className={className} style={style} href={url ?? source.url} trafficLights={trafficLights} ar="16 / 9">
109
+ {url ? (
110
+ <iframe
111
+ className="replay-preview__frame"
112
+ src={url}
113
+ title={title ?? source.title ?? 'Replay preview'}
114
+ loading="lazy"
115
+ allow="autoplay; fullscreen"
116
+ allowFullScreen
117
+ referrerPolicy="strict-origin-when-cross-origin"
118
+ />
119
+ ) : (
120
+ <a className="replay-preview__invalid" href={source.url} target="_blank" rel="noreferrer">
121
+ Open replay
122
+ </a>
123
+ )}
124
+ </Frame>
92
125
  );
93
126
  }
94
127
 
95
- function RemoteReplay({
96
- source,
97
- autoplay,
98
- loop,
99
- speeds,
100
- }: {
101
- source: ReplayEventsUrlSource;
102
- autoplay?: boolean;
103
- loop?: boolean;
104
- speeds?: number[];
105
- }) {
128
+ function RemoteReplay({ source, options }: { source: ReplayEventsUrlSource; options: ReplayPlayerOptions }) {
106
129
  const [events, setEvents] = useState<ReplayEvent[] | null>(null);
107
130
  const [error, setError] = useState<string | null>(null);
108
131
 
@@ -130,48 +153,51 @@ function RemoteReplay({
130
153
  return () => controller.abort();
131
154
  }, [source.url, source.fetchInit]);
132
155
 
133
- if (error) return <div className="replay-preview__error" role="alert">{error}</div>;
134
- if (!events) return <div className="replay-preview__empty">Loading replay…</div>;
135
- return <NativeReplay events={events} autoplay={autoplay} loop={loop} speeds={speeds} />;
156
+ if (events) return <ReplayPlayer {...options} events={events} />;
157
+ return (
158
+ <Frame kind="rrweb" chrome={options.chrome ?? 'browser'} className={options.className} style={options.style} href={options.url} trafficLights={options.trafficLights} ar="16 / 9">
159
+ {error ? (
160
+ <div className="replay-preview__error" role="alert">{error}</div>
161
+ ) : (
162
+ <div className="replay-preview__empty">Loading replay…</div>
163
+ )}
164
+ </Frame>
165
+ );
136
166
  }
137
167
 
138
- export function ReplayPreview({
139
- source,
140
- active = true,
141
- autoplay = false,
142
- loop = false,
143
- className,
144
- title,
145
- speeds = DEFAULT_SPEEDS,
146
- }: ReplayPreviewProps) {
147
- if (!active) return <div className={`replay-preview ${className ?? ''}`.trim()} data-replay-preview="paused" />;
148
-
149
- const page = sourcePage(source);
150
- const events = sourceEvents(source);
151
- const eventsUrl = sourceEventsUrl(source);
152
- const rootClass = `replay-preview ${className ?? ''}`.trim();
153
-
154
- if (page) {
155
- return (
156
- <div className={rootClass} data-replay-preview="iframe">
157
- <ReplayFrame source={page} title={title} />
158
- </div>
159
- );
160
- }
161
- if (events) {
168
+ /**
169
+ * A session replay in a browser frame. Pass `events` (an rrweb recording) to play it with the full player:
170
+ * URL bar, poster, scrubber with markers, pointer overlay, keyboard map, fullscreen. Or pass `source` (a
171
+ * public Loop QA URL, an events URL, or an event list), which renders the same player, or an iframe for
172
+ * page URLs. rrweb loads on first render of an event player, never for iframes.
173
+ */
174
+ export function ReplayPreview(props: ReplayPreviewProps) {
175
+ const { active = true, source, events, ...options } = props;
176
+ const { chrome, className, style } = options;
177
+
178
+ if (!active) {
162
179
  return (
163
- <div className={rootClass} data-replay-preview="rrweb">
164
- <NativeReplay events={events.events} autoplay={autoplay} loop={loop} speeds={speeds} />
165
- </div>
180
+ <div
181
+ className={className ? `replay-preview ${className}` : 'replay-preview'}
182
+ data-slot="replay-preview"
183
+ data-replay-preview="paused"
184
+ data-chrome={chrome ?? 'minimal'}
185
+ style={style}
186
+ />
166
187
  );
167
188
  }
168
- if (eventsUrl) {
169
- return (
170
- <div className={rootClass} data-replay-preview="rrweb">
171
- <RemoteReplay source={eventsUrl} autoplay={autoplay} loop={loop} speeds={speeds} />
172
- </div>
173
- );
189
+
190
+ if (events) return <ReplayPlayer {...options} events={events} />;
191
+ if (source === undefined) return null;
192
+
193
+ const page = sourcePage(source);
194
+ if (page) {
195
+ return <ReplayFrame source={page} title={options.title} chrome={chrome} className={className} style={style} trafficLights={options.trafficLights} />;
174
196
  }
197
+ const list = sourceEvents(source);
198
+ if (list) return <ReplayPlayer {...options} events={list.events} />;
199
+ const remote = sourceEventsUrl(source);
200
+ if (remote) return <RemoteReplay source={remote} options={options} />;
175
201
  return null;
176
202
  }
177
203