diff --git a/package.json b/package.json
index f0cf05554..5308cecd4 100644
--- a/package.json
+++ b/package.json
@@ -113,7 +113,7 @@
"redis": "^5.12.1",
"request-ip": "^3.3.0",
"rrweb": "^2.0.1",
- "rrweb-player": "^2.0.1",
+ "rrweb-player": "2.0.0-alpha.20",
"semver": "^7.8.4",
"serialize-error": "^13.0.1",
"thenby": "^1.4.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1dd220b99..83d026e2c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -189,8 +189,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1
rrweb-player:
- specifier: ^2.0.1
- version: 2.0.1
+ specifier: 2.0.0-alpha.20
+ version: 2.0.0-alpha.20
semver:
specifier: ^7.8.4
version: 7.8.4
@@ -5610,8 +5610,8 @@ packages:
rrdom@2.0.1:
resolution: {integrity: sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==}
- rrweb-player@2.0.1:
- resolution: {integrity: sha512-v8PVnOy7Q3kXqWriGuzZo4ndGEV06nCMRkH/jSHweJfTLwpLqb2evgMj0S5FwQLsN2X1VM+L2/i7QWGJfgj9zw==}
+ rrweb-player@2.0.0-alpha.20:
+ resolution: {integrity: sha512-3ZCv1ksUxuIOn3Vn/eWrwWs9Xy+4KVjISD+q26ZLfisZ3hZ0CPgYG3iC22pmmycIeMS2svOfvf7gPh7jExwpUA==}
rrweb-snapshot@2.0.1:
resolution: {integrity: sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==}
@@ -11638,7 +11638,7 @@ snapshots:
dependencies:
rrweb-snapshot: 2.0.1
- rrweb-player@2.0.1:
+ rrweb-player@2.0.0-alpha.20:
dependencies:
'@rrweb/packer': 2.0.1
'@rrweb/replay': 2.0.1
diff --git a/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx b/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx
index 2d498d84f..4950ccb54 100644
--- a/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx
+++ b/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx
@@ -7,5 +7,5 @@ export default async function ({
}) {
const { websiteId, sessionId } = await params;
- return ;
+ return ;
}
diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx
index 7127a613d..4b37264ba 100644
--- a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx
+++ b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx
@@ -2,21 +2,32 @@
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
import { useNavigation } from '@/components/hooks';
+import { buildPath } from '@/lib/url';
export interface ReplayModalProps extends ModalProps {
websiteId: string;
+ replayId?: string;
}
-export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
+export function ReplayModal({ websiteId, replayId, ...props }: ReplayModalProps) {
const {
router,
query: { replay },
+ searchParams,
updateParams,
} = useNavigation();
+ const activeReplayId = replayId || replay;
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
- router.push(updateParams({ replay: undefined }));
+ if (replayId) {
+ const query = Object.fromEntries(searchParams.entries());
+ delete query.replay;
+
+ router.push(buildPath(`/websites/${websiteId}/replays`, query));
+ } else {
+ router.push(updateParams({ replay: undefined }));
+ }
}
};
@@ -24,7 +35,7 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
{({ close }) => (
-
+ {activeReplayId && (
+
+ )}
)}
diff --git a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx
index 95ca3e883..960f8e4a0 100644
--- a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx
+++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx
@@ -1,71 +1,124 @@
'use client';
import { Column } from '@umami/react-zen';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMobile } from '@/components/hooks';
-import { hasReplayFullSnapshot } from '@/lib/replay';
+import { getReplayPlayerEvents, getReplayViewport } from '@/lib/replay';
import 'rrweb-player/dist/style.css';
+const DEFAULT_REPLAY_ASPECT_RATIO = 9 / 16;
+
export function ReplayPlayer({ events }: { events: any[] }) {
- const containerRef = useRef(null);
+ const playerRootRef = useRef(null);
const playerRef = useRef(null);
- const [loaded, setLoaded] = useState(false);
+ const [playerError, setPlayerError] = useState(false);
const { isMobile, isPhone } = useMobile();
- const canReplay = hasReplayFullSnapshot(events);
- const showUnavailable = !events?.length || !canReplay;
+ const replayEvents = useMemo(() => getReplayPlayerEvents(events), [events]);
+ const replayViewport = useMemo(() => getReplayViewport(replayEvents), [replayEvents]);
+ const canReplay = replayEvents.length >= 2;
+ const showUnavailable = !events?.length || !canReplay || playerError;
const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
- const playerHeight = isPhone ? 202 : isMobile ? 360 : 576;
+ const replayAspectRatio = replayViewport
+ ? replayViewport.height / replayViewport.width
+ : DEFAULT_REPLAY_ASPECT_RATIO;
+ const playerHeight = Math.round(
+ playerWidth * Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 1.5),
+ );
+ const playerOuterHeight = playerHeight + 80;
useEffect(() => {
- if (!containerRef.current || !events?.length || !canReplay) return;
+ let cancelled = false;
+ const playerRoot = playerRootRef.current;
- import('rrweb-player').then(mod => {
- const RRWebPlayer = mod.default;
+ setPlayerError(false);
- if (containerRef.current) {
- containerRef.current.innerHTML = '';
- }
+ if (playerRef.current) {
+ playerRef.current.$destroy?.();
+ playerRef.current = null;
+ }
- playerRef.current = new RRWebPlayer({
- target: containerRef.current,
- props: {
- events: events,
- width: playerWidth,
- height: playerHeight,
- autoPlay: false,
- showController: true,
- speedOption: [1, 2, 4, 8],
- useVirtualDom: false,
- showWarning: false,
- },
+ playerRoot?.replaceChildren();
+
+ if (!playerRoot || !canReplay) return;
+
+ import('rrweb-player')
+ .then(mod => {
+ if (cancelled) return;
+
+ const RRWebPlayer = mod.default;
+
+ playerRoot.replaceChildren();
+
+ try {
+ const player = new RRWebPlayer({
+ target: playerRoot,
+ props: {
+ events: replayEvents,
+ width: playerWidth,
+ height: playerHeight,
+ autoPlay: false,
+ showController: true,
+ speedOption: [1, 2, 4, 8],
+ useVirtualDom: false,
+ showWarning: false,
+ },
+ });
+
+ if (cancelled) {
+ (player as any).$destroy?.();
+ return;
+ }
+
+ playerRef.current = player;
+ } catch {
+ playerRoot.replaceChildren();
+
+ if (!cancelled) {
+ setPlayerError(true);
+ }
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setPlayerError(true);
+ }
});
- setLoaded(true);
- });
-
return () => {
+ cancelled = true;
+
if (playerRef.current) {
playerRef.current.$destroy?.();
playerRef.current = null;
}
+
+ playerRoot.replaceChildren();
};
- }, [canReplay, events, playerWidth, playerHeight]);
+ }, [canReplay, replayEvents, playerWidth, playerHeight]);
return (
-
+
diff --git a/src/lib/replay.test.ts b/src/lib/replay.test.ts
index d875b10e5..db93bfad7 100644
--- a/src/lib/replay.test.ts
+++ b/src/lib/replay.test.ts
@@ -1,15 +1,78 @@
import { expect, test } from 'vitest';
import {
+ canReplayEvents,
getReplayEventCount,
+ getReplayPlayerEvents,
+ getReplayViewport,
+ hasReplayableFullSnapshot,
hasReplayFullSnapshot,
+ hasReplayMeta,
REPLAY_EVENT_FRAGMENT_TYPE,
restoreReplayEventFragments,
} from './replay';
+const fullSnapshot = {
+ type: 2,
+ timestamp: 1781553116151,
+ data: {
+ node: {
+ type: 0,
+ id: 1,
+ childNodes: [{ type: 2, id: 2, tagName: 'html', attributes: {}, childNodes: [] }],
+ },
+ initialOffset: { left: 0, top: 0 },
+ },
+};
+
test('hasReplayFullSnapshot returns true when a full snapshot is present', () => {
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
});
+test('hasReplayMeta returns true when a meta event is present', () => {
+ expect(hasReplayMeta([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
+});
+
+test('getReplayViewport returns the recorded viewport dimensions', () => {
+ expect(getReplayViewport(null)).toBe(null);
+ expect(getReplayViewport([{ type: 4, data: { width: 0, height: 768 } }])).toBe(null);
+ expect(getReplayViewport([{ type: 4, data: { width: '1024', height: '1365' } }])).toEqual({
+ width: 1024,
+ height: 1365,
+ });
+});
+
+test('hasReplayableFullSnapshot returns true when a usable full snapshot is present', () => {
+ expect(hasReplayableFullSnapshot([{ type: 4 }, fullSnapshot, { type: 3 }])).toBe(true);
+});
+
+test('canReplayEvents requires events that can be normalized with a full snapshot', () => {
+ expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, fullSnapshot])).toBe(true);
+ expect(canReplayEvents([fullSnapshot])).toBe(true);
+ expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 2 }])).toBe(false);
+ expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 3 }])).toBe(false);
+ expect(canReplayEvents([{ ...fullSnapshot, timestamp: undefined }])).toBe(true);
+});
+
+test('getReplayPlayerEvents normalizes events for rrweb-player', () => {
+ expect(getReplayPlayerEvents(null)).toEqual([]);
+ expect(getReplayPlayerEvents([{ type: 3, timestamp: 1781553116160 }])).toEqual([]);
+
+ expect(getReplayPlayerEvents([fullSnapshot])).toEqual([
+ fullSnapshot,
+ { ...fullSnapshot, timestamp: fullSnapshot.timestamp + 1 },
+ ]);
+
+ expect(
+ getReplayPlayerEvents([
+ { type: 0, timestamp: '1781553116149' },
+ { ...fullSnapshot, timestamp: undefined },
+ ]),
+ ).toEqual([
+ { type: 0, timestamp: 1781553116149 },
+ { ...fullSnapshot, timestamp: 1781553116150 },
+ ]);
+});
+
test('hasReplayFullSnapshot returns false when only incremental events are present', () => {
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 3 }, { type: 3 }])).toBe(false);
});
@@ -20,16 +83,6 @@ test('hasReplayFullSnapshot handles missing events', () => {
});
test('restoreReplayEventFragments restores fragmented events', () => {
- const fullSnapshot = {
- type: 2,
- timestamp: 1781553116151,
- data: {
- node: {
- type: 0,
- childNodes: [{ type: 2, tagName: 'html' }],
- },
- },
- };
const serialized = JSON.stringify(fullSnapshot);
const splitAt = Math.floor(serialized.length / 2);
diff --git a/src/lib/replay.ts b/src/lib/replay.ts
index 0ad8c96ba..5a8b3c8cb 100644
--- a/src/lib/replay.ts
+++ b/src/lib/replay.ts
@@ -1,4 +1,5 @@
export const RRWEB_EVENT_TYPE = {
+ Meta: 4,
FullSnapshot: 2,
} as const;
@@ -106,3 +107,105 @@ export function hasReplayFullSnapshot(events: any[] | null | undefined) {
Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.FullSnapshot)
);
}
+
+export function hasReplayableFullSnapshot(events: any[] | null | undefined) {
+ return (
+ Array.isArray(events) &&
+ events.some(event => {
+ const { data } = event || {};
+ const { node } = data || {};
+
+ return (
+ event?.type === RRWEB_EVENT_TYPE.FullSnapshot &&
+ Number.isInteger(node?.type) &&
+ Array.isArray(node?.childNodes)
+ );
+ })
+ );
+}
+
+export function hasReplayMeta(events: any[] | null | undefined) {
+ return Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.Meta);
+}
+
+function getReplayDimension(value: any) {
+ const dimension = Number(value);
+
+ return Number.isFinite(dimension) && dimension > 0 ? dimension : null;
+}
+
+export function getReplayViewport(events: any[] | null | undefined) {
+ if (!Array.isArray(events)) {
+ return null;
+ }
+
+ const meta = events.find(event => {
+ const width = getReplayDimension(event?.data?.width);
+ const height = getReplayDimension(event?.data?.height);
+
+ return event?.type === RRWEB_EVENT_TYPE.Meta && width && height;
+ });
+
+ if (!meta) {
+ return null;
+ }
+
+ return {
+ width: getReplayDimension(meta.data.width) as number,
+ height: getReplayDimension(meta.data.height) as number,
+ };
+}
+
+function getReplayTimestamp(event: any) {
+ const timestamp = Number(event?.timestamp);
+
+ return Number.isFinite(timestamp) ? timestamp : null;
+}
+
+export function getReplayPlayerEvents(events: any[] | null | undefined) {
+ if (!Array.isArray(events)) {
+ return [];
+ }
+
+ const replayEvents = events.filter(
+ event => event && typeof event === 'object' && event.type !== undefined && event.type !== null,
+ );
+
+ if (!hasReplayableFullSnapshot(replayEvents)) {
+ return [];
+ }
+
+ const firstTimestamp =
+ replayEvents.map(getReplayTimestamp).find(timestamp => timestamp !== null) ?? Date.now();
+ let lastTimestamp = firstTimestamp - 1;
+
+ const normalizedEvents = replayEvents.map(event => {
+ const timestamp = getReplayTimestamp(event);
+
+ if (timestamp !== null) {
+ lastTimestamp = timestamp;
+
+ return typeof event.timestamp === 'number' ? event : { ...event, timestamp };
+ }
+
+ lastTimestamp += 1;
+
+ return { ...event, timestamp: lastTimestamp };
+ });
+
+ if (normalizedEvents.length === 1) {
+ return [
+ normalizedEvents[0],
+ {
+ ...normalizedEvents[0],
+ timestamp: normalizedEvents[0].timestamp + 1,
+ },
+ ];
+ }
+
+ return normalizedEvents;
+}
+
+export function canReplayEvents(events: any[] | null | undefined) {
+ return getReplayPlayerEvents(events).length >= 2;
+}