diff --git a/web/packages/teleport/src/SessionRecordings/view/player/PlayerControls.tsx b/web/packages/teleport/src/SessionRecordings/view/player/PlayerControls.tsx index 3dd2b79995e14..1d02360b654dd 100644 --- a/web/packages/teleport/src/SessionRecordings/view/player/PlayerControls.tsx +++ b/web/packages/teleport/src/SessionRecordings/view/player/PlayerControls.tsx @@ -41,6 +41,8 @@ import { HoverTooltip } from 'design/Tooltip'; import { PlayerState } from 'teleport/SessionRecordings/view/stream/SessionStream'; +import { PlayerSpeed } from './PlayerSpeed'; + export interface PlayerControlsHandle { setTime: (time: number) => void; } @@ -50,6 +52,8 @@ interface PlayerControlsProps { onPlay: () => void; onPause: () => void; onSeek: (time: number) => void; + speed: number; + onSpeedChange: (speed: number) => void; state: PlayerState; ref: RefObject; onToggleFullscreen?: () => void; @@ -65,6 +69,8 @@ export function PlayerControls({ onPlay, onPause, onSeek, + speed, + onSpeedChange, fullscreen, onToggleFullscreen, onToggleTimeline, @@ -216,6 +222,12 @@ export function PlayerControls({ + + {onToggleTimeline && ( . + */ + +import { useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import Flex from 'design/Flex'; +import { Check } from 'design/Icon'; +import Menu, { MenuItem } from 'design/Menu'; +import { HoverTooltip } from 'design/Tooltip'; + +interface PlayerSpeedProps { + speed: number; + portalRoot: HTMLElement; + onSpeedChange: (speed: number) => void; +} + +const SpeedButton = styled.button` + background: transparent; + border: none; + color: ${p => p.theme.colors.text.main}; + cursor: pointer; + height: 32px; + font-size: ${p => p.theme.fontSizes[2]}px; + padding: ${p => p.theme.space[1]}px + ${p => p.theme.space[2] + p.theme.space[1]}px; + line-height: 1; + border-radius: ${p => p.theme.radii[3]}px; + + &:hover { + background: ${p => p.theme.colors.spotBackground[1]}; + } +`; + +const StyledMenu = styled(Menu)` + width: 150px; +`; + +const StyledMenuItem = styled(MenuItem)` + font-size: ${p => p.theme.fontSizes[2]}px; + display: flex; + align-items: center; +`; + +const AVAILABLE_SPEEDS = [0.25, 0.5, 1, 1.5, 2, 3, 4]; + +export function PlayerSpeed({ + onSpeedChange, + portalRoot, + speed, +}: PlayerSpeedProps) { + const [isOpen, setIsOpen] = useState(false); + + const ref = useRef(null); + + const items = useMemo(() => { + return AVAILABLE_SPEEDS.map(s => ( + { + onSpeedChange(s); + }} + > + + {s === speed && } + + {s}x + + )); + }, [onSpeedChange, speed]); + + return ( + <> + + { + setIsOpen(true); + }} + ref={ref} + > + {speed}x + + + + setIsOpen(false)} + // hack to properly position the menu + getContentAnchorEl={null} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + menuListProps={{ onClick: () => setIsOpen(false) }} + > + {items} + + + ); +} diff --git a/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx b/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx index c1f9d61f7361b..a637f819ada67 100644 --- a/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx +++ b/web/packages/teleport/src/SessionRecordings/view/player/RecordingPlayer.tsx @@ -85,6 +85,7 @@ export function RecordingPlayer< ws, }: RecordingPlayerProps) { const [playerState, setPlayerState] = useState(PlayerState.Loading); + const [speed, setSpeed] = useState(1); const [showPlayButton, setShowPlayButton] = useState(true); @@ -119,6 +120,10 @@ export function RecordingPlayer< }; }, [stream, onTimeChange]); + useEffect(() => { + stream.setSpeed(speed); + }, [speed, stream]); + useEffect(() => { if (!playerRef.current) { return; @@ -201,6 +206,8 @@ export function RecordingPlayer< onPlay={handlePlay} onPause={handlePause} onSeek={handleSeek} + onSpeedChange={setSpeed} + speed={speed} state={playerState} ref={controlsRef} /> diff --git a/web/packages/teleport/src/SessionRecordings/view/stream/SessionStream.ts b/web/packages/teleport/src/SessionRecordings/view/stream/SessionStream.ts index 44c1161218aae..5eb85556d3ac1 100644 --- a/web/packages/teleport/src/SessionRecordings/view/stream/SessionStream.ts +++ b/web/packages/teleport/src/SessionRecordings/view/stream/SessionStream.ts @@ -47,8 +47,8 @@ interface Range { start: number; } -const LOAD_THRESHOLD_MS = 5 * 1000; // 5 seconds -const LOAD_CHUNK_MS = 20 * 1000; // 20 seconds +const BASE_LOAD_THRESHOLD_MS = 5 * 1000; // 5 seconds +const BASE_LOAD_CHUNK_MS = 20 * 1000; // 20 seconds /** * SessionStream manages the loading and buffering of events from a WebSocket connection. @@ -85,6 +85,7 @@ export class SessionStream< private requestId = 0; private state: PlayerState = PlayerState.Loading; private wasPlayingBeforeSeek = false; + private playbackSpeed = 1; constructor( private ws: WebSocket, @@ -111,7 +112,7 @@ export class SessionStream< } loadInitial() { - this.load(0, LOAD_CHUNK_MS, false); + this.load(0, this.getLoadChunkMs(), false); } play() { @@ -126,7 +127,7 @@ export class SessionStream< this.setState(PlayerState.Playing); if (this.pausedTime > 0) { - this.startTime = performance.now() - this.pausedTime; + this.startTime = performance.now() - this.pausedTime / this.playbackSpeed; this.pausedTime = 0; } else if (!this.startTime) { this.startTime = performance.now(); @@ -165,7 +166,7 @@ export class SessionStream< this.animationFrameId = null; } - this.startTime = performance.now() - time; + this.startTime = performance.now() - time / this.playbackSpeed; this.currentTime = time; if ( @@ -177,7 +178,7 @@ export class SessionStream< this.setState(PlayerState.Loading); - this.load(time, time + LOAD_CHUNK_MS, true); + this.load(time, time + this.getLoadChunkMs(), true); return; } @@ -191,7 +192,7 @@ export class SessionStream< this.player.clear(); } - this.startTime = performance.now() - time; + this.startTime = performance.now() - time / this.playbackSpeed; // play all events up until the requested time this.playEventsUpUntil(time); @@ -217,19 +218,52 @@ export class SessionStream< this.animationFrameId = null; } - this.pausedTime = performance.now() - this.startTime; + this.pausedTime = (performance.now() - this.startTime) * this.playbackSpeed; this.currentTime = this.pausedTime; this.wasPlayingBeforeSeek = false; this.player.onPause(); } + setSpeed(speed: number) { + if (speed <= 0) { + return; + } + + const wasPlaying = this.state === PlayerState.Playing; + + if (wasPlaying) { + this.pause(); + } + + this.playbackSpeed = speed; + + if (wasPlaying) { + this.play(); + } + } + + private getLoadThresholdMs(): number { + // For higher speeds, we need more buffer time to avoid stuttering + // At 2x speed, we want at least 10 seconds of buffer + // At 4x speed, we want at least 20 seconds of buffer + return BASE_LOAD_THRESHOLD_MS * Math.max(1, this.playbackSpeed); + } + + private getLoadChunkMs(): number { + // Load bigger chunks at higher speeds + // At 2x speed, load 40 seconds + // At 4x speed, load 80 seconds + return BASE_LOAD_CHUNK_MS * Math.max(1, this.playbackSpeed); + } + private playEvents = () => { if (this.state !== PlayerState.Playing) { return; } - const currentTime = performance.now() - this.startTime; + const currentTime = + (performance.now() - this.startTime) * this.playbackSpeed; this.currentTime = currentTime; @@ -280,9 +314,10 @@ export class SessionStream< return; } + const loadThreshold = this.getLoadThresholdMs(); const isWithinLoadedRange = currentTime >= this.loadedRange.start && - currentTime + LOAD_THRESHOLD_MS <= this.loadedRange.end; + currentTime + loadThreshold <= this.loadedRange.end; if (isWithinLoadedRange) { return; @@ -295,13 +330,9 @@ export class SessionStream< const remainingBufferTime = lastEventTime - currentTime; - if ( - !this.atEnd && - !this.loading && - remainingBufferTime < LOAD_THRESHOLD_MS - ) { + if (!this.atEnd && !this.loading && remainingBufferTime < loadThreshold) { const newStartTime = this.loadedRange.end; - const newEndTime = newStartTime + LOAD_CHUNK_MS; + const newEndTime = newStartTime + this.getLoadChunkMs(); this.load(newStartTime, newEndTime); }