replayContext.tsx 20 KB

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