replayController.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import {useCallback, useLayoutEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useResizeObserver} from '@react-aria/utils';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {CompositeSelect} from 'sentry/components/compactSelect/composite';
  7. import {PlayerScrubber} from 'sentry/components/replays/player/scrubber';
  8. import useScrubberMouseTracking from 'sentry/components/replays/player/useScrubberMouseTracking';
  9. import {useReplayContext} from 'sentry/components/replays/replayContext';
  10. import {formatTime, relativeTimeInMs} from 'sentry/components/replays/utils';
  11. import {
  12. IconContract,
  13. IconExpand,
  14. IconNext,
  15. IconPause,
  16. IconPlay,
  17. IconPrevious,
  18. IconRewind10,
  19. IconSettings,
  20. } from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import ConfigStore from 'sentry/stores/configStore';
  23. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  24. import space from 'sentry/styles/space';
  25. import {BreadcrumbType} from 'sentry/types/breadcrumbs';
  26. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  27. import {getNextReplayEvent} from 'sentry/utils/replays/getReplayEvent';
  28. import useFullscreen from 'sentry/utils/replays/hooks/useFullscreen';
  29. import useOrganization from 'sentry/utils/useOrganization';
  30. const SECOND = 1000;
  31. const COMPACT_WIDTH_BREAKPOINT = 500;
  32. const USER_ACTIONS = [
  33. BreadcrumbType.ERROR,
  34. BreadcrumbType.INIT,
  35. BreadcrumbType.NAVIGATION,
  36. BreadcrumbType.UI,
  37. BreadcrumbType.USER,
  38. ];
  39. interface Props {
  40. speedOptions?: number[];
  41. toggleFullscreen?: () => void;
  42. }
  43. function ReplayPlayPauseBar() {
  44. const {
  45. currentTime,
  46. isFinished,
  47. isPlaying,
  48. replay,
  49. restart,
  50. setCurrentTime,
  51. togglePlayPause,
  52. } = useReplayContext();
  53. return (
  54. <ButtonBar merged>
  55. <Button
  56. size="sm"
  57. title={t('Rewind 10s')}
  58. icon={<IconRewind10 size="sm" />}
  59. onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
  60. aria-label={t('Rewind 10 seconds')}
  61. />
  62. {isFinished ? (
  63. <Button
  64. size="sm"
  65. title={t('Restart Replay')}
  66. icon={<IconPrevious size="sm" />}
  67. onClick={restart}
  68. aria-label={t('Restart Replay')}
  69. />
  70. ) : (
  71. <Button
  72. size="sm"
  73. title={isPlaying ? t('Pause') : t('Play')}
  74. icon={isPlaying ? <IconPause size="sm" /> : <IconPlay size="sm" />}
  75. onClick={() => togglePlayPause(!isPlaying)}
  76. aria-label={isPlaying ? t('Pause') : t('Play')}
  77. />
  78. )}
  79. <Button
  80. size="sm"
  81. title={t('Next breadcrumb')}
  82. icon={<IconNext size="sm" />}
  83. onClick={() => {
  84. const startTimestampMs = replay?.getReplay().started_at?.getTime();
  85. if (!startTimestampMs) {
  86. return;
  87. }
  88. const transformedCrumbs = replay?.getRawCrumbs() || [];
  89. const next = getNextReplayEvent({
  90. items: transformedCrumbs.filter(crumb => USER_ACTIONS.includes(crumb.type)),
  91. targetTimestampMs: startTimestampMs + currentTime,
  92. });
  93. if (startTimestampMs !== undefined && next?.timestamp) {
  94. setCurrentTime(relativeTimeInMs(next.timestamp, startTimestampMs));
  95. }
  96. }}
  97. aria-label={t('Fast-forward to next breadcrumb')}
  98. />
  99. </ButtonBar>
  100. );
  101. }
  102. function ReplayOptionsMenu({speedOptions}: {speedOptions: number[]}) {
  103. const {setSpeed, speed, isSkippingInactive, toggleSkipInactive} = useReplayContext();
  104. const SKIP_OPTION_VALUE = 'skip';
  105. return (
  106. <CompositeSelect
  107. trigger={triggerProps => (
  108. <Button
  109. {...triggerProps}
  110. size="sm"
  111. title={t('Settings')}
  112. aria-label={t('Settings')}
  113. icon={<IconSettings size="sm" />}
  114. />
  115. )}
  116. >
  117. <CompositeSelect.Region
  118. label={t('Playback Speed')}
  119. value={speed}
  120. onChange={opt => setSpeed(opt.value)}
  121. options={speedOptions.map(option => ({
  122. label: `${option}x`,
  123. value: option,
  124. }))}
  125. />
  126. <CompositeSelect.Region
  127. aria-label={t('Fast-Forward Inactivity')}
  128. multiple
  129. value={isSkippingInactive ? [SKIP_OPTION_VALUE] : []}
  130. onChange={opts => {
  131. toggleSkipInactive(opts.length > 0);
  132. }}
  133. options={[
  134. {
  135. label: t('Fast-forward inactivity'),
  136. value: SKIP_OPTION_VALUE,
  137. },
  138. ]}
  139. />
  140. </CompositeSelect>
  141. );
  142. }
  143. const ReplayControls = ({
  144. toggleFullscreen,
  145. speedOptions = [0.1, 0.25, 0.5, 1, 2, 4, 8, 16],
  146. }: Props) => {
  147. const config = useLegacyStore(ConfigStore);
  148. const organization = useOrganization();
  149. const barRef = useRef<HTMLDivElement>(null);
  150. const [isCompact, setIsCompact] = useState(false);
  151. const {isFullscreen} = useFullscreen();
  152. const {currentTime, replay} = useReplayContext();
  153. const durationMs = replay?.getDurationMs();
  154. const handleFullscreenToggle = () => {
  155. if (toggleFullscreen) {
  156. trackAdvancedAnalyticsEvent('replay.toggle-fullscreen', {
  157. organization,
  158. user_email: config.user.email,
  159. fullscreen: !isFullscreen,
  160. });
  161. toggleFullscreen();
  162. }
  163. };
  164. const updateIsCompact = useCallback(() => {
  165. const {width} = barRef.current?.getBoundingClientRect() ?? {
  166. width: COMPACT_WIDTH_BREAKPOINT,
  167. };
  168. setIsCompact(width < COMPACT_WIDTH_BREAKPOINT);
  169. }, []);
  170. useResizeObserver({
  171. ref: barRef,
  172. onResize: updateIsCompact,
  173. });
  174. useLayoutEffect(() => updateIsCompact, [updateIsCompact]);
  175. const elem = useRef<HTMLDivElement>(null);
  176. const mouseTrackingProps = useScrubberMouseTracking({elem});
  177. return (
  178. <ButtonGrid ref={barRef} isCompact={isCompact}>
  179. <ReplayPlayPauseBar />
  180. <TimeAndScrubber isCompact={isCompact}>
  181. <Time>{formatTime(currentTime)}</Time>
  182. <StyledScrubber ref={elem} {...mouseTrackingProps}>
  183. <PlayerScrubber />
  184. </StyledScrubber>
  185. <Time>{durationMs ? formatTime(durationMs) : '--:--'}</Time>
  186. </TimeAndScrubber>
  187. <ButtonBar gap={1}>
  188. <ReplayOptionsMenu speedOptions={speedOptions} />
  189. <Button
  190. size="sm"
  191. title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  192. aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  193. icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
  194. onClick={handleFullscreenToggle}
  195. />
  196. </ButtonBar>
  197. </ButtonGrid>
  198. );
  199. };
  200. const ButtonGrid = styled('div')<{isCompact: boolean}>`
  201. display: flex;
  202. gap: 0 ${space(1)};
  203. flex-direction: row;
  204. justify-content: space-between;
  205. ${p => (p.isCompact ? `flex-wrap: wrap;` : '')}
  206. `;
  207. const TimeAndScrubber = styled('div')<{isCompact: boolean}>`
  208. width: 100%;
  209. display: grid;
  210. grid-column-gap: ${space(1.5)};
  211. grid-template-columns: max-content auto max-content;
  212. align-items: center;
  213. ${p =>
  214. p.isCompact
  215. ? `
  216. order: -1;
  217. min-width: 100%;
  218. margin-top: -8px;
  219. `
  220. : ''}
  221. `;
  222. const Time = styled('span')`
  223. font-variant-numeric: tabular-nums;
  224. `;
  225. const StyledScrubber = styled('div')`
  226. height: 32px;
  227. display: flex;
  228. align-items: center;
  229. `;
  230. export default ReplayControls;