import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {useResizeObserver} from '@react-aria/utils'; import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import BufferingOverlay from 'sentry/components/replays/player/bufferingOverlay'; import FastForwardBadge from 'sentry/components/replays/player/fastForwardBadge'; import {useReplayContext} from 'sentry/components/replays/replayContext'; import {trackAnalytics} from 'sentry/utils/analytics'; import useOrganization from 'sentry/utils/useOrganization'; import PlayerDOMAlert from './playerDOMAlert'; type Dimensions = ReturnType['dimensions']; interface Props { className?: string; isPreview?: boolean; overlayContent?: React.ReactNode; } function useVideoSizeLogger({ videoDimensions, windowDimensions, }: { videoDimensions: Dimensions; windowDimensions: Dimensions; }) { const organization = useOrganization(); const [didLog, setDidLog] = useState(false); const {analyticsContext} = useReplayContext(); useEffect(() => { if (didLog || (videoDimensions.width === 0 && videoDimensions.height === 0)) { return; } const aspect_ratio = videoDimensions.width > videoDimensions.height ? 'landscape' : 'portrait'; const scale = Math.min( windowDimensions.width / videoDimensions.width, windowDimensions.height / videoDimensions.height, 1 ); const scale_bucket = (Math.floor(scale * 10) * 10) as Parameters< typeof trackAnalytics<'replay.render-player'> >[1]['scale_bucket']; trackAnalytics('replay.render-player', { organization, aspect_ratio, context: analyticsContext, scale_bucket, }); setDidLog(true); }, [organization, windowDimensions, videoDimensions, didLog, analyticsContext]); } function BasePlayerRoot({className, overlayContent, isPreview = false}: Props) { const { dimensions: videoDimensions, fastForwardSpeed, setRoot, isBuffering, isVideoBuffering, isFetching, isFinished, isVideoReplay, } = useReplayContext(); const windowEl = useRef(null); const viewEl = useRef(null); const [windowDimensions, setWindowDimensions] = useState({ width: 0, height: 0, }); useVideoSizeLogger({videoDimensions, windowDimensions}); // Sets the parent element where the player // instance will use as root (i.e. where it will // create an iframe) useEffect(() => { // XXX: This is smelly, but without the // dependence on `isFetching` here, will result // in ReplayContext creating a new Replayer // instance before events are hydrated. This // resulted in the `recording(Start/End)Frame` // as the only two events when we instanciated // Replayer and the rrweb Replayer requires all // events to be present when instanciated. if (!isFetching) { setRoot(viewEl.current); } return () => { setRoot(null); }; }, [setRoot, isFetching]); // Read the initial width & height where the player will be inserted, this is // so we can shrink the video into the available space. // If the size of the container changes, we can re-calculate the scaling factor const updateWindowDimensions = useCallback( () => setWindowDimensions({ width: windowEl.current?.clientWidth || 0, height: windowEl.current?.clientHeight || 0, }), [setWindowDimensions] ); useResizeObserver({ref: windowEl, onResize: updateWindowDimensions}); // If your browser doesn't have ResizeObserver then set the size once. useEffect(() => { if (typeof window.ResizeObserver !== 'undefined') { return; } updateWindowDimensions(); }, [updateWindowDimensions]); // Update the scale of the view whenever dimensions have changed. useEffect(() => { if (viewEl.current) { const scale = Math.min( windowDimensions.width / videoDimensions.width, windowDimensions.height / videoDimensions.height, 1 ); if (scale) { viewEl.current.style['transform-origin'] = 'top left'; viewEl.current.style.transform = `scale(${scale})`; viewEl.current.style.width = `${videoDimensions.width * scale}px`; viewEl.current.style.height = `${videoDimensions.height * scale}px`; } } }, [windowDimensions, videoDimensions]); return ( {isFinished && overlayContent && ( {overlayContent} )}
{fastForwardSpeed ? : null} {isBuffering || isVideoBuffering ? : null} {isPreview || isVideoReplay ? null : } {isFetching ? : null} ); } const PositionedFastForward = styled(FastForwardBadge)` position: absolute; left: 0; bottom: 0; `; const PositionedBuffering = styled(BufferingOverlay)` position: absolute; top: 0; left: 0; right: 0; bottom: 0; `; const PositionedLoadingIndicator = styled(LoadingIndicator)` position: absolute; `; // Base styles, to make the Replayer instance work const PlayerRoot = styled(BasePlayerRoot)` .replayer-wrapper { user-select: none; } .replayer-wrapper > .replayer-mouse { pointer-events: none; } .replayer-wrapper > .replayer-mouse-tail { position: absolute; pointer-events: none; } /* Override default user-agent styles */ .replayer-wrapper > iframe { border: none; background: white; /* Set pointer-events to make it easier to right-click & inspect */ pointer-events: initial !important; } `; // Sentry-specific styles for the player. // The elements we have to work with are: // ```css // div.replayer-wrapper {} // div.replayer-wrapper > div.replayer-mouse {} // div.replayer-wrapper > canvas.replayer-mouse-tail {} // div.replayer-wrapper > iframe {} // ``` // The mouse-tail is also configured for color/size in `app/components/replays/replayContext.tsx` const SentryPlayerRoot = styled(PlayerRoot)` .replayer-mouse { position: absolute; width: 32px; height: 32px; transition: left 0.05s linear, top 0.05s linear; background-size: contain; background-repeat: no-repeat; background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTkiIHZpZXdCb3g9IjAgMCAxMiAxOSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMTZWMEwxMS42IDExLjZINC44TDQuNCAxMS43TDAgMTZaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNOS4xIDE2LjdMNS41IDE4LjJMMC43OTk5OTkgNy4xTDQuNSA1LjZMOS4xIDE2LjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNC42NzQ1MSA4LjYxODUxTDIuODMwMzEgOS4zOTI3MUw1LjkyNzExIDE2Ljc2OTVMNy43NzEzMSAxNS45OTUzTDQuNjc0NTEgOC42MTg1MVoiIGZpbGw9ImJsYWNrIi8+CjxwYXRoIGQ9Ik0xIDIuNFYxMy42TDQgMTAuN0w0LjQgMTAuNkg5LjJMMSAyLjRaIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4K'); border-color: transparent; } .replayer-mouse:after { content: ''; display: inline-block; width: 32px; height: 32px; background: ${p => p.theme.purple300}; border-radius: 100%; transform: translate(-50%, -50%); opacity: 0.3; } .replayer-mouse.active:after { animation: click 0.2s ease-in-out 1; } .replayer-mouse.touch-device { background-image: none; width: 70px; height: 70px; border-radius: 100%; margin-left: -37px; margin-top: -37px; border: 4px solid rgba(73, 80, 246, 0); transition: left 0s linear, top 0s linear, border-color 0.2s ease-in-out; } .replayer-mouse.touch-device.touch-active { border-color: ${p => p.theme.purple200}; transition: left 0.25s linear, top 0.25s linear, border-color 0.2s ease-in-out; } .replayer-mouse.touch-device:after { opacity: 0; } .replayer-mouse.touch-device.active:after { animation: touch-click 0.2s ease-in-out 1; } @keyframes click { 0% { opacity: 0.3; width: 20px; height: 20px; } 50% { opacity: 0.5; width: 10px; height: 10px; } } @keyframes touch-click { 0% { opacity: 0; width: 20px; height: 20px; } 50% { opacity: 0.5; width: 10px; height: 10px; } } `; const Overlay = styled('div')` position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 1; `; const OverlayInnerWrapper = styled('div')` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; display: flex; justify-content: center; flex-direction: column; align-items: center; gap: 9px; `; const StyledNegativeSpaceContainer = styled(NegativeSpaceContainer)` position: relative; width: 100%; height: 100%; `; export default SentryPlayerRoot;