replayPlayer.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import {useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useResizeObserver} from '@react-aria/utils';
  4. import {Panel} from 'sentry/components/panels';
  5. import {Consumer as ReplayContextProvider} from 'sentry/components/replays/replayContext';
  6. interface Props {
  7. className?: string;
  8. }
  9. type Dimensions = {height: number; width: number};
  10. type RootElem = null | HTMLDivElement;
  11. type RootProps = {
  12. initRoot: (root: RootElem) => void;
  13. videoDimensions: Dimensions;
  14. className?: string;
  15. };
  16. function BasePlayerRoot({className, initRoot, videoDimensions}: RootProps) {
  17. const windowEl = useRef<HTMLDivElement>(null);
  18. const viewEl = useRef<HTMLDivElement>(null);
  19. const [windowDimensions, setWindowDimensions] = useState<Dimensions>();
  20. // Create the `rrweb` instance which creates an iframe inside `viewEl`
  21. useEffect(() => initRoot(viewEl.current), [viewEl.current]);
  22. // Read the initial width & height where the player will be inserted, this is
  23. // so we can shrink the video into the available space.
  24. // If the size of the container changes, we can re-calculate the scaling factor
  25. const updateWindowDimensions = useCallback(
  26. () =>
  27. setWindowDimensions({
  28. width: windowEl.current?.clientWidth,
  29. height: windowEl.current?.clientHeight,
  30. } as Dimensions),
  31. [windowEl.current]
  32. );
  33. useResizeObserver({ref: windowEl, onResize: updateWindowDimensions});
  34. // If your browser doesn't have ResizeObserver then set the size once.
  35. useEffect(() => {
  36. if (typeof window.ResizeObserver !== 'undefined') {
  37. return;
  38. }
  39. updateWindowDimensions();
  40. }, [updateWindowDimensions]);
  41. // Update the scale of the view whenever dimensions have changed.
  42. useEffect(() => {
  43. if (viewEl.current) {
  44. const scale = Math.min((windowDimensions?.width || 0) / videoDimensions.width, 1);
  45. if (scale) {
  46. viewEl.current.style.transform = `scale(${scale})`;
  47. viewEl.current.style.width = `${videoDimensions.width * scale}px`;
  48. viewEl.current.style.height = `${videoDimensions.height * scale}px`;
  49. }
  50. }
  51. }, [windowDimensions, videoDimensions]);
  52. return (
  53. <div ref={windowEl} data-test-id="replay-window">
  54. <div ref={viewEl} data-test-id="replay-view" className={className} />
  55. </div>
  56. );
  57. }
  58. // Base styles, to make the player work
  59. const PlayerRoot = styled(BasePlayerRoot)`
  60. /* Make sure the replayer fits inside it's container */
  61. transform-origin: top left;
  62. /* Fix the replayer layout so layers are stacked properly */
  63. .replayer-wrapper > .replayer-mouse-tail {
  64. position: absolute;
  65. pointer-events: none;
  66. }
  67. /* Override default user-agent styles */
  68. .replayer-wrapper > iframe {
  69. border: none;
  70. }
  71. `;
  72. const PlayerPanel = styled(Panel)`
  73. iframe {
  74. border-radius: ${p => p.theme.borderRadius};
  75. }
  76. `;
  77. // Sentry-specific styles for the player.
  78. // The elements we have to work with are:
  79. // ```css
  80. // div.replayer-wrapper {}
  81. // div.replayer-wrapper > div.replayer-mouse {}
  82. // div.replayer-wrapper > canvas.replayer-mouse-tail {}
  83. // div.replayer-wrapper > iframe {}
  84. // ```
  85. // The mouse-tail is also configured for color/size in `app/components/replays/replayContext.tsx`
  86. const SentryPlayerRoot = styled(PlayerRoot)`
  87. .replayer-mouse {
  88. position: absolute;
  89. width: 32px;
  90. height: 32px;
  91. transition: left 0.05s linear, top 0.05s linear;
  92. background-size: contain;
  93. background-repeat: no-repeat;
  94. background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTkiIHZpZXdCb3g9IjAgMCAxMiAxOSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMTZWMEwxMS42IDExLjZINC44TDQuNCAxMS43TDAgMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOS4xIDE2LjdMNS41IDE4LjJMMC43OTk5OTkgNy4xTDQuNSA1LjZMOS4xIDE2LjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNC42NzQ1MSA4LjYxODUxTDIuODMwMzEgOS4zOTI3MUw1LjkyNzExIDE2Ljc2OTVMNy43NzEzMSAxNS45OTUzTDQuNjc0NTEgOC42MTg1MVoiIGZpbGw9ImJsYWNrIi8+CjxwYXRoIGQ9Ik0xIDIuNFYxMy42TDQgMTAuN0w0LjQgMTAuNkg5LjJMMSAyLjRaIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4K');
  95. border-color: transparent;
  96. }
  97. .replayer-mouse:after {
  98. content: '';
  99. display: inline-block;
  100. width: 32px;
  101. height: 32px;
  102. background: ${p => p.theme.purple300};
  103. border-radius: 100%;
  104. transform: translate(-50%, -50%);
  105. opacity: 0.3;
  106. }
  107. .replayer-mouse.active:after {
  108. animation: click 0.2s ease-in-out 1;
  109. }
  110. .replayer-mouse.touch-device {
  111. background-image: none;
  112. width: 70px;
  113. height: 70px;
  114. border-radius: 100%;
  115. margin-left: -37px;
  116. margin-top: -37px;
  117. border: 4px solid rgba(73, 80, 246, 0);
  118. transition: left 0s linear, top 0s linear, border-color 0.2s ease-in-out;
  119. }
  120. .replayer-mouse.touch-device.touch-active {
  121. border-color: ${p => p.theme.purple200};
  122. transition: left 0.25s linear, top 0.25s linear, border-color 0.2s ease-in-out;
  123. }
  124. .replayer-mouse.touch-device:after {
  125. opacity: 0;
  126. }
  127. .replayer-mouse.touch-device.active:after {
  128. animation: touch-click 0.2s ease-in-out 1;
  129. }
  130. @keyframes click {
  131. 0% {
  132. opacity: 0.3;
  133. width: 20px;
  134. height: 20px;
  135. }
  136. 50% {
  137. opacity: 0.5;
  138. width: 10px;
  139. height: 10px;
  140. }
  141. }
  142. @keyframes touch-click {
  143. 0% {
  144. opacity: 0;
  145. width: 20px;
  146. height: 20px;
  147. }
  148. 50% {
  149. opacity: 0.5;
  150. width: 10px;
  151. height: 10px;
  152. }
  153. }
  154. `;
  155. export default function ReplayPlayer({className}: Props) {
  156. return (
  157. <ReplayContextProvider>
  158. {({initRoot, dimensions}) => (
  159. <PlayerPanel>
  160. <SentryPlayerRoot
  161. className={className}
  162. initRoot={initRoot}
  163. videoDimensions={dimensions}
  164. />
  165. </PlayerPanel>
  166. )}
  167. </ReplayContextProvider>
  168. );
  169. }