replayContext.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import {Replayer, ReplayerEvents} from '@sentry-internal/rrweb';
  4. import useReplayHighlighting from 'sentry/components/replays/useReplayHighlighting';
  5. import {VideoReplayerWithInteractions} from 'sentry/components/replays/videoReplayerWithInteractions';
  6. import {trackAnalytics} from 'sentry/utils/analytics';
  7. import clamp from 'sentry/utils/number/clamp';
  8. import type useInitialOffsetMs from 'sentry/utils/replays/hooks/useInitialTimeOffsetMs';
  9. import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
  10. import {ReplayCurrentTimeContextProvider} from 'sentry/utils/replays/playback/providers/useCurrentHoverTime';
  11. import type ReplayReader from 'sentry/utils/replays/replayReader';
  12. import type {Dimensions} from 'sentry/utils/replays/types';
  13. import useOrganization from 'sentry/utils/useOrganization';
  14. import usePrevious from 'sentry/utils/usePrevious';
  15. import useProjectFromId from 'sentry/utils/useProjectFromId';
  16. import useRAF from 'sentry/utils/useRAF';
  17. import {useUser} from 'sentry/utils/useUser';
  18. import {CanvasReplayerPlugin} from './canvasReplayerPlugin';
  19. type RootElem = null | HTMLDivElement;
  20. type HighlightCallbacks = ReturnType<typeof useReplayHighlighting>;
  21. // Important: Don't allow context Consumers to access `Replayer` directly.
  22. // It has state that, when changed, will not trigger a react render.
  23. // Instead only expose methods that wrap `Replayer` and manage state.
  24. interface ReplayPlayerContextProps extends HighlightCallbacks {
  25. /**
  26. * The context in which the replay is being viewed.
  27. */
  28. analyticsContext: string;
  29. /**
  30. * The current time of the video, in milliseconds
  31. * The value is updated on every animation frame, about every 16.6ms
  32. */
  33. currentTime: number;
  34. /**
  35. * Original dimensions in pixels of the captured browser window
  36. */
  37. dimensions: Dimensions;
  38. /**
  39. * The calculated speed of the player when fast-forwarding through idle moments in the video
  40. * The value is set to `0` when the video is not fast-forwarding
  41. * The speed is automatically determined by the length of each idle period
  42. */
  43. fastForwardSpeed: number;
  44. /**
  45. * Set to true while the library is reconstructing the DOM
  46. */
  47. isBuffering: boolean;
  48. /**
  49. * Is the data inside the `replay` complete, or are we waiting for more.
  50. */
  51. isFetching;
  52. /**
  53. * Set to true when the replay finish event is fired
  54. */
  55. isFinished: boolean;
  56. /**
  57. * Whether the video is currently playing
  58. */
  59. isPlaying: boolean;
  60. /**
  61. * Set to true while the current video is loading (this is used
  62. * only for video replays and in lieu of `isBuffering`)
  63. */
  64. isVideoBuffering: boolean;
  65. /**
  66. * Whether the replay is considered a video replay
  67. */
  68. isVideoReplay: boolean;
  69. /**
  70. * The core replay data
  71. */
  72. replay: ReplayReader | null;
  73. /**
  74. * Restart the replay
  75. */
  76. restart: () => void;
  77. /**
  78. * Jump the video to a specific time
  79. */
  80. setCurrentTime: (time: number) => void;
  81. /**
  82. * Required to be called with a <div> Ref
  83. * Represents the location in the DOM where the iframe video should be mounted
  84. *
  85. * @param root
  86. */
  87. setRoot: (root: RootElem) => void;
  88. /**
  89. * Start or stop playback
  90. *
  91. * @param play
  92. */
  93. togglePlayPause: (play: boolean) => void;
  94. }
  95. const ReplayPlayerContext = createContext<ReplayPlayerContextProps>({
  96. analyticsContext: '',
  97. clearAllHighlights: () => {},
  98. currentTime: 0,
  99. dimensions: {height: 0, width: 0},
  100. fastForwardSpeed: 0,
  101. addHighlight: () => {},
  102. isBuffering: false,
  103. isVideoBuffering: false,
  104. isFetching: false,
  105. isFinished: false,
  106. isPlaying: false,
  107. isVideoReplay: false,
  108. removeHighlight: () => {},
  109. replay: null,
  110. restart: () => {},
  111. setCurrentTime: () => {},
  112. setRoot: () => {},
  113. togglePlayPause: () => {},
  114. });
  115. type Props = {
  116. /**
  117. * The context in which the replay is being viewed.
  118. * Attached to certain analytics events.
  119. */
  120. analyticsContext: string;
  121. children: React.ReactNode;
  122. /**
  123. * Is the data inside the `replay` complete, or are we waiting for more.
  124. */
  125. isFetching: boolean;
  126. replay: ReplayReader | null;
  127. /**
  128. * Start the video as soon as it's ready
  129. */
  130. autoStart?: boolean;
  131. /**
  132. * Time, in seconds, when the video should start
  133. */
  134. initialTimeOffsetMs?: ReturnType<typeof useInitialOffsetMs>;
  135. /**
  136. * Override return fields for testing
  137. */
  138. value?: Partial<ReplayPlayerContextProps>;
  139. };
  140. function useCurrentTime(callback: () => number) {
  141. const [currentTime, setCurrentTime] = useState(0);
  142. useRAF(() => setCurrentTime(callback));
  143. return currentTime;
  144. }
  145. export function Provider({
  146. analyticsContext,
  147. children,
  148. initialTimeOffsetMs,
  149. isFetching,
  150. replay,
  151. autoStart,
  152. value = {},
  153. }: Props) {
  154. const user = useUser();
  155. const organization = useOrganization();
  156. const projectSlug = useProjectFromId({
  157. project_id: replay?.getReplay().project_id,
  158. })?.slug;
  159. const events = replay?.getRRWebFrames();
  160. const [prefs] = useReplayPrefs();
  161. const initialPrefsRef = useRef(prefs); // don't re-mount the player when prefs change, instead there's a useEffect
  162. const theme = useTheme();
  163. const oldEvents = usePrevious(events);
  164. // Note we have to check this outside of hooks, see `usePrevious` comments
  165. const hasNewEvents = events !== oldEvents;
  166. const replayerRef = useRef<Replayer>(null);
  167. const [dimensions, setDimensions] = useState<Dimensions>({height: 0, width: 0});
  168. const [isPlaying, setIsPlaying] = useState(false);
  169. const [finishedAtMS, setFinishedAtMS] = useState<number>(-1);
  170. const [fastForwardSpeed, setFFSpeed] = useState(0);
  171. const [buffer, setBufferTime] = useState({target: -1, previous: -1});
  172. const [isVideoBuffering, setVideoBuffering] = useState(false);
  173. const playTimer = useRef<number | undefined>(undefined);
  174. const didApplyInitialOffset = useRef(false);
  175. const [rootEl, setRoot] = useState<HTMLDivElement | null>(null);
  176. const durationMs = replay?.getDurationMs() ?? 0;
  177. const clipWindow = replay?.getClipWindow() ?? undefined;
  178. const startTimeOffsetMs = replay?.getStartOffsetMs() ?? 0;
  179. const videoEvents = replay?.getVideoEvents();
  180. const startTimestampMs = replay?.getStartTimestampMs();
  181. const isVideoReplay = Boolean(videoEvents?.length);
  182. const {addHighlight, clearAllHighlights, removeHighlight} = useReplayHighlighting({
  183. replayerRef,
  184. });
  185. const getCurrentPlayerTime = useCallback(
  186. () => (replayerRef.current ? Math.max(replayerRef.current.getCurrentTime(), 0) : 0),
  187. []
  188. );
  189. const isFinished = getCurrentPlayerTime() === finishedAtMS;
  190. const setReplayFinished = useCallback(() => {
  191. setFinishedAtMS(getCurrentPlayerTime());
  192. setIsPlaying(false);
  193. }, [getCurrentPlayerTime]);
  194. const privateSetCurrentTime = useCallback(
  195. (requestedTimeMs: number) => {
  196. const replayer = replayerRef.current;
  197. if (!replayer) {
  198. return;
  199. }
  200. const skipInactive = replayer.config.skipInactive;
  201. if (skipInactive) {
  202. // If the replayer is set to skip inactive, we should turn it off before
  203. // manually scrubbing, so when the player resumes playing it's not stuck
  204. // fast-forwarding even through sections with activity
  205. replayer.setConfig({skipInactive: false});
  206. }
  207. const time = clamp(requestedTimeMs, 0, startTimeOffsetMs + durationMs);
  208. // Sometimes rrweb doesn't get to the exact target time, as long as it has
  209. // changed away from the previous time then we can hide then buffering message.
  210. setBufferTime({target: time, previous: getCurrentPlayerTime()});
  211. // Clear previous timers. Without this (but with the setTimeout) multiple
  212. // requests to set the currentTime could finish out of order and cause jumping.
  213. if (playTimer.current) {
  214. window.clearTimeout(playTimer.current);
  215. }
  216. replayer.setConfig({skipInactive});
  217. if (isPlaying) {
  218. playTimer.current = window.setTimeout(() => replayer.play(time), 0);
  219. setIsPlaying(true);
  220. } else {
  221. playTimer.current = window.setTimeout(() => replayer.pause(time), 0);
  222. setIsPlaying(false);
  223. }
  224. },
  225. [startTimeOffsetMs, durationMs, getCurrentPlayerTime, isPlaying]
  226. );
  227. const setCurrentTime = useCallback(
  228. (requestedTimeMs: number) => {
  229. privateSetCurrentTime(requestedTimeMs + startTimeOffsetMs);
  230. clearAllHighlights();
  231. },
  232. [privateSetCurrentTime, startTimeOffsetMs, clearAllHighlights]
  233. );
  234. const applyInitialOffset = useCallback(() => {
  235. const offsetMs = (initialTimeOffsetMs?.offsetMs ?? 0) + startTimeOffsetMs;
  236. if (
  237. !didApplyInitialOffset.current &&
  238. (initialTimeOffsetMs || offsetMs) &&
  239. events &&
  240. replayerRef.current
  241. ) {
  242. const highlightArgs = initialTimeOffsetMs?.highlight;
  243. if (offsetMs > 0) {
  244. privateSetCurrentTime(offsetMs);
  245. }
  246. if (highlightArgs) {
  247. addHighlight(highlightArgs);
  248. setTimeout(() => {
  249. clearAllHighlights();
  250. addHighlight(highlightArgs);
  251. });
  252. }
  253. if (autoStart) {
  254. setTimeout(() => {
  255. replayerRef.current?.play(offsetMs);
  256. setIsPlaying(true);
  257. });
  258. }
  259. didApplyInitialOffset.current = true;
  260. }
  261. }, [
  262. clearAllHighlights,
  263. events,
  264. addHighlight,
  265. initialTimeOffsetMs,
  266. privateSetCurrentTime,
  267. startTimeOffsetMs,
  268. autoStart,
  269. ]);
  270. useEffect(clearAllHighlights, [clearAllHighlights, isPlaying]);
  271. const initRoot = useCallback(
  272. (root: RootElem) => {
  273. if (events === undefined || root === null || isFetching) {
  274. return;
  275. }
  276. if (replayerRef.current) {
  277. if (!hasNewEvents) {
  278. return;
  279. }
  280. if (replayerRef.current.iframe.contentDocument?.body.childElementCount === 0) {
  281. // If this is true, then no need to clear old iframe as nothing was rendered
  282. return;
  283. }
  284. // We have new events, need to clear out the old iframe because a new
  285. // `Replayer` instance is about to be created
  286. while (root.firstChild) {
  287. root.removeChild(root.firstChild);
  288. }
  289. }
  290. // eslint-disable-next-line no-new
  291. const inst = new Replayer(events, {
  292. root,
  293. blockClass: 'sentry-block',
  294. mouseTail: {
  295. duration: 0.75 * 1000,
  296. lineCap: 'round',
  297. lineWidth: 2,
  298. strokeStyle: theme.purple200,
  299. },
  300. plugins: organization.features.includes('session-replay-enable-canvas-replayer')
  301. ? [CanvasReplayerPlugin(events)]
  302. : [],
  303. skipInactive: initialPrefsRef.current.isSkippingInactive,
  304. speed: initialPrefsRef.current.playbackSpeed,
  305. });
  306. // @ts-expect-error: rrweb types event handlers with `unknown` parameters
  307. inst.on(ReplayerEvents.Resize, (dimension: Dimensions) => {
  308. setDimensions(dimension);
  309. });
  310. inst.on(ReplayerEvents.Finish, setReplayFinished);
  311. // @ts-expect-error: rrweb types event handlers with `unknown` parameters
  312. inst.on(ReplayerEvents.SkipStart, (e: {speed: number}) => {
  313. setFFSpeed(e.speed);
  314. });
  315. inst.on(ReplayerEvents.SkipEnd, () => {
  316. setFFSpeed(0);
  317. });
  318. // `.current` is marked as readonly, but it's safe to set the value from
  319. // inside a `useEffect` hook.
  320. // See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
  321. // @ts-expect-error
  322. replayerRef.current = inst;
  323. applyInitialOffset();
  324. },
  325. [
  326. applyInitialOffset,
  327. events,
  328. hasNewEvents,
  329. isFetching,
  330. organization.features,
  331. setReplayFinished,
  332. theme.purple200,
  333. ]
  334. );
  335. const initVideoRoot = useCallback(
  336. (root: RootElem) => {
  337. if (root === null || isFetching) {
  338. return null;
  339. }
  340. // check if this is a video replay and if we can use the video (wrapper) replayer
  341. if (!isVideoReplay || !videoEvents || !startTimestampMs) {
  342. return null;
  343. }
  344. // This is a wrapper class that wraps both the VideoReplayer
  345. // and the rrweb Replayer
  346. const inst = new VideoReplayerWithInteractions({
  347. // video specific
  348. videoEvents,
  349. videoApiPrefix: `/api/0/projects/${
  350. organization.slug
  351. }/${projectSlug}/replays/${replay?.getReplay().id}/videos/`,
  352. start: startTimestampMs,
  353. onFinished: setReplayFinished,
  354. onLoaded: event => {
  355. const {videoHeight, videoWidth} = event.target;
  356. if (!videoHeight || !videoWidth) {
  357. return;
  358. }
  359. setDimensions({
  360. height: videoHeight,
  361. width: videoWidth,
  362. });
  363. },
  364. onBuffer: buffering => {
  365. setVideoBuffering(buffering);
  366. },
  367. clipWindow,
  368. durationMs,
  369. speed: prefs.playbackSpeed,
  370. // rrweb specific
  371. theme,
  372. eventsWithSnapshots: replay?.getRRWebFramesWithSnapshots() ?? [],
  373. touchEvents: replay?.getRRwebTouchEvents() ?? [],
  374. // common to both
  375. root,
  376. context: {
  377. sdkName: replay?.getReplay().sdk.name,
  378. sdkVersion: replay?.getReplay().sdk.version,
  379. },
  380. });
  381. // `.current` is marked as readonly, but it's safe to set the value from
  382. // inside a `useEffect` hook.
  383. // See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
  384. // @ts-expect-error
  385. replayerRef.current = inst;
  386. applyInitialOffset();
  387. return inst;
  388. },
  389. [
  390. applyInitialOffset,
  391. clipWindow,
  392. durationMs,
  393. isFetching,
  394. isVideoReplay,
  395. organization.slug,
  396. prefs.playbackSpeed,
  397. projectSlug,
  398. replay,
  399. setReplayFinished,
  400. startTimestampMs,
  401. theme,
  402. videoEvents,
  403. ]
  404. );
  405. useEffect(() => {
  406. const replayer = replayerRef.current;
  407. if (!replayer) {
  408. return;
  409. }
  410. if (isPlaying) {
  411. // we need to pass in the current time when pausing for mobile replays
  412. replayer.pause(getCurrentPlayerTime());
  413. replayer.setConfig({speed: prefs.playbackSpeed});
  414. replayer.play(getCurrentPlayerTime());
  415. } else {
  416. replayer.setConfig({speed: prefs.playbackSpeed});
  417. }
  418. }, [getCurrentPlayerTime, isPlaying, prefs.playbackSpeed]);
  419. const togglePlayPause = useCallback(
  420. (play: boolean) => {
  421. const replayer = replayerRef.current;
  422. if (!replayer) {
  423. return;
  424. }
  425. if (play) {
  426. replayer.play(getCurrentPlayerTime());
  427. } else {
  428. replayer.pause(getCurrentPlayerTime());
  429. }
  430. setIsPlaying(play);
  431. trackAnalytics('replay.play-pause', {
  432. organization,
  433. user_email: user.email,
  434. play,
  435. context: analyticsContext,
  436. mobile: isVideoReplay,
  437. });
  438. },
  439. [organization, user.email, analyticsContext, getCurrentPlayerTime, isVideoReplay]
  440. );
  441. useEffect(() => {
  442. const handleVisibilityChange = () => {
  443. if (document.visibilityState !== 'visible' && replayerRef.current) {
  444. togglePlayPause(false);
  445. }
  446. };
  447. document.addEventListener('visibilitychange', handleVisibilityChange);
  448. return () => {
  449. document.removeEventListener('visibilitychange', handleVisibilityChange);
  450. };
  451. }, [togglePlayPause]);
  452. // Initialize replayer for Video Replays
  453. useEffect(() => {
  454. const instance =
  455. isVideoReplay && rootEl && !replayerRef.current && initVideoRoot(rootEl);
  456. return () => {
  457. if (instance && !rootEl) {
  458. instance.destroy();
  459. }
  460. };
  461. }, [rootEl, isVideoReplay, initVideoRoot, videoEvents]);
  462. // For non-video (e.g. rrweb) replays, initialize the player
  463. useEffect(() => {
  464. if (!isVideoReplay && events) {
  465. if (replayerRef.current) {
  466. // If it's already been initialized, we still call initRoot, which
  467. // should clear out existing dom element
  468. initRoot(replayerRef.current.wrapper.parentElement as RootElem);
  469. } else if (rootEl) {
  470. initRoot(rootEl);
  471. }
  472. }
  473. }, [rootEl, initRoot, events, isVideoReplay]);
  474. // Clean-up rrweb replayer when root element is unmounted
  475. useEffect(() => {
  476. return () => {
  477. if (rootEl && replayerRef.current) {
  478. replayerRef.current.destroy();
  479. // @ts-expect-error Cleaning up
  480. replayerRef.current = null;
  481. }
  482. };
  483. }, [rootEl]);
  484. const restart = useCallback(() => {
  485. if (replayerRef.current) {
  486. replayerRef.current.play(startTimeOffsetMs);
  487. setIsPlaying(true);
  488. }
  489. }, [startTimeOffsetMs]);
  490. useEffect(() => {
  491. const replayer = replayerRef.current;
  492. if (!replayer) {
  493. return;
  494. }
  495. if (prefs.isSkippingInactive !== replayer.config.skipInactive) {
  496. replayer.setConfig({skipInactive: prefs.isSkippingInactive});
  497. }
  498. }, [prefs.isSkippingInactive]);
  499. const currentPlayerTime = useCurrentTime(getCurrentPlayerTime);
  500. const [isBuffering, currentBufferedPlayerTime] =
  501. buffer.target !== -1 &&
  502. buffer.previous === currentPlayerTime &&
  503. buffer.target !== buffer.previous
  504. ? [true, buffer.target]
  505. : [false, currentPlayerTime];
  506. const currentTime = currentBufferedPlayerTime - startTimeOffsetMs;
  507. useEffect(() => {
  508. if (!isBuffering && events && events.length >= 2 && replayerRef.current) {
  509. applyInitialOffset();
  510. }
  511. }, [isBuffering, events, applyInitialOffset]);
  512. useEffect(() => {
  513. if (!isBuffering && buffer.target !== -1) {
  514. setBufferTime({target: -1, previous: -1});
  515. }
  516. }, [isBuffering, buffer.target]);
  517. return (
  518. <ReplayCurrentTimeContextProvider>
  519. <ReplayPlayerContext.Provider
  520. value={{
  521. analyticsContext,
  522. clearAllHighlights,
  523. currentTime,
  524. dimensions,
  525. fastForwardSpeed,
  526. addHighlight,
  527. setRoot,
  528. isBuffering: isBuffering && !isVideoReplay,
  529. isVideoBuffering,
  530. isFetching,
  531. isVideoReplay,
  532. isFinished,
  533. isPlaying,
  534. removeHighlight,
  535. replay,
  536. restart,
  537. setCurrentTime,
  538. togglePlayPause,
  539. ...value,
  540. }}
  541. >
  542. {children}
  543. </ReplayPlayerContext.Provider>
  544. </ReplayCurrentTimeContextProvider>
  545. );
  546. }
  547. export const useReplayContext = () => useContext(ReplayPlayerContext);