replayController.tsx 7.3 KB

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