replayController.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import React, {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/forms/compositeSelect';
  7. import {useReplayContext} from 'sentry/components/replays/replayContext';
  8. import {formatTime, relativeTimeInMs} from 'sentry/components/replays/utils';
  9. import {
  10. IconContract,
  11. IconExpand,
  12. IconNext,
  13. IconPause,
  14. IconPlay,
  15. IconPrevious,
  16. IconRewind10,
  17. IconSettings,
  18. } from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import ConfigStore from 'sentry/stores/configStore';
  21. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  22. import space from 'sentry/styles/space';
  23. import {SelectValue} from 'sentry/types';
  24. import {BreadcrumbType} from 'sentry/types/breadcrumbs';
  25. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  26. import {getNextReplayEvent} from 'sentry/utils/replays/getReplayEvent';
  27. import useFullscreen from 'sentry/utils/replays/hooks/useFullscreen';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. const SECOND = 1000;
  30. const USER_ACTIONS = [
  31. BreadcrumbType.ERROR,
  32. BreadcrumbType.INIT,
  33. BreadcrumbType.NAVIGATION,
  34. BreadcrumbType.UI,
  35. BreadcrumbType.USER,
  36. ];
  37. interface Props {
  38. speedOptions?: number[];
  39. toggleFullscreen?: () => void;
  40. }
  41. function ReplayPlayPauseBar({isCompact}: {isCompact: boolean}) {
  42. const {
  43. currentTime,
  44. isFinished,
  45. isPlaying,
  46. replay,
  47. restart,
  48. setCurrentTime,
  49. togglePlayPause,
  50. } = useReplayContext();
  51. return (
  52. <ButtonBar merged>
  53. {!isCompact && (
  54. <Button
  55. size="sm"
  56. title={t('Rewind 10s')}
  57. icon={<IconRewind10 size="sm" />}
  58. onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
  59. aria-label={t('Rewind 10 seconds')}
  60. />
  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. {!isCompact && (
  80. <Button
  81. size="sm"
  82. title={t('Next breadcrumb')}
  83. icon={<IconNext size="sm" />}
  84. onClick={() => {
  85. const startTimestampMs = replay?.getReplay().startedAt?.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. )}
  101. </ButtonBar>
  102. );
  103. }
  104. function ReplayCurrentTime() {
  105. const {currentTime, replay} = useReplayContext();
  106. const durationMs = replay?.getDurationMs();
  107. return (
  108. <span>
  109. {formatTime(currentTime)} / {durationMs ? formatTime(durationMs) : '--:--'}
  110. </span>
  111. );
  112. }
  113. function ReplayOptionsMenu({speedOptions}: {speedOptions: number[]}) {
  114. const {setSpeed, speed, isSkippingInactive, toggleSkipInactive} = useReplayContext();
  115. const SKIP_OPTION_VALUE = 'skip';
  116. return (
  117. <CompositeSelect<SelectValue<string | number>>
  118. placement="bottom"
  119. trigger={({props, ref}) => (
  120. <Button
  121. ref={ref}
  122. {...props}
  123. size="sm"
  124. title={t('Settings')}
  125. aria-label={t('Settings')}
  126. icon={<IconSettings size="sm" />}
  127. />
  128. )}
  129. sections={[
  130. {
  131. defaultValue: speed,
  132. label: t('Playback Speed'),
  133. value: 'playback_speed',
  134. onChange: setSpeed,
  135. options: speedOptions.map(option => ({
  136. label: `${option}x`,
  137. value: option,
  138. })),
  139. },
  140. {
  141. multiple: true,
  142. defaultValue: isSkippingInactive ? [SKIP_OPTION_VALUE] : [],
  143. label: '',
  144. value: 'fast_forward',
  145. onChange: (value: typeof SKIP_OPTION_VALUE[]) => {
  146. toggleSkipInactive(value.length > 0);
  147. },
  148. options: [
  149. {
  150. label: t('Fast-forward inactivity'),
  151. value: SKIP_OPTION_VALUE,
  152. },
  153. ],
  154. },
  155. ]}
  156. />
  157. );
  158. }
  159. const ReplayControls = ({
  160. toggleFullscreen,
  161. speedOptions = [0.1, 0.25, 0.5, 1, 2, 4],
  162. }: Props) => {
  163. const config = useLegacyStore(ConfigStore);
  164. const organization = useOrganization();
  165. const barRef = useRef<HTMLDivElement>(null);
  166. const [compactLevel, setCompactLevel] = useState(0);
  167. const {isFullscreen} = useFullscreen();
  168. const handleFullscreenToggle = () => {
  169. if (toggleFullscreen) {
  170. trackAdvancedAnalyticsEvent('replay.toggle-fullscreen', {
  171. organization,
  172. user_email: config.user.email,
  173. fullscreen: !isFullscreen,
  174. });
  175. toggleFullscreen();
  176. }
  177. };
  178. const updateCompactLevel = useCallback(() => {
  179. const {width} = barRef.current?.getBoundingClientRect() ?? {width: 500};
  180. if (width < 400) {
  181. setCompactLevel(1);
  182. } else {
  183. setCompactLevel(0);
  184. }
  185. }, []);
  186. useResizeObserver({
  187. ref: barRef,
  188. onResize: updateCompactLevel,
  189. });
  190. useLayoutEffect(() => updateCompactLevel, [updateCompactLevel]);
  191. return (
  192. <ButtonGrid ref={barRef}>
  193. <ReplayPlayPauseBar isCompact={compactLevel > 0} />
  194. <ReplayCurrentTime />
  195. <ReplayOptionsMenu speedOptions={speedOptions} />
  196. <Button
  197. size="sm"
  198. title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  199. aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  200. icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
  201. onClick={handleFullscreenToggle}
  202. />
  203. </ButtonGrid>
  204. );
  205. };
  206. const ButtonGrid = styled('div')`
  207. display: grid;
  208. grid-column-gap: ${space(1)};
  209. grid-template-columns: max-content auto max-content max-content;
  210. align-items: center;
  211. `;
  212. export default ReplayControls;