replayController.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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 space from 'sentry/styles/space';
  21. import {SelectValue} from 'sentry/types';
  22. import {BreadcrumbType} from 'sentry/types/breadcrumbs';
  23. import {getNextBreadcrumb} from 'sentry/utils/replays/getBreadcrumb';
  24. import useFullscreen from 'sentry/utils/replays/hooks/useFullscreen';
  25. const SECOND = 1000;
  26. const USER_ACTIONS = [
  27. BreadcrumbType.ERROR,
  28. BreadcrumbType.INIT,
  29. BreadcrumbType.NAVIGATION,
  30. BreadcrumbType.UI,
  31. BreadcrumbType.USER,
  32. ];
  33. interface Props {
  34. speedOptions?: number[];
  35. toggleFullscreen?: () => void;
  36. }
  37. function ReplayPlayPauseBar({isCompact}: {isCompact: boolean}) {
  38. const {
  39. currentTime,
  40. isFinished,
  41. isPlaying,
  42. replay,
  43. restart,
  44. setCurrentTime,
  45. togglePlayPause,
  46. } = useReplayContext();
  47. return (
  48. <ButtonBar merged>
  49. {!isCompact && (
  50. <Button
  51. size="sm"
  52. title={t('Rewind 10s')}
  53. icon={<IconRewind10 size="sm" />}
  54. onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
  55. aria-label={t('Rewind 10 seconds')}
  56. />
  57. )}
  58. {isFinished ? (
  59. <Button
  60. size="sm"
  61. title={t('Restart Replay')}
  62. icon={<IconPrevious size="sm" />}
  63. onClick={restart}
  64. aria-label={t('Restart Replay')}
  65. />
  66. ) : (
  67. <Button
  68. size="sm"
  69. title={isPlaying ? t('Pause') : t('Play')}
  70. icon={isPlaying ? <IconPause size="sm" /> : <IconPlay size="sm" />}
  71. onClick={() => togglePlayPause(!isPlaying)}
  72. aria-label={isPlaying ? t('Pause') : t('Play')}
  73. />
  74. )}
  75. {!isCompact && (
  76. <Button
  77. size="sm"
  78. title={t('Next breadcrumb')}
  79. icon={<IconNext size="sm" />}
  80. onClick={() => {
  81. const startTimestampMs = replay?.getReplay().startedAt?.getTime();
  82. if (!startTimestampMs) {
  83. return;
  84. }
  85. const transformedCrumbs = replay?.getRawCrumbs() || [];
  86. const next = getNextBreadcrumb({
  87. crumbs: transformedCrumbs.filter(crumb =>
  88. USER_ACTIONS.includes(crumb.type)
  89. ),
  90. targetTimestampMs: startTimestampMs + currentTime,
  91. });
  92. if (startTimestampMs !== undefined && next?.timestamp) {
  93. setCurrentTime(relativeTimeInMs(next.timestamp, startTimestampMs));
  94. }
  95. }}
  96. aria-label={t('Fast-forward to next breadcrumb')}
  97. />
  98. )}
  99. </ButtonBar>
  100. );
  101. }
  102. function ReplayCurrentTime() {
  103. const {currentTime, replay} = useReplayContext();
  104. const durationMs = replay?.getDurationMs();
  105. return (
  106. <span>
  107. {formatTime(currentTime)} / {durationMs ? formatTime(durationMs) : '--:--'}
  108. </span>
  109. );
  110. }
  111. function ReplayOptionsMenu({speedOptions}: {speedOptions: number[]}) {
  112. const {setSpeed, speed, isSkippingInactive, toggleSkipInactive} = useReplayContext();
  113. const SKIP_OPTION_VALUE = 'skip';
  114. return (
  115. <CompositeSelect<SelectValue<string | number>>
  116. placement="bottom"
  117. trigger={({props, ref}) => (
  118. <Button
  119. ref={ref}
  120. {...props}
  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 : undefined,
  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 barRef = useRef<HTMLDivElement>(null);
  162. const [compactLevel, setCompactLevel] = useState(0);
  163. const {isFullscreen} = useFullscreen();
  164. const updateCompactLevel = useCallback(() => {
  165. const {width} = barRef.current?.getBoundingClientRect() ?? {width: 500};
  166. if (width < 400) {
  167. setCompactLevel(1);
  168. } else {
  169. setCompactLevel(0);
  170. }
  171. }, []);
  172. useResizeObserver({
  173. ref: barRef,
  174. onResize: updateCompactLevel,
  175. });
  176. useLayoutEffect(() => updateCompactLevel, [updateCompactLevel]);
  177. return (
  178. <ButtonGrid ref={barRef}>
  179. <ReplayPlayPauseBar isCompact={compactLevel > 0} />
  180. <ReplayCurrentTime />
  181. <ReplayOptionsMenu speedOptions={speedOptions} />
  182. <Button
  183. size="sm"
  184. title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  185. aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  186. icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
  187. onClick={toggleFullscreen}
  188. />
  189. </ButtonGrid>
  190. );
  191. };
  192. const ButtonGrid = styled('div')`
  193. display: grid;
  194. grid-column-gap: ${space(1)};
  195. grid-template-columns: max-content auto max-content max-content;
  196. align-items: center;
  197. `;
  198. export default ReplayControls;