replayController.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import {useCallback, useLayoutEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useResizeObserver} from '@react-aria/utils';
  4. import screenfull from 'screenfull';
  5. import {Button} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import {CompositeSelect} from 'sentry/components/compactSelect/composite';
  8. import ReplayTimeline from 'sentry/components/replays/breadcrumbs/replayTimeline';
  9. import {PlayerScrubber} from 'sentry/components/replays/player/scrubber';
  10. import useScrubberMouseTracking from 'sentry/components/replays/player/useScrubberMouseTracking';
  11. import {useReplayContext} from 'sentry/components/replays/replayContext';
  12. import {formatTime} from 'sentry/components/replays/utils';
  13. import {
  14. IconAdd,
  15. IconContract,
  16. IconExpand,
  17. IconNext,
  18. IconPause,
  19. IconPlay,
  20. IconPrevious,
  21. IconRewind10,
  22. IconSettings,
  23. IconSubtract,
  24. } from 'sentry/icons';
  25. import {t} from 'sentry/locale';
  26. import ConfigStore from 'sentry/stores/configStore';
  27. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  28. import {space} from 'sentry/styles/space';
  29. import {trackAnalytics} from 'sentry/utils/analytics';
  30. import {getNextReplayFrame} from 'sentry/utils/replays/getReplayEvent';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import useIsFullscreen from 'sentry/utils/window/useIsFullscreen';
  33. const SECOND = 1000;
  34. const COMPACT_WIDTH_BREAKPOINT = 500;
  35. interface Props {
  36. toggleFullscreen: () => void;
  37. speedOptions?: number[];
  38. }
  39. function ReplayPlayPauseBar() {
  40. const {
  41. currentTime,
  42. isFinished,
  43. isPlaying,
  44. replay,
  45. restart,
  46. setCurrentTime,
  47. togglePlayPause,
  48. } = useReplayContext();
  49. return (
  50. <ButtonBar gap={1}>
  51. <Button
  52. size="sm"
  53. title={t('Rewind 10s')}
  54. icon={<IconRewind10 size="sm" />}
  55. onClick={() => setCurrentTime(currentTime - 10 * SECOND)}
  56. aria-label={t('Rewind 10 seconds')}
  57. />
  58. {isFinished ? (
  59. <Button
  60. size="md"
  61. title={t('Restart Replay')}
  62. icon={<IconPrevious size="md" />}
  63. onClick={restart}
  64. aria-label={t('Restart Replay')}
  65. priority="primary"
  66. />
  67. ) : (
  68. <Button
  69. size="md"
  70. title={isPlaying ? t('Pause') : t('Play')}
  71. icon={isPlaying ? <IconPause size="md" /> : <IconPlay size="md" />}
  72. onClick={() => togglePlayPause(!isPlaying)}
  73. aria-label={isPlaying ? t('Pause') : t('Play')}
  74. priority="primary"
  75. />
  76. )}
  77. <Button
  78. size="sm"
  79. title={t('Next breadcrumb')}
  80. icon={<IconNext size="sm" />}
  81. onClick={() => {
  82. if (!replay) {
  83. return;
  84. }
  85. const next = getNextReplayFrame({
  86. frames: replay.getChapterFrames(),
  87. targetOffsetMs: currentTime,
  88. });
  89. if (next) {
  90. setCurrentTime(next.offsetMs);
  91. }
  92. }}
  93. aria-label={t('Fast-forward to next breadcrumb')}
  94. />
  95. </ButtonBar>
  96. );
  97. }
  98. function ReplayOptionsMenu({speedOptions}: {speedOptions: number[]}) {
  99. const {setSpeed, speed, isSkippingInactive, toggleSkipInactive} = useReplayContext();
  100. const SKIP_OPTION_VALUE = 'skip';
  101. return (
  102. <CompositeSelect
  103. trigger={triggerProps => (
  104. <Button
  105. {...triggerProps}
  106. size="sm"
  107. title={t('Settings')}
  108. aria-label={t('Settings')}
  109. icon={<IconSettings size="sm" />}
  110. />
  111. )}
  112. >
  113. <CompositeSelect.Region
  114. label={t('Playback Speed')}
  115. value={speed}
  116. onChange={opt => setSpeed(opt.value)}
  117. options={speedOptions.map(option => ({
  118. label: `${option}x`,
  119. value: option,
  120. }))}
  121. />
  122. <CompositeSelect.Region
  123. aria-label={t('Fast-Forward Inactivity')}
  124. multiple
  125. value={isSkippingInactive ? [SKIP_OPTION_VALUE] : []}
  126. onChange={opts => {
  127. toggleSkipInactive(opts.length > 0);
  128. }}
  129. options={[
  130. {
  131. label: t('Fast-forward inactivity'),
  132. value: SKIP_OPTION_VALUE,
  133. },
  134. ]}
  135. />
  136. </CompositeSelect>
  137. );
  138. }
  139. function TimelineSizeBar({size, setSize}) {
  140. return (
  141. <ButtonBar merged>
  142. <Button
  143. size="xs"
  144. title={t('Zoom out')}
  145. icon={<IconSubtract size="xs" />}
  146. borderless
  147. onClick={() => setSize(Math.max(size - 50, 100))}
  148. aria-label={t('Zoom out')}
  149. />
  150. <Button
  151. size="xs"
  152. title={t('Zoom in')}
  153. icon={<IconAdd size="xs" />}
  154. borderless
  155. onClick={() => setSize(Math.min(size + 50, 1000))}
  156. aria-label={t('Zoom in')}
  157. />
  158. </ButtonBar>
  159. );
  160. }
  161. function ReplayControls({
  162. toggleFullscreen,
  163. speedOptions = [0.1, 0.25, 0.5, 1, 2, 4, 8, 16],
  164. }: Props) {
  165. const config = useLegacyStore(ConfigStore);
  166. const organization = useOrganization();
  167. const barRef = useRef<HTMLDivElement>(null);
  168. const [isCompact, setIsCompact] = useState(false);
  169. const isFullscreen = useIsFullscreen();
  170. const {currentTime, replay} = useReplayContext();
  171. const durationMs = replay?.getDurationMs();
  172. const [size, setSize] = useState(300);
  173. // If the browser supports going fullscreen or not. iPhone Safari won't do
  174. // it. https://caniuse.com/fullscreen
  175. const showFullscreenButton = screenfull.isEnabled;
  176. const handleFullscreenToggle = useCallback(() => {
  177. trackAnalytics('replay.toggle-fullscreen', {
  178. organization,
  179. user_email: config.user.email,
  180. fullscreen: !isFullscreen,
  181. });
  182. toggleFullscreen();
  183. }, [config.user.email, isFullscreen, organization, toggleFullscreen]);
  184. const updateIsCompact = useCallback(() => {
  185. const {width} = barRef.current?.getBoundingClientRect() ?? {
  186. width: COMPACT_WIDTH_BREAKPOINT,
  187. };
  188. setIsCompact(width < COMPACT_WIDTH_BREAKPOINT);
  189. }, []);
  190. useResizeObserver({
  191. ref: barRef,
  192. onResize: updateIsCompact,
  193. });
  194. useLayoutEffect(() => updateIsCompact, [updateIsCompact]);
  195. const elem = useRef<HTMLDivElement>(null);
  196. const mouseTrackingProps = useScrubberMouseTracking({elem});
  197. const hasNewTimeline = organization.features.includes('session-replay-new-timeline');
  198. return (
  199. <ButtonGrid ref={barRef} isCompact={isCompact}>
  200. <ReplayPlayPauseBar />
  201. <Container>
  202. {hasNewTimeline ? (
  203. <TimeAndScrubberGrid isCompact={isCompact}>
  204. <Time style={{gridArea: 'currentTime'}}>{formatTime(currentTime)}</Time>
  205. <div style={{gridArea: 'timeline'}}>
  206. <ReplayTimeline size={size} />
  207. </div>
  208. <div style={{gridArea: 'timelineSize'}}>
  209. <TimelineSizeBar size={size} setSize={setSize} />
  210. </div>
  211. <StyledScrubber
  212. style={{gridArea: 'scrubber'}}
  213. ref={elem}
  214. {...mouseTrackingProps}
  215. >
  216. <PlayerScrubber />
  217. </StyledScrubber>
  218. <Time style={{gridArea: 'duration'}}>
  219. {durationMs ? formatTime(durationMs) : '--:--'}
  220. </Time>
  221. </TimeAndScrubberGrid>
  222. ) : (
  223. <TimeAndScrubber isCompact={isCompact}>
  224. <Time>{formatTime(currentTime)}</Time>
  225. <StyledScrubber ref={elem} {...mouseTrackingProps}>
  226. <PlayerScrubber />
  227. </StyledScrubber>
  228. <Time>{durationMs ? formatTime(durationMs) : '--:--'}</Time>
  229. </TimeAndScrubber>
  230. )}
  231. </Container>
  232. <ButtonBar gap={1}>
  233. <ReplayOptionsMenu speedOptions={speedOptions} />
  234. {showFullscreenButton ? (
  235. <Button
  236. size="sm"
  237. title={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  238. aria-label={isFullscreen ? t('Exit full screen') : t('Enter full screen')}
  239. icon={isFullscreen ? <IconContract size="sm" /> : <IconExpand size="sm" />}
  240. onClick={handleFullscreenToggle}
  241. />
  242. ) : null}
  243. </ButtonBar>
  244. </ButtonGrid>
  245. );
  246. }
  247. const ButtonGrid = styled('div')<{isCompact: boolean}>`
  248. display: flex;
  249. gap: 0 ${space(2)};
  250. flex-direction: row;
  251. justify-content: space-between;
  252. ${p => (p.isCompact ? `flex-wrap: wrap;` : '')}
  253. `;
  254. const Container = styled('div')`
  255. display: flex;
  256. flex-direction: column;
  257. flex: 1 1;
  258. justify-content: center;
  259. `;
  260. const TimeAndScrubber = styled('div')<{isCompact: boolean}>`
  261. width: 100%;
  262. display: grid;
  263. grid-column-gap: ${space(1.5)};
  264. grid-template-columns: max-content auto max-content;
  265. align-items: center;
  266. ${p =>
  267. p.isCompact
  268. ? `
  269. order: -1;
  270. min-width: 100%;
  271. margin-top: -8px;
  272. `
  273. : ''}
  274. `;
  275. const TimeAndScrubberGrid = styled('div')<{isCompact: boolean}>`
  276. width: 100%;
  277. display: grid;
  278. grid-template-areas:
  279. '. timeline timelineSize'
  280. 'currentTime scrubber duration';
  281. grid-column-gap: ${space(1)};
  282. grid-template-columns: max-content auto max-content;
  283. align-items: center;
  284. ${p =>
  285. p.isCompact
  286. ? `
  287. order: -1;
  288. min-width: 100%;
  289. margin-top: -8px;
  290. `
  291. : ''}
  292. `;
  293. const Time = styled('span')`
  294. font-variant-numeric: tabular-nums;
  295. padding: 0 ${space(1.5)};
  296. `;
  297. const StyledScrubber = styled('div')`
  298. height: 32px;
  299. display: flex;
  300. align-items: center;
  301. `;
  302. export default ReplayControls;