replayPlayer.tsx 6.6 KB

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