replayPlayer.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import React, {useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useResizeObserver} from '@react-aria/utils';
  4. import {Panel as _Panel} from 'sentry/components/panels';
  5. import BufferingOverlay from 'sentry/components/replays/player/bufferingOverlay';
  6. import FastForwardBadge from 'sentry/components/replays/player/fastForwardBadge';
  7. import {useReplayContext} from 'sentry/components/replays/replayContext';
  8. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  9. import useOrganization from 'sentry/utils/useOrganization';
  10. import PlayerDOMAlert from './playerDOMAlert';
  11. type Dimensions = ReturnType<typeof useReplayContext>['dimensions'];
  12. interface Props {
  13. className?: string;
  14. isPreview?: boolean;
  15. }
  16. function useVideoSizeLogger({
  17. videoDimensions,
  18. windowDimensions,
  19. }: {
  20. videoDimensions: Dimensions;
  21. windowDimensions: Dimensions;
  22. }) {
  23. const organization = useOrganization();
  24. const [didLog, setDidLog] = useState<boolean>(false);
  25. useEffect(() => {
  26. if (didLog || (videoDimensions.width === 0 && videoDimensions.height === 0)) {
  27. return;
  28. }
  29. const aspect_ratio =
  30. videoDimensions.width > videoDimensions.height ? 'landscape' : 'portrait';
  31. const scale = Math.min(
  32. windowDimensions.width / videoDimensions.width,
  33. windowDimensions.height / videoDimensions.height,
  34. 1
  35. );
  36. const scale_bucket = (Math.floor(scale * 10) * 10) as Parameters<
  37. typeof trackAdvancedAnalyticsEvent<'replay.render-player'>
  38. >[1]['scale_bucket'];
  39. trackAdvancedAnalyticsEvent('replay.render-player', {
  40. organization,
  41. aspect_ratio,
  42. scale_bucket,
  43. });
  44. setDidLog(true);
  45. }, [organization, windowDimensions, videoDimensions, didLog]);
  46. }
  47. function BasePlayerRoot({className, isPreview = false}: Props) {
  48. const {
  49. initRoot,
  50. dimensions: videoDimensions,
  51. fastForwardSpeed,
  52. isBuffering,
  53. } = useReplayContext();
  54. const windowEl = useRef<HTMLDivElement>(null);
  55. const viewEl = useRef<HTMLDivElement>(null);
  56. const [windowDimensions, setWindowDimensions] = useState<Dimensions>({
  57. width: 0,
  58. height: 0,
  59. });
  60. useVideoSizeLogger({videoDimensions, windowDimensions});
  61. // Create the `rrweb` instance which creates an iframe inside `viewEl`
  62. useEffect(() => initRoot(viewEl.current), [initRoot]);
  63. // Read the initial width & height where the player will be inserted, this is
  64. // so we can shrink the video into the available space.
  65. // If the size of the container changes, we can re-calculate the scaling factor
  66. const updateWindowDimensions = useCallback(
  67. () =>
  68. setWindowDimensions({
  69. width: windowEl.current?.clientWidth || 0,
  70. height: windowEl.current?.clientHeight || 0,
  71. }),
  72. [setWindowDimensions]
  73. );
  74. useResizeObserver({ref: windowEl, onResize: updateWindowDimensions});
  75. // If your browser doesn't have ResizeObserver then set the size once.
  76. useEffect(() => {
  77. if (typeof window.ResizeObserver !== 'undefined') {
  78. return;
  79. }
  80. updateWindowDimensions();
  81. }, [updateWindowDimensions]);
  82. // Update the scale of the view whenever dimensions have changed.
  83. useEffect(() => {
  84. if (viewEl.current) {
  85. const scale = Math.min(
  86. windowDimensions.width / videoDimensions.width,
  87. windowDimensions.height / videoDimensions.height,
  88. 1
  89. );
  90. if (scale) {
  91. viewEl.current.style['transform-origin'] = 'top left';
  92. viewEl.current.style.transform = `scale(${scale})`;
  93. viewEl.current.style.width = `${videoDimensions.width * scale}px`;
  94. viewEl.current.style.height = `${videoDimensions.height * scale}px`;
  95. }
  96. }
  97. }, [windowDimensions, videoDimensions]);
  98. return (
  99. <SizingWindow ref={windowEl} className="sentry-block">
  100. <div ref={viewEl} className={className} />
  101. {fastForwardSpeed ? <PositionedFastForward speed={fastForwardSpeed} /> : null}
  102. {isBuffering ? <PositionedBuffering /> : null}
  103. {!isPreview ? <PlayerDOMAlert /> : null}
  104. </SizingWindow>
  105. );
  106. }
  107. // Center the viewEl inside the windowEl.
  108. // This is useful when the window is inside a container that has large fixed
  109. // dimensions, like when in fullscreen mode.
  110. // If the container has a dimensions that can grow/shrink then it is
  111. // important to also set `overflow: hidden` on the container, so that the
  112. // SizingWindow can calculate size as things shrink.
  113. const SizingWindow = styled('div')`
  114. width: 100%;
  115. display: flex;
  116. flex-grow: 1;
  117. justify-content: center;
  118. align-items: center;
  119. position: relative;
  120. overflow: hidden;
  121. background-color: ${p => p.theme.backgroundSecondary};
  122. background-image: repeating-linear-gradient(
  123. -145deg,
  124. transparent,
  125. transparent 8px,
  126. ${p => p.theme.backgroundSecondary} 8px,
  127. ${p => p.theme.backgroundSecondary} 11px
  128. ),
  129. repeating-linear-gradient(
  130. -45deg,
  131. transparent,
  132. transparent 15px,
  133. ${p => p.theme.gray100} 15px,
  134. ${p => p.theme.gray100} 16px
  135. );
  136. `;
  137. const PositionedFastForward = styled(FastForwardBadge)`
  138. position: absolute;
  139. left: 0;
  140. bottom: 0;
  141. `;
  142. const PositionedBuffering = styled(BufferingOverlay)`
  143. position: absolute;
  144. top: 0;
  145. left: 0;
  146. right: 0;
  147. bottom: 0;
  148. `;
  149. // Base styles, to make the Replayer instance work
  150. const PlayerRoot = styled(BasePlayerRoot)`
  151. .replayer-wrapper {
  152. user-select: none;
  153. }
  154. .replayer-wrapper > .replayer-mouse-tail {
  155. position: absolute;
  156. pointer-events: none;
  157. }
  158. /* Override default user-agent styles */
  159. .replayer-wrapper > iframe {
  160. border: none;
  161. background: white;
  162. }
  163. `;
  164. // Sentry-specific styles for the player.
  165. // The elements we have to work with are:
  166. // ```css
  167. // div.replayer-wrapper {}
  168. // div.replayer-wrapper > div.replayer-mouse {}
  169. // div.replayer-wrapper > canvas.replayer-mouse-tail {}
  170. // div.replayer-wrapper > iframe {}
  171. // ```
  172. // The mouse-tail is also configured for color/size in `app/components/replays/replayContext.tsx`
  173. const SentryPlayerRoot = styled(PlayerRoot)`
  174. .replayer-mouse {
  175. position: absolute;
  176. width: 32px;
  177. height: 32px;
  178. transition: left 0.05s linear, top 0.05s linear;
  179. background-size: contain;
  180. background-repeat: no-repeat;
  181. background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTkiIHZpZXdCb3g9IjAgMCAxMiAxOSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMTZWMEwxMS42IDExLjZINC44TDQuNCAxMS43TDAgMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOS4xIDE2LjdMNS41IDE4LjJMMC43OTk5OTkgNy4xTDQuNSA1LjZMOS4xIDE2LjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNC42NzQ1MSA4LjYxODUxTDIuODMwMzEgOS4zOTI3MUw1LjkyNzExIDE2Ljc2OTVMNy43NzEzMSAxNS45OTUzTDQuNjc0NTEgOC42MTg1MVoiIGZpbGw9ImJsYWNrIi8+CjxwYXRoIGQ9Ik0xIDIuNFYxMy42TDQgMTAuN0w0LjQgMTAuNkg5LjJMMSAyLjRaIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4K');
  182. border-color: transparent;
  183. }
  184. .replayer-mouse:after {
  185. content: '';
  186. display: inline-block;
  187. width: 32px;
  188. height: 32px;
  189. background: ${p => p.theme.purple300};
  190. border-radius: 100%;
  191. transform: translate(-50%, -50%);
  192. opacity: 0.3;
  193. }
  194. .replayer-mouse.active:after {
  195. animation: click 0.2s ease-in-out 1;
  196. }
  197. .replayer-mouse.touch-device {
  198. background-image: none;
  199. width: 70px;
  200. height: 70px;
  201. border-radius: 100%;
  202. margin-left: -37px;
  203. margin-top: -37px;
  204. border: 4px solid rgba(73, 80, 246, 0);
  205. transition: left 0s linear, top 0s linear, border-color 0.2s ease-in-out;
  206. }
  207. .replayer-mouse.touch-device.touch-active {
  208. border-color: ${p => p.theme.purple200};
  209. transition: left 0.25s linear, top 0.25s linear, border-color 0.2s ease-in-out;
  210. }
  211. .replayer-mouse.touch-device:after {
  212. opacity: 0;
  213. }
  214. .replayer-mouse.touch-device.active:after {
  215. animation: touch-click 0.2s ease-in-out 1;
  216. }
  217. @keyframes click {
  218. 0% {
  219. opacity: 0.3;
  220. width: 20px;
  221. height: 20px;
  222. }
  223. 50% {
  224. opacity: 0.5;
  225. width: 10px;
  226. height: 10px;
  227. }
  228. }
  229. @keyframes touch-click {
  230. 0% {
  231. opacity: 0;
  232. width: 20px;
  233. height: 20px;
  234. }
  235. 50% {
  236. opacity: 0.5;
  237. width: 10px;
  238. height: 10px;
  239. }
  240. }
  241. `;
  242. export default SentryPlayerRoot;