Repository: getsentry/sentry
Pull request: feat(replays): Adds restart functionality to replays
### Changes

- Adds isFinished boolean to replay context
- Adds restart function to replay context
- Updates UI to replace play/pause button with restart button when 'isFinished'

### Notes

This resolves a bug where you can keep hitting play after the video has ended and the current time keeps increasing and breaking the UI.

Closes #36139 

https://user-images.githubusercontent.com/3721977/176468847-ff96abf8-aa1c-493b-b881-23696c0fd726.mp4

<!--

  Sentry employees and contractors can delete or ignore the following.

-->

### Legal Boilerplate

Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.


Problem statement:
Bug: Play button should be disabled on player when at the end of a replay
It currently allows you to play, and the timer jumps beyond the duration of the replay. At the min it should be disabled, but we probably want it to restart.

Hints from issue discussion:
Routing to @getsentry/emerging-technology for [triage](https://develop.sentry.dev/processing-tickets/#3-triage). ⏲️

Commit message: feat(replays): adds restart btn when replay is finished

Patch to review:
diff --git a/static/app/components/replays/replayContext.tsx b/static/app/components/replays/replayContext.tsx
index f6ffeac61d9a96..332aa290252499 100644
--- a/static/app/components/replays/replayContext.tsx
+++ b/static/app/components/replays/replayContext.tsx
@@ -73,6 +73,11 @@ type ReplayPlayerContextProps = {
    */
   isBuffering: boolean;
 
+  /**
+   * Set to true when the replay finish event is fired
+   */
+  isFinished: boolean;
+
   /**
    * Whether the video is currently playing
    */
@@ -93,6 +98,11 @@ type ReplayPlayerContextProps = {
    */
   replay: ReplayReader | null;
 
+  /**
+   * Restart the replay
+   */
+  restart: () => void;
+
   /**
    * Set the currentHoverTime so collaborating components can highlight related
    * information
@@ -139,10 +149,12 @@ const ReplayPlayerContext = React.createContext<ReplayPlayerContextProps>({
   highlight: () => {},
   initRoot: () => {},
   isBuffering: false,
+  isFinished: false,
   isPlaying: false,
   isSkippingInactive: false,
   removeHighlight: () => {},
   replay: null,
+  restart: () => {},
   setCurrentHoverTime: () => {},
   setCurrentTime: () => {},
   setSpeed: () => {},
@@ -183,18 +195,18 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
   const [dimensions, setDimensions] = useState<Dimensions>({height: 0, width: 0});
   const [currentHoverTime, setCurrentHoverTime] = useState<undefined | number>();
   const [isPlaying, setIsPlaying] = useState(false);
+  const [finishedAtMS, setFinishedAtMS] = useState<number>(-1);
   const [isSkippingInactive, setIsSkippingInactive] = useState(false);
   const [speed, setSpeedState] = useState(1);
   const [fastForwardSpeed, setFFSpeed] = useState(0);
   const [buffer, setBufferTime] = useState({target: -1, previous: -1});
   const playTimer = useRef<number | undefined>(undefined);
 
+  const isFinished = replayerRef.current?.getCurrentTime() === finishedAtMS;
+
   const forceDimensions = (dimension: Dimensions) => {
     setDimensions(dimension);
   };
-  const setPlayingFalse = () => {
-    setIsPlaying(false);
-  };
   const onFastForwardStart = (e: {speed: number}) => {
     setFFSpeed(e.speed);
   };
@@ -229,6 +241,11 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
     removeHighlightedNode({replayer, nodeId});
   }, []);
 
+  const setReplayFinished = useCallback(() => {
+    setFinishedAtMS(replayerRef.current?.getCurrentTime() ?? -1);
+    setIsPlaying(false);
+  }, [replayerRef]);
+
   const initRoot = useCallback(
     (root: RootElem) => {
       if (events === undefined) {
@@ -277,7 +294,7 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
 
       // @ts-expect-error: rrweb types event handlers with `unknown` parameters
       inst.on(ReplayerEvents.Resize, forceDimensions);
-      inst.on(ReplayerEvents.Finish, setPlayingFalse);
+      inst.on(ReplayerEvents.Finish, setReplayFinished);
       // @ts-expect-error: rrweb types event handlers with `unknown` parameters
       inst.on(ReplayerEvents.SkipStart, onFastForwardStart);
       inst.on(ReplayerEvents.SkipEnd, onFastForwardEnd);
@@ -288,7 +305,7 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
       // @ts-expect-error
       replayerRef.current = inst;
     },
-    [events, theme.purple200, hasNewEvents]
+    [events, theme.purple200, hasNewEvents, setReplayFinished]
   );
 
   useEffect(() => {
@@ -379,6 +396,13 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
     [getCurrentTime]
   );
 
+  const restart = useCallback(() => {
+    if (replayerRef.current) {
+      replayerRef.current.play(0);
+      setIsPlaying(true);
+    }
+  }, [replayerRef]);
+
   const toggleSkipInactive = useCallback((skip: boolean) => {
     const replayer = replayerRef.current;
     if (!replayer) {
@@ -423,10 +447,12 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
         highlight,
         initRoot,
         isBuffering,
+        isFinished,
         isPlaying,
         isSkippingInactive,
         removeHighlight,
         replay,
+        restart,
         setCurrentHoverTime,
         setCurrentTime,
         setSpeed,
diff --git a/static/app/components/replays/replayController.tsx b/static/app/components/replays/replayController.tsx
index 6e33358fa003b2..c4d04561c9b689 100644
--- a/static/app/components/replays/replayController.tsx
+++ b/static/app/components/replays/replayController.tsx
@@ -12,6 +12,7 @@ import {
   IconNext,
   IconPause,
   IconPlay,
+  IconPrevious,
   IconRefresh,
   IconResize,
 } from 'sentry/icons';
@@ -37,8 +38,15 @@ interface Props {
 }
 
 function ReplayPlayPauseBar() {
-  const {currentTime, isPlaying, replay, setCurrentTime, togglePlayPause} =
-    useReplayContext();
+  const {
+    currentTime,
+    isPlaying,
+    replay,
+    setCurrentTime,
+    togglePlayPause,
+    isFinished,
+    restart,
+  } = useReplayContext();
 
   return (
     <ButtonBar merged>
@@ -49,13 +57,23 @@ function ReplayPlayPauseBar() {
         onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
         aria-label={t('Go back 10 seconds')}
       />
-      <Button
-        size="xsmall"
-        title={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
-        icon={isPlaying ? <IconPause size="sm" /> : <IconPlay size="sm" />}
-        onClick={() => togglePlayPause(!isPlaying)}
-        aria-label={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
-      />
+      {isFinished ? (
+        <Button
+          size="xsmall"
+          title={t('Restart Replay')}
+          icon={<IconPrevious size="sm" />}
+          onClick={restart}
+          aria-label={t('Restart the Replay')}
+        />
+      ) : (
+        <Button
+          size="xsmall"
+          title={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
+          icon={isPlaying ? <IconPause size="sm" /> : <IconPlay size="sm" />}
+          onClick={() => togglePlayPause(!isPlaying)}
+          aria-label={isPlaying ? t('Pause the Replay') : t('Play the Replay')}
+        />
+      )}
       <Button
         size="xsmall"
         title={t('Jump to next event')}


Code review comments:
diff_hunk: @@ -379,6 +396,13 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
     [getCurrentTime]
   );
 
+  const restart = useCallback(() => {
+    if (replayerRef.current) {
+      replayerRef.current.play(0);
+      setIsPlaying(true);
+    }
+  }, [replayerRef]);
path: static/app/components/replays/replayContext.tsx
text: @user1:
ditto above

```suggestion
  }, []);
```

diff_hunk: @@ -229,6 +241,11 @@ export function Provider({children, replay, initialTimeOffset = 0, value = {}}:
     removeHighlightedNode({replayer, nodeId});
   }, []);
 
+  const setReplayFinished = useCallback(() => {
+    setFinishedAtMS(replayerRef.current?.getCurrentTime() ?? -1);
+    setIsPlaying(false);
+  }, [replayerRef]);
path: static/app/components/replays/replayContext.tsx
text: @user1:
I don't think we need the `replayerRef` in the dependency array

```suggestion
  }, []);
```

I've seen lint errors like this and it should also apply for useCallback and this ref too:
```
React Hook React.useEffect has an unnecessary dependency: 'usernameInputRef.current'.
Either exclude it or remove the dependency array.
Mutable values like 'usernameInputRef.current' aren't valid dependencies because
mutating them doesn't re-render the component.
eslint(react-hooks/exhaustive-deps)
```


It believe it makes sense because the React.RefObject `replayerRef` doesn't change it's identity between renders. The `.current` property is where object identity will change. So the dependency array `[replayerRef]` is the same as `[]`, neither of those arrays will change, we will always have the same `setReplayFinished` value between renders.

@author:
Good catch I never thought about this before so use to just adding deps to the array. I think in this case I actually want to track replayerRef.current to be technically correct since if ref.current isn't set initially the effect/callback will never be run again. If replayerRef.current gets mutated it won't cause a re-render, but if a re-render does happen and it's value has changed, the effect/callback will be run.

Just made a StackBlitz to test this out: https://stackblitz.com/edit/react-ts-nh5ace?file=App.tsx