replayController.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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/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. trigger={triggerProps => (
  119. <Button
  120. {...triggerProps}
  121. size="sm"
  122. title={t('Settings')}
  123. aria-label={t('Settings')}
  124. icon={<IconSettings size="sm" />}
  125. />
  126. )}
  127. sections={[
  128. {
  129. defaultValue: speed,
  130. label: t('Playback Speed'),
  131. value: 'playback_speed',
  132. onChange: setSpeed,
  133. options: speedOptions.map(option => ({
  134. label: `${option}x`,
  135. value: option,
  136. })),
  137. },
  138. {
  139. multiple: true,
  140. defaultValue: isSkippingInactive ? [SKIP_OPTION_VALUE] : [],
  141. label: '',
  142. value: 'fast_forward',
  143. onChange: (value: typeof SKIP_OPTION_VALUE[]) => {
  144. toggleSkipInactive(value.length > 0);
  145. },
  146. options: [
  147. {
  148. label: t('Fast-forward inactivity'),
  149. value: SKIP_OPTION_VALUE,
  150. },
  151. ],
  152. },
  153. ]}
  154. />
  155. );
  156. }
  157. const ReplayControls = ({
  158. toggleFullscreen,
  159. speedOptions = [0.1, 0.25, 0.5, 1, 2, 4],
  160. }: Props) => {
  161. const config = useLegacyStore(ConfigStore);
  162. const organization = useOrganization();
  163. const barRef = useRef<HTMLDivElement>(null);
  164. const [compactLevel, setCompactLevel] = useState(0);
  165. const {isFullscreen} = useFullscreen();
  166. const handleFullscreenToggle = () => {
  167. if (toggleFullscreen) {
  168. trackAdvancedAnalyticsEvent('replay.toggle-fullscreen', {
  169. organization,
  170. user_email: config.user.email,
  171. fullscreen: !isFullscreen,
  172. });
  173. toggleFullscreen();
  174. }
  175. };
  176. const updateCompactLevel = useCallback(() => {
  177. const {width} = barRef.current?.getBoundingClientRect() ?? {width: 500};
  178. if (width < 400) {
  179. setCompactLevel(1);
  180. } else {
  181. setCompactLevel(0);
  182. }
  183. }, []);
  184. useResizeObserver({
  185. ref: barRef,
  186. onResize: updateCompactLevel,
  187. });
  188. useLayoutEffect(() => updateCompactLevel, [updateCompactLevel]);
  189. return (
  190. <ButtonGrid ref={barRef}>
  191. <ReplayPlayPauseBar isCompact={compactLevel > 0} />
  192. <ReplayCurrentTime />
  193. <ReplayOptionsMenu speedOptions={speedOptions} />
  194. <Button
  195. size="sm"
  196. title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  197. aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  198. icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
  199. onClick={handleFullscreenToggle}
  200. />
  201. </ButtonGrid>
  202. );
  203. };
  204. const ButtonGrid = styled('div')`
  205. display: grid;
  206. grid-column-gap: ${space(1)};
  207. grid-template-columns: max-content auto max-content max-content;
  208. align-items: center;
  209. `;
  210. export default ReplayControls;