replayContext.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import {Replayer, ReplayerEvents} from 'rrweb';
  4. import localStorage from 'sentry/utils/localStorage';
  5. import {
  6. clearAllHighlights,
  7. highlightNode,
  8. removeHighlightedNode,
  9. } from 'sentry/utils/replays/highlightNode';
  10. import useRAF from 'sentry/utils/replays/hooks/useRAF';
  11. import type ReplayReader from 'sentry/utils/replays/replayReader';
  12. import usePrevious from 'sentry/utils/usePrevious';
  13. enum ReplayLocalstorageKeys {
  14. ReplayConfig = 'replay-config',
  15. }
  16. type ReplayConfig = {
  17. skip?: boolean;
  18. speed?: number;
  19. };
  20. type Dimensions = {height: number; width: number};
  21. type RootElem = null | HTMLDivElement;
  22. type HighlightParams = {
  23. nodeId: number;
  24. annotation?: string;
  25. };
  26. // Important: Don't allow context Consumers to access `Replayer` directly.
  27. // It has state that, when changed, will not trigger a react render.
  28. // Instead only expose methods that wrap `Replayer` and manage state.
  29. type ReplayPlayerContextProps = {
  30. /**
  31. * Clear all existing highlights in replay
  32. */
  33. clearAllHighlights: () => void;
  34. /**
  35. * The time, in milliseconds, where the user focus is.
  36. * The user focus can be reported by any collaborating object, usually on
  37. * hover.
  38. */
  39. currentHoverTime: undefined | number;
  40. /**
  41. * The current time of the video, in milliseconds
  42. * The value is updated on every animation frame, about every 16.6ms
  43. */
  44. currentTime: number;
  45. /**
  46. * Original dimensions in pixels of the captured browser window
  47. */
  48. dimensions: Dimensions;
  49. /**
  50. * The calculated speed of the player when fast-forwarding through idle moments in the video
  51. * The value is set to `0` when the video is not fast-forwarding
  52. * The speed is automatically determined by the length of each idle period
  53. */
  54. fastForwardSpeed: number;
  55. /**
  56. * Highlight a node in the replay
  57. */
  58. highlight: (args: HighlightParams) => void;
  59. /**
  60. * Required to be called with a <div> Ref
  61. * Represents the location in the DOM where the iframe video should be mounted
  62. *
  63. * @param _root
  64. */
  65. initRoot: (root: RootElem) => void;
  66. /**
  67. * Set to true while the library is reconstructing the DOM
  68. */
  69. isBuffering: boolean;
  70. /**
  71. * Set to true when the replay finish event is fired
  72. */
  73. isFinished: boolean;
  74. /**
  75. * Whether the video is currently playing
  76. */
  77. isPlaying: boolean;
  78. /**
  79. * Whether fast-forward mode is enabled if RRWeb detects idle moments in the video
  80. */
  81. isSkippingInactive: boolean;
  82. /**
  83. * Removes a highlighted node from the replay
  84. */
  85. removeHighlight: ({nodeId}: {nodeId: number}) => void;
  86. /**
  87. * The core replay data
  88. */
  89. replay: ReplayReader | null;
  90. /**
  91. * Restart the replay
  92. */
  93. restart: () => void;
  94. /**
  95. * Set the currentHoverTime so collaborating components can highlight related
  96. * information
  97. */
  98. setCurrentHoverTime: (time: undefined | number) => void;
  99. /**
  100. * Jump the video to a specific time
  101. */
  102. setCurrentTime: (time: number) => void;
  103. /**
  104. * Set speed for normal playback
  105. */
  106. setSpeed: (speed: number) => void;
  107. /**
  108. * The speed for normal playback
  109. */
  110. speed: number;
  111. /**
  112. * Start or stop playback
  113. *
  114. * @param play
  115. */
  116. togglePlayPause: (play: boolean) => void;
  117. /**
  118. * Allow RRWeb to use Fast-Forward mode for idle moments in the video
  119. *
  120. * @param skip
  121. */
  122. toggleSkipInactive: (skip: boolean) => void;
  123. };
  124. const ReplayPlayerContext = React.createContext<ReplayPlayerContextProps>({
  125. clearAllHighlights: () => {},
  126. currentHoverTime: undefined,
  127. currentTime: 0,
  128. dimensions: {height: 0, width: 0},
  129. fastForwardSpeed: 0,
  130. highlight: () => {},
  131. initRoot: () => {},
  132. isBuffering: false,
  133. isFinished: false,
  134. isPlaying: false,
  135. isSkippingInactive: true,
  136. removeHighlight: () => {},
  137. replay: null,
  138. restart: () => {},
  139. setCurrentHoverTime: () => {},
  140. setCurrentTime: () => {},
  141. setSpeed: () => {},
  142. speed: 1,
  143. togglePlayPause: () => {},
  144. toggleSkipInactive: () => {},
  145. });
  146. type Props = {
  147. children: React.ReactNode;
  148. replay: ReplayReader | null;
  149. /**
  150. * Time, in seconds, when the video should start
  151. */
  152. initialTimeOffset?: number;
  153. /**
  154. * Override return fields for testing
  155. */
  156. value?: Partial<ReplayPlayerContextProps>;
  157. };
  158. function useCurrentTime(callback: () => number) {
  159. const [currentTime, setCurrentTime] = useState(0);
  160. useRAF(() => setCurrentTime(callback));
  161. return currentTime;
  162. }
  163. function updateSavedReplayConfig(config: ReplayConfig) {
  164. localStorage.setItem(ReplayLocalstorageKeys.ReplayConfig, JSON.stringify(config));
  165. }
  166. export function Provider({children, replay, initialTimeOffset = 0, value = {}}: Props) {
  167. const events = replay?.getRRWebEvents();
  168. const savedReplayConfigRef = useRef<ReplayConfig>(
  169. JSON.parse(localStorage.getItem(ReplayLocalstorageKeys.ReplayConfig) || '{}')
  170. );
  171. const theme = useTheme();
  172. const oldEvents = usePrevious(events);
  173. // Note we have to check this outside of hooks, see `usePrevious` comments
  174. const hasNewEvents = events !== oldEvents;
  175. const replayerRef = useRef<Replayer>(null);
  176. const [dimensions, setDimensions] = useState<Dimensions>({height: 0, width: 0});
  177. const [currentHoverTime, setCurrentHoverTime] = useState<undefined | number>();
  178. const [isPlaying, setIsPlaying] = useState(false);
  179. const [finishedAtMS, setFinishedAtMS] = useState<number>(-1);
  180. const [isSkippingInactive, setIsSkippingInactive] = useState(
  181. savedReplayConfigRef.current.skip ?? true
  182. );
  183. const [speed, setSpeedState] = useState(savedReplayConfigRef.current.speed || 1);
  184. const [fastForwardSpeed, setFFSpeed] = useState(0);
  185. const [buffer, setBufferTime] = useState({target: -1, previous: -1});
  186. const playTimer = useRef<number | undefined>(undefined);
  187. const unMountedRef = useRef(false);
  188. const isFinished = replayerRef.current?.getCurrentTime() === finishedAtMS;
  189. const forceDimensions = (dimension: Dimensions) => {
  190. setDimensions(dimension);
  191. };
  192. const onFastForwardStart = (e: {speed: number}) => {
  193. setFFSpeed(e.speed);
  194. };
  195. const onFastForwardEnd = () => {
  196. setFFSpeed(0);
  197. };
  198. const highlight = useCallback(({nodeId, annotation}: HighlightParams) => {
  199. const replayer = replayerRef.current;
  200. if (!replayer) {
  201. return;
  202. }
  203. highlightNode({replayer, nodeId, annotation});
  204. }, []);
  205. const clearAllHighlightsCallback = useCallback(() => {
  206. const replayer = replayerRef.current;
  207. if (!replayer) {
  208. return;
  209. }
  210. clearAllHighlights({replayer});
  211. }, []);
  212. const removeHighlight = useCallback(({nodeId}: {nodeId: number}) => {
  213. const replayer = replayerRef.current;
  214. if (!replayer) {
  215. return;
  216. }
  217. removeHighlightedNode({replayer, nodeId});
  218. }, []);
  219. const setReplayFinished = useCallback(() => {
  220. setFinishedAtMS(replayerRef.current?.getCurrentTime() ?? -1);
  221. setIsPlaying(false);
  222. }, []);
  223. const initRoot = useCallback(
  224. (root: RootElem) => {
  225. if (events === undefined) {
  226. return;
  227. }
  228. if (root === null) {
  229. return;
  230. }
  231. if (replayerRef.current) {
  232. if (!hasNewEvents && !unMountedRef.current) {
  233. // Already have a player for these events, the parent node must've re-rendered
  234. return;
  235. }
  236. if (replayerRef.current.iframe.contentDocument?.body.childElementCount === 0) {
  237. // If this is true, then no need to clear old iframe as nothing was rendered
  238. return;
  239. }
  240. // We have new events, need to clear out the old iframe because a new
  241. // `Replayer` instance is about to be created
  242. while (root.firstChild) {
  243. root.removeChild(root.firstChild);
  244. }
  245. }
  246. // eslint-disable-next-line no-new
  247. const inst = new Replayer(events, {
  248. root,
  249. blockClass: 'sr-block',
  250. // liveMode: false,
  251. // triggerFocus: false,
  252. mouseTail: {
  253. duration: 0.75 * 1000,
  254. lineCap: 'round',
  255. lineWidth: 2,
  256. strokeStyle: theme.purple200,
  257. },
  258. // unpackFn: _ => _,
  259. // plugins: [],
  260. skipInactive: savedReplayConfigRef.current.skip ?? true,
  261. speed: savedReplayConfigRef.current.speed || 1,
  262. });
  263. // @ts-expect-error: rrweb types event handlers with `unknown` parameters
  264. inst.on(ReplayerEvents.Resize, forceDimensions);
  265. inst.on(ReplayerEvents.Finish, setReplayFinished);
  266. // @ts-expect-error: rrweb types event handlers with `unknown` parameters
  267. inst.on(ReplayerEvents.SkipStart, onFastForwardStart);
  268. inst.on(ReplayerEvents.SkipEnd, onFastForwardEnd);
  269. // `.current` is marked as readonly, but it's safe to set the value from
  270. // inside a `useEffect` hook.
  271. // See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
  272. // @ts-expect-error
  273. replayerRef.current = inst;
  274. if (unMountedRef.current) {
  275. unMountedRef.current = false;
  276. }
  277. },
  278. [events, theme.purple200, setReplayFinished, hasNewEvents]
  279. );
  280. useEffect(() => {
  281. const handleVisibilityChange = () => {
  282. if (document.visibilityState !== 'visible') {
  283. replayerRef.current?.pause();
  284. }
  285. };
  286. if (replayerRef.current && events) {
  287. initRoot(replayerRef.current.wrapper.parentElement as RootElem);
  288. document.addEventListener('visibilitychange', handleVisibilityChange);
  289. }
  290. return () => {
  291. document.removeEventListener('visibilitychange', handleVisibilityChange);
  292. };
  293. }, [initRoot, events]);
  294. const getCurrentTime = useCallback(
  295. () => (replayerRef.current ? Math.max(replayerRef.current.getCurrentTime(), 0) : 0),
  296. []
  297. );
  298. const setCurrentTime = useCallback(
  299. (requestedTimeMs: number) => {
  300. const replayer = replayerRef.current;
  301. if (!replayer) {
  302. return;
  303. }
  304. const maxTimeMs = replayerRef.current?.getMetaData().totalTime;
  305. const time = requestedTimeMs > maxTimeMs ? 0 : requestedTimeMs;
  306. // Sometimes rrweb doesn't get to the exact target time, as long as it has
  307. // changed away from the previous time then we can hide then buffering message.
  308. setBufferTime({target: time, previous: getCurrentTime()});
  309. // Clear previous timers. Without this (but with the setTimeout) multiple
  310. // requests to set the currentTime could finish out of order and cause jumping.
  311. if (playTimer.current) {
  312. window.clearTimeout(playTimer.current);
  313. }
  314. if (isPlaying) {
  315. playTimer.current = window.setTimeout(() => replayer.play(time), 0);
  316. setIsPlaying(true);
  317. } else {
  318. playTimer.current = window.setTimeout(() => replayer.pause(time), 0);
  319. setIsPlaying(false);
  320. }
  321. },
  322. [getCurrentTime, isPlaying]
  323. );
  324. const setSpeed = useCallback(
  325. (newSpeed: number) => {
  326. const replayer = replayerRef.current;
  327. savedReplayConfigRef.current = {
  328. ...savedReplayConfigRef.current,
  329. speed: newSpeed,
  330. };
  331. updateSavedReplayConfig(savedReplayConfigRef.current);
  332. if (!replayer) {
  333. return;
  334. }
  335. if (isPlaying) {
  336. replayer.pause();
  337. replayer.setConfig({speed: newSpeed});
  338. replayer.play(getCurrentTime());
  339. } else {
  340. replayer.setConfig({speed: newSpeed});
  341. }
  342. setSpeedState(newSpeed);
  343. },
  344. [getCurrentTime, isPlaying]
  345. );
  346. const togglePlayPause = useCallback(
  347. (play: boolean) => {
  348. const replayer = replayerRef.current;
  349. if (!replayer) {
  350. return;
  351. }
  352. if (play) {
  353. replayer.play(getCurrentTime());
  354. } else {
  355. replayer.pause(getCurrentTime());
  356. }
  357. setIsPlaying(play);
  358. },
  359. [getCurrentTime]
  360. );
  361. const restart = useCallback(() => {
  362. if (replayerRef.current) {
  363. replayerRef.current.play(0);
  364. setIsPlaying(true);
  365. }
  366. }, []);
  367. const toggleSkipInactive = useCallback((skip: boolean) => {
  368. const replayer = replayerRef.current;
  369. savedReplayConfigRef.current = {
  370. ...savedReplayConfigRef.current,
  371. skip,
  372. };
  373. updateSavedReplayConfig(savedReplayConfigRef.current);
  374. if (!replayer) {
  375. return;
  376. }
  377. if (skip !== replayer.config.skipInactive) {
  378. replayer.setConfig({skipInactive: skip});
  379. }
  380. setIsSkippingInactive(skip);
  381. }, []);
  382. // Only on pageload: set the initial playback timestamp
  383. useEffect(() => {
  384. if (initialTimeOffset && events && replayerRef.current) {
  385. setCurrentTime(initialTimeOffset * 1000);
  386. }
  387. return () => {
  388. unMountedRef.current = true;
  389. };
  390. }, [events, replayerRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
  391. const currentPlayerTime = useCurrentTime(getCurrentTime);
  392. const [isBuffering, currentTime] =
  393. buffer.target !== -1 &&
  394. buffer.previous === currentPlayerTime &&
  395. buffer.target !== buffer.previous
  396. ? [true, buffer.target]
  397. : [false, currentPlayerTime];
  398. if (!isBuffering && buffer.target !== -1) {
  399. setBufferTime({target: -1, previous: -1});
  400. }
  401. return (
  402. <ReplayPlayerContext.Provider
  403. value={{
  404. clearAllHighlights: clearAllHighlightsCallback,
  405. currentHoverTime,
  406. currentTime,
  407. dimensions,
  408. fastForwardSpeed,
  409. highlight,
  410. initRoot,
  411. isBuffering,
  412. isFinished,
  413. isPlaying,
  414. isSkippingInactive,
  415. removeHighlight,
  416. replay,
  417. restart,
  418. setCurrentHoverTime,
  419. setCurrentTime,
  420. setSpeed,
  421. speed,
  422. togglePlayPause,
  423. toggleSkipInactive,
  424. ...value,
  425. }}
  426. >
  427. {children}
  428. </ReplayPlayerContext.Provider>
  429. );
  430. }
  431. export const useReplayContext = () => useContext(ReplayPlayerContext);