Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): create a wrapper class to init rrweb player alongside video replayer #69927

Merged
merged 12 commits into from
May 8, 2024
61 changes: 36 additions & 25 deletions static/app/components/replays/replayContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ReplayPrefs,
} from 'sentry/components/replays/preferences/replayPreferences';
import useReplayHighlighting from 'sentry/components/replays/useReplayHighlighting';
import {WrapperReplayer} from 'sentry/components/replays/wrapperReplayer';
import {trackAnalytics} from 'sentry/utils/analytics';
import clamp from 'sentry/utils/number/clamp';
import type useInitialOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
Expand All @@ -26,7 +27,6 @@ import useProjectFromId from 'sentry/utils/useProjectFromId';
import {useUser} from 'sentry/utils/useUser';

import {CanvasReplayerPlugin} from './canvasReplayerPlugin';
import {VideoReplayer} from './videoReplayer';

type Dimensions = {height: number; width: number};
type RootElem = null | HTMLDivElement;
Expand Down Expand Up @@ -474,34 +474,43 @@ function ProviderNonMemo({
return null;
}

// check if this is a video replay and if we can use the video replayer
// check if this is a video replay and if we can use the video (wrapper) replayer
if (!isVideoReplay || !videoEvents || !startTimestampMs) {
return null;
}

const inst = new VideoReplayer(videoEvents, {
videoApiPrefix: `/api/0/projects/${
organization.slug
}/${projectSlug}/replays/${replay?.getReplay().id}/videos/`,
root,
start: startTimestampMs,
onFinished: setReplayFinished,
onLoaded: event => {
const {videoHeight, videoWidth} = event.target;
if (!videoHeight || !videoWidth) {
return;
}
setDimensions({
height: videoHeight,
width: videoWidth,
});
},
onBuffer: buffering => {
setVideoBuffering(buffering);
},
clipWindow,
durationMs,
});
// This is a wrapper class that wraps both the VideoReplayer
// and the rrweb Replayer
const inst = new WrapperReplayer(
{videoEvents, events: events ?? []},
{
// video specific
videoApiPrefix: `/api/0/projects/${
organization.slug
}/${projectSlug}/replays/${replay?.getReplay().id}/videos/`,
start: startTimestampMs,
onFinished: setReplayFinished,
onLoaded: event => {
const {videoHeight, videoWidth} = event.target;
if (!videoHeight || !videoWidth) {
return;
}
setDimensions({
height: videoHeight,
width: videoWidth,
});
},
onBuffer: buffering => {
setVideoBuffering(buffering);
},
clipWindow,
durationMs,
// rrweb specific
theme,
// common to both
root,
}
);
// `.current` is marked as readonly, but it's safe to set the value from
// inside a `useEffect` hook.
// See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
Expand All @@ -520,6 +529,7 @@ function ProviderNonMemo({
isFetching,
isVideoReplay,
videoEvents,
events,
organization.slug,
projectSlug,
replay,
Expand All @@ -528,6 +538,7 @@ function ProviderNonMemo({
startTimeOffsetMs,
clipWindow,
durationMs,
theme,
]
);

Expand Down
2 changes: 1 addition & 1 deletion static/app/components/replays/videoReplayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface VideoReplayerOptions {
clipWindow?: ClipWindow;
}

interface VideoReplayerConfig {
export interface VideoReplayerConfig {
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
/**
* Not supported, only here to maintain compat w/ rrweb player
*/
Expand Down
153 changes: 153 additions & 0 deletions static/app/components/replays/wrapperReplayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type {Theme} from '@emotion/react';
import {type eventWithTime, Replayer} from '@sentry-internal/rrweb';

import {
VideoReplayer,
type VideoReplayerConfig,
} from 'sentry/components/replays/videoReplayer';
import type {ClipWindow, VideoEvent} from 'sentry/utils/replays/types';

type RootElem = HTMLDivElement | null;

interface WrapperReplayerOptions {
durationMs: number;
onBuffer: (isBuffering: boolean) => void;
onFinished: () => void;
onLoaded: (event: any) => void;
root: RootElem;
start: number;
theme: Theme;
videoApiPrefix: string;
clipWindow?: ClipWindow;
}

/**
* A wrapper replayer that wraps both VideoReplayer and the rrweb Replayer.
* We need both instances in order to render the video playback alongside gestures.
*/
export class WrapperReplayer {
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
public config: VideoReplayerConfig = {
skipInactive: false,
speed: 1.0,
};
public iframe = {};
public videoInst: VideoReplayer;
public rrwebInst: Replayer;
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved

constructor(
{videoEvents, events}: {events: eventWithTime[]; videoEvents: VideoEvent[]},
{
root,
start,
videoApiPrefix,
onBuffer,
onFinished,
onLoaded,
clipWindow,
durationMs,
theme,
}: WrapperReplayerOptions
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
) {
this.videoInst = new VideoReplayer(videoEvents, {
videoApiPrefix,
root,
start,
onFinished,
onLoaded,
onBuffer,
clipWindow,
durationMs,
});

const metaEventIdx = events.findIndex(e => e.type === 4);

// Create a mock full snapshot event, in order to render rrweb gestures properly
// The hardcoded data.node.id here should match the ID of the data being sent
// in the `positions` arrays
const fullSnapshotEvent = {
type: 2,
data: {
node: {
type: 0,
childNodes: [
{
type: 1,
name: 'html',
publicId: '',
systemId: '',
id: 0,
},
{
type: 2,
tagName: 'html',
attributes: {
lang: 'en',
},
childNodes: [],
id: 0,
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
},
],
id: 0,
},
initialOffset: {
left: 0,
top: 0,
},
},
timestamp: events[metaEventIdx].timestamp,
};

// Insert after the meta event
events.splice(metaEventIdx + 1, 0, fullSnapshotEvent);

this.rrwebInst = new Replayer(events, {
root: root as Element,
blockClass: 'sentry-block',
mouseTail: {
duration: 0.75 * 1000,
lineCap: 'round',
lineWidth: 2,
strokeStyle: theme.purple200,
},
plugins: [],
skipInactive: false,
speed: this.config.speed,
});
}

public destroy() {
this.videoInst.destroy();
this.rrwebInst.destroy();
}

/**
* Returns the current video time, using the video's external timer.
*/
public getCurrentTime() {
return this.videoInst.getCurrentTime();
}

/**
* Play both the rrweb and video player.
*/
public play(videoOffsetMs: number) {
this.videoInst.play(videoOffsetMs);
this.rrwebInst.play(videoOffsetMs);
}

/**
* Pause both the rrweb and video player.
*/
public pause(videoOffsetMs: number) {
this.videoInst.pause(videoOffsetMs);
this.rrwebInst.pause(videoOffsetMs);
}

/**
* Equivalent to rrweb's `setConfig()`, but here we only support the `speed` configuration.
michellewzhang marked this conversation as resolved.
Show resolved Hide resolved
*/
public setConfig(config: Partial<VideoReplayerConfig>): void {
this.videoInst.setConfig(config);
this.rrwebInst.setConfig(config);
}
}
Loading