hyperdx/packages/app/src/DOMPlayer.tsx
Ernest Iliiasov 6d99e3be9c
fix: Update session player (#395)
* Replace react-slider with mantine's slider + custom markers for better performance
* Fix bug when opening a session replay with a ?ts param shows the first frame instead of correct frame at `ts`
* abort loading events when navigating off the page

before

https://github.com/hyperdxio/hyperdx/assets/20255948/195ce791-2d31-4ae4-9700-0ff52f021171

after

https://github.com/hyperdxio/hyperdx/assets/20255948/8ec31ff4-c3c1-4c0d-9f04-29c123e9444f
2024-05-12 17:39:00 +00:00

435 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import throttle from 'lodash/throttle';
import { useHotkeys } from 'react-hotkeys-hook';
import { Replayer } from 'rrweb';
import { useSearchEventStream } from './search';
import { useDebugMode } from './utils';
function getPlayerCurrentTime(player: Replayer) {
return Math.max(player.getCurrentTime(), 0); //getCurrentTime can be -startTime
}
export default function DOMPlayer({
config: { sessionId, dateRange },
focus,
setPlayerTime,
playerState,
setPlayerState,
playerSpeed,
skipInactive,
setPlayerStartTimestamp,
setPlayerEndTimestamp,
resizeKey,
}: {
config: {
sessionId: string;
dateRange: [Date, Date];
};
focus: { ts: number; setBy: string } | undefined;
setPlayerTime: (ts: number) => void;
playerState: 'playing' | 'paused';
setPlayerState: (state: 'playing' | 'paused') => void;
playerSpeed: number;
setPlayerStartTimestamp?: (ts: number) => void;
setPlayerEndTimestamp?: (ts: number) => void;
skipInactive: boolean;
resizeKey?: string;
}) {
const debug = useDebugMode();
const wrapper = useRef<HTMLDivElement>(null);
const playerContainer = useRef<HTMLDivElement>(null);
const replayer = useRef<Replayer | null>(null);
const initialEvents = useRef<any[]>([]);
const lastEventTsLoadedRef = useRef(0);
const [lastEventTsLoaded, _setLastEventTsLoaded] = useState(0);
const setLastEventTsLoaded = useRef(
throttle(_setLastEventTsLoaded, 100, { leading: true, trailing: true }),
);
const [isInitialEventsLoaded, setIsInitialEventsLoaded] = useState(false);
const [isReplayFullyLoaded, setIsReplayFullyLoaded] = useState(false);
let currentRrwebEvent = '';
const { isFetching: isSearchResultsFetching, abort } = useSearchEventStream(
{
apiUrlPath: `/sessions/${sessionId}/rrweb`,
q: '',
startDate: dateRange?.[0] ?? new Date(),
endDate: dateRange?.[1] ?? new Date(),
extraFields: [],
order: 'asc', // hardcoded at the api side. doesn't matter here
limit: 1000000, // large enough to get all events
onEvent: (event: { b: string; ck: number; tcks: number; t: number }) => {
try {
const { b: body, ck: chunk, tcks: totalChunks, t: type } = event;
currentRrwebEvent += body;
if (!chunk || chunk === totalChunks) {
const parsedEvent = JSON.parse(currentRrwebEvent);
if (replayer.current != null) {
replayer.current.addEvent(parsedEvent);
} else {
if (
setPlayerStartTimestamp != null &&
initialEvents.current.length === 0
) {
setPlayerStartTimestamp(parsedEvent.timestamp);
}
initialEvents.current.push(parsedEvent);
}
setLastEventTsLoaded.current(parsedEvent.timestamp);
// Used for setting the player end timestamp on onEnd
// we can't use state since the onEnd function is declared
// at the beginning of the component lifecylce.
// We can't use the rrweb metadata as it's not updated fast enough
lastEventTsLoadedRef.current = parsedEvent.timestamp;
currentRrwebEvent = '';
}
} catch (e) {
if (debug) {
console.error(e);
}
currentRrwebEvent = '';
}
if (initialEvents.current.length > 5) {
setIsInitialEventsLoaded(true);
}
},
onEnd: () => {
setIsInitialEventsLoaded(true);
setIsReplayFullyLoaded(true);
if (setPlayerEndTimestamp != null) {
if (replayer.current != null) {
const endTime = lastEventTsLoadedRef.current;
// Might want to merge with the below logic at some point, since
// it's using a ts ref now
setPlayerEndTimestamp(endTime ?? 0);
} else {
// If there's no events (empty replay session), there's no point in setting a timestamp
if (initialEvents.current.length > 0) {
setPlayerEndTimestamp(
initialEvents.current[initialEvents.current.length - 1]
.timestamp ?? 0,
);
}
}
}
},
},
{
enabled: dateRange != null,
keepPreviousData: true, // TODO: support streaming
shouldAbortPendingRequest: true,
},
);
// RRWeb Player Stuff ==============================
const [lastHref, setLastHref] = useState('');
const play = useCallback(() => {
if (replayer.current != null) {
try {
replayer.current.play(getPlayerCurrentTime(replayer.current));
} catch (e) {
console.error(e);
}
}
}, [replayer]);
const pause = useCallback(
(ts?: number) => {
if (replayer.current != null) {
try {
replayer.current.pause(ts);
} catch (e) {
console.error(e);
}
}
},
[replayer],
);
useHotkeys(['space'], () => {
if (playerState === 'playing') {
setPlayerState('paused');
} else if (playerState === 'paused') {
setPlayerState('playing');
}
});
// XXX: Hack to let requestAnimationFrame access the current setPlayerTime
const setPlayerTimeRef = useRef(setPlayerTime);
useEffect(() => {
setPlayerTimeRef.current = setPlayerTime;
}, [setPlayerTime]);
const updatePlayerTimeRafRef = useRef(0);
const updatePlayerTime = () => {
if (
replayer.current != null &&
replayer.current.service.state.matches('playing')
) {
setPlayerTimeRef.current(
Math.round(
replayer.current.getMetaData().startTime +
getPlayerCurrentTime(replayer.current),
),
);
}
updatePlayerTimeRafRef.current = requestAnimationFrame(updatePlayerTime);
};
// Update timestamp ui in timeline
useEffect(() => {
updatePlayerTimeRafRef.current = requestAnimationFrame(updatePlayerTime);
return () => {
cancelAnimationFrame(updatePlayerTimeRafRef.current);
};
}, []);
// Manage playback pause/play state, rrweb only
useEffect(() => {
if (replayer.current != null) {
if (playerState === 'playing') {
play();
} else if (playerState === 'paused') {
pause();
}
}
}, [playerState, play, pause]);
useEffect(() => {
if (replayer.current != null) {
if (playerState === 'playing') {
pause();
replayer.current?.setConfig({ speed: playerSpeed, skipInactive });
play();
} else if (playerState === 'paused') {
replayer.current?.setConfig({ speed: playerSpeed, skipInactive });
}
}
}, [playerState, playerSpeed, skipInactive, pause, play]);
const handleResize = useCallback(() => {
if (wrapper.current == null || playerContainer.current == null) {
return;
}
const wrapperRect = wrapper.current.getBoundingClientRect();
const playerWidth = replayer?.current?.iframe?.offsetWidth ?? 1280;
const playerHeight = replayer?.current?.iframe?.offsetHeight ?? 720;
const xScale = wrapperRect.width / playerWidth;
const yScale = wrapperRect.height / playerHeight;
playerContainer.current.style.transform = `scale(${Math.min(
xScale,
yScale,
)})`;
}, [wrapper, playerContainer]);
// Listen to window resizes to resize player
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
// Resize when something external changes our player size
useEffect(() => {
handleResize();
}, [resizeKey, handleResize]);
const [isReplayerInitialized, setIsReplayerInitialized] = useState(false);
// Set up player
useEffect(() => {
if (
// If we have no events yet, we can't mount yet.
initialEvents.current.length < 2 ||
// Just skip if we're already enabled
playerContainer.current == null ||
replayer.current != null
) {
return;
}
replayer.current = new Replayer(initialEvents.current, {
root: playerContainer.current,
mouseTail: false,
pauseAnimation: false,
showWarning: debug,
skipInactive: true,
});
setIsReplayerInitialized(true);
if (debug) {
// @ts-ignore
window.__hdx_replayer = replayer.current;
}
replayer.current.enableInteract();
replayer.current.on('event-cast', (e: any) => {
try {
// if this is an incremental update from a resize
// OR if its a full snapshot `type=4`, we'll want to resize just in case
// https://github.com/rrweb-io/rrweb/blob/07aa1b2807da5a9a1db678ebc3ff59320a300d06/packages/rrweb/src/record/index.ts#L447
// https://github.com/rrweb-io/rrweb/blob/2a809499480ae4f7118432f09871c5f75fda06d7/packages/types/src/index.ts#L74
if ((e?.type === 3 && e?.data?.source === 4) || e.type === 4) {
setTimeout(() => {
handleResize();
}, 0);
}
if (e?.type === 4) {
setLastHref(e.data.href);
}
} catch (e) {
if (debug) {
console.error(e);
}
}
});
// If we're supposed to be playing, let's start playing.
if (
playerState === 'playing' &&
replayer.current.getMetaData().endTime > (focus?.ts ?? 0)
) {
if (focus != null) {
pause(focus.ts - replayer.current.getMetaData().startTime);
}
play();
}
// XXX: Yes this is a hugeee antipattern
setTimeout(() => {
handleResize();
}, 0);
}, [
handleResize,
focus,
pause,
isInitialEventsLoaded,
playerState,
play,
debug,
]);
// Set player to the correct time based on focus
useEffect(() => {
if (
!isInitialEventsLoaded ||
!isReplayerInitialized ||
lastEventTsLoaded < (focus?.ts ? focus.ts + 1000 : Infinity)
) {
return;
}
if (focus?.setBy !== 'player' && replayer.current != null) {
pause(
focus?.ts == null
? 0
: focus?.ts - replayer.current.getMetaData().startTime,
);
handleResize();
if (playerState === 'playing') {
play();
}
}
}, [
focus,
pause,
setPlayerState,
playerState,
play,
isInitialEventsLoaded,
isReplayerInitialized,
handleResize,
lastEventTsLoaded,
]);
useEffect(() => {
return () => {
if (replayer.current != null) {
replayer.current?.destroy();
replayer.current = null;
}
abort();
};
}, []);
const isLoading = isInitialEventsLoaded === false && isSearchResultsFetching;
// TODO: Handle when ts is set to a value that's outside of this session
const isBuffering =
isReplayFullyLoaded === false &&
(replayer.current?.getMetaData()?.endTime ?? 0) < (focus?.ts ?? 0);
useEffect(() => {
// If we're trying to play, but the player is paused
// try to play again if we've loaded the event we're trying to play
// this is relevant when you click or load on a timestamp that hasn't loaded yet
if (
replayer.current != null &&
focus != null &&
replayer.current.getMetaData().endTime > focus.ts &&
playerState === 'playing' &&
replayer.current?.service?.state?.matches('paused')
) {
pause(focus.ts - replayer.current.getMetaData().startTime);
play();
}
}, [lastEventTsLoaded, focus, playerState, pause, play]);
return (
<>
{lastHref != '' && (
<div className="bg-dark rounded p-2 mb-2">{lastHref}</div>
)}
{(isLoading || isBuffering) && (
<div
className="d-flex align-items-center justify-content-center"
style={{ minHeight: 300 }}
>
<div className="text-center">
<div className="spinner-border" role="status" />
<div className="mt-2">
{isBuffering ? 'Buffering to time...' : 'Loading replay...'}
</div>
</div>
</div>
)}
{isReplayFullyLoaded && replayer.current == null && (
<div className="d-flex align-items-center justify-content-center bg-hdx-dark p-4 text-center text-muted">
No replay available for this session, most likely due to this session
starting and ending in a background tab.
</div>
)}
<div
ref={wrapper}
className={cx('player-wrapper overflow-hidden', {
'd-none': isLoading || isBuffering,
started: (replayer.current?.getCurrentTime() ?? 0) > 0,
})}
style={{
marginBottom: 80, // XXX (mikeshi): This is a CSS hack as the side panel parent
// height is 100%, which is incorrect and will be higher than the actual height available
// however fixing the side panel to use flex will likely take longer to fix
}}
>
<div
className="player rr-block"
ref={playerContainer}
style={{
transformOrigin: '0 0',
}}
/>
</div>
</>
);
}