From a649c850ca3a3a152edd42cc747bd474894c557f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 19 Jan 2026 14:20:25 +0100 Subject: [PATCH] feat: integrate custom ReplayPlayerControls for enhanced playback functionality, including play/pause, seek, speed adjustment, and fullscreen support --- app/sites/[id]/replays/[replayId]/page.tsx | 283 +++++++++------------ components/ReplayPlayerControls.tsx | 192 ++++++++++++++ 2 files changed, 310 insertions(+), 165 deletions(-) create mode 100644 components/ReplayPlayerControls.tsx diff --git a/app/sites/[id]/replays/[replayId]/page.tsx b/app/sites/[id]/replays/[replayId]/page.tsx index 032f485..2827e7e 100644 --- a/app/sites/[id]/replays/[replayId]/page.tsx +++ b/app/sites/[id]/replays/[replayId]/page.tsx @@ -7,6 +7,7 @@ import { getReplay, getReplayData, deleteReplay, formatDuration, type SessionRep import { toast } from 'sonner' import { LockClosedIcon } from '@radix-ui/react-icons' import LoadingOverlay from '@/components/LoadingOverlay' +import ReplayPlayerControls from '@/components/ReplayPlayerControls' import type { eventWithTime } from '@rrweb/types' import 'rrweb-player/dist/style.css' @@ -48,7 +49,13 @@ export default function ReplayViewerPage() { const [loadingData, setLoadingData] = useState(false) const [playerReady, setPlayerReady] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [skipInactive, setSkipInactive] = useState(true) + const [speed, setSpeed] = useState(1) + const [currentTimeMs, setCurrentTimeMs] = useState(0) + const [totalTimeMs, setTotalTimeMs] = useState(0) + const playerWrapperRef = useRef(null) const playerContainerRef = useRef(null) const playerRef = useRef(null) @@ -88,29 +95,29 @@ export default function ReplayViewerPage() { loadData() }, [replay, siteId, replayId]) - // Initialize rrweb player when data is ready + // Initialize rrweb player when data is ready (no built-in controller; we use ReplayPlayerControls) useEffect(() => { if (!replayData || !playerContainerRef.current || replayData.length === 0) return - // Dynamically import rrweb-player + setPlayerReady(false) + setCurrentTimeMs(0) + setIsPlaying(false) + const initPlayer = async () => { try { const rrwebPlayer = await import('rrweb-player') - // Clear previous player - if (playerContainerRef.current) { - playerContainerRef.current.innerHTML = '' - } + if (!playerContainerRef.current) return + playerContainerRef.current.innerHTML = '' - // Create player with fixed dimensions const player = new rrwebPlayer.default({ - target: playerContainerRef.current!, + target: playerContainerRef.current, props: { events: replayData, width: PLAYER_WIDTH, height: PLAYER_HEIGHT, autoPlay: false, - showController: true, + showController: false, skipInactive: true, showWarning: false, showDebug: false, @@ -118,6 +125,12 @@ export default function ReplayViewerPage() { }) playerRef.current = player + try { + const meta = (player as { getMetaData?: () => { totalTime: number } }).getMetaData?.() + if (meta && typeof meta.totalTime === 'number') setTotalTimeMs(meta.totalTime) + } catch { + // ignore + } setPlayerReady(true) } catch (error) { console.error('Failed to initialize player:', error) @@ -128,12 +141,44 @@ export default function ReplayViewerPage() { initPlayer() return () => { - if (playerRef.current) { - playerRef.current = null - } + playerRef.current = null } }, [replayData]) + // Poll current time and detect end of replay + useEffect(() => { + if (!playerReady) return + const interval = setInterval(() => { + const p = playerRef.current as + | { getReplayer?: () => { getCurrentTime?: () => number }; getMetaData?: () => { totalTime: number }; pause?: () => void } + | null + if (!p?.getReplayer) return + try { + const t = p.getReplayer?.()?.getCurrentTime?.() + if (typeof t === 'number') setCurrentTimeMs(t) + const meta = p.getMetaData?.() + if (meta && typeof meta.totalTime === 'number' && typeof t === 'number' && t >= meta.totalTime) { + p.pause?.() + setIsPlaying(false) + } + } catch { + // ignore + } + }, 200) + return () => clearInterval(interval) + }, [playerReady]) + + // Trigger rrweb replayer resize when entering fullscreen so it can scale + useEffect(() => { + const onFullscreenChange = () => { + if (document.fullscreenElement === playerWrapperRef.current) { + setTimeout(() => (playerRef.current as { triggerResize?: () => void })?.triggerResize?.(), 50) + } + } + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => document.removeEventListener('fullscreenchange', onFullscreenChange) + }, []) + const handleDelete = async () => { try { await deleteReplay(siteId, replayId) @@ -177,25 +222,60 @@ export default function ReplayViewerPage() {
{/* Player */}
-
- {/* Player Container - Fixed size */} -
- {loadingData ? ( -
-
- Loading replay data... -
- ) : !replayData || replayData.length === 0 ? ( -
-
🎬
-

No replay data available

-

This session may not have recorded any events.

-
- ) : null} +
+
+ {/* Player container (rrweb-player mounts here when replayData is ready) */} +
+ {loadingData ? ( +
+
+ Loading replay data... +
+ ) : !replayData || replayData.length === 0 ? ( +
+
🎬
+

No replay data available

+

This session may not have recorded any events.

+
+ ) : null} +
+ + {/* Custom controls (Ciphera‑branded) */} + {playerReady && ( + { + ;(playerRef.current as { toggle?: () => void })?.toggle?.() + setIsPlaying((p) => !p) + }} + currentTimeMs={currentTimeMs} + totalTimeMs={totalTimeMs} + onSeek={(f) => { + ;(playerRef.current as { goto?: (ms: number, play?: boolean) => void })?.goto?.(f * totalTimeMs, isPlaying) + }} + speed={speed} + onSpeedChange={(s) => { + ;(playerRef.current as { setSpeed?: (n: number) => void })?.setSpeed?.(s) + setSpeed(s) + }} + skipInactive={skipInactive} + onSkipInactiveChange={() => { + ;(playerRef.current as { toggleSkipInactive?: () => void })?.toggleSkipInactive?.() + setSkipInactive((p) => !p) + }} + onFullscreenRequest={() => { + if (document.fullscreenElement) { + document.exitFullscreen() + } else { + playerWrapperRef.current?.requestFullscreen?.() + } + }} + /> + )}
{/* Session info bar */} @@ -330,155 +410,28 @@ export default function ReplayViewerPage() {
)} - {/* Custom styles for rrweb player - Brand compliant */} + {/* * rrweb player frame and cursor – Ciphera branding (controller is custom, not rrweb) */}
diff --git a/components/ReplayPlayerControls.tsx b/components/ReplayPlayerControls.tsx new file mode 100644 index 0000000..a110f35 --- /dev/null +++ b/components/ReplayPlayerControls.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + PlayIcon, + PauseIcon, + EnterFullScreenIcon, + ExitFullScreenIcon, +} from '@radix-ui/react-icons' + +/** Formats milliseconds as mm:ss. */ +function formatTime(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '0:00' + const s = Math.floor(ms / 1000) + const m = Math.floor(s / 60) + return `${m}:${String(s % 60).padStart(2, '0')}` +} + +const SPEED_OPTIONS = [1, 2, 4, 8] as const + +export type ReplayPlayerControlsProps = { + isPlaying: boolean + onPlayPause: () => void + currentTimeMs: number + totalTimeMs: number + onSeek: (fraction: number) => void + speed: number + onSpeedChange: (speed: number) => void + skipInactive: boolean + onSkipInactiveChange: () => void + onFullscreenRequest: () => void +} + +/** + * Custom session replay player controls with Ciphera branding. + * Matches design: brand orange #FD5E0F, Plus Jakarta Sans, rounded-xl, neutral greys. + */ +export default function ReplayPlayerControls({ + isPlaying, + onPlayPause, + currentTimeMs, + totalTimeMs, + onSeek, + speed, + onSpeedChange, + skipInactive, + onSkipInactiveChange, + onFullscreenRequest, +}: ReplayPlayerControlsProps) { + const [isFullscreen, setIsFullscreen] = useState(false) + const [isSeeking, setIsSeeking] = useState(false) + const [seekValue, setSeekValue] = useState(0) + + const totalSec = totalTimeMs / 1000 + const currentSec = currentTimeMs / 1000 + const fraction = totalSec > 0 ? Math.min(1, Math.max(0, currentSec / totalSec)) : 0 + const displayFraction = isSeeking ? seekValue : fraction + + useEffect(() => { + const onFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement) + } + document.addEventListener('fullscreenchange', onFullscreenChange) + return () => document.removeEventListener('fullscreenchange', onFullscreenChange) + }, []) + + const handleSeekChange = (e: React.ChangeEvent) => { + const v = parseFloat(e.target.value) + const p = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0 + setSeekValue(p) + onSeek(p) + } + const handleSeekPointerDown = () => { + setSeekValue(fraction) + setIsSeeking(true) + } + const handleSeekPointerUp = () => setIsSeeking(false) + + return ( +
+ {/* * Progress bar / timeline */} +
+ + {formatTime(currentTimeMs)} + +
+
+ {displayFraction > 0 && displayFraction < 1 && ( +
+ )} +
+ +
+ + {formatTime(totalTimeMs)} + +
+ + {/* * Buttons row */} +
+
+ {/* * Play / Pause */} + + + {/* * Speed pills */} +
+ {SPEED_OPTIONS.map((s) => ( + + ))} +
+ + {/* * Skip inactive toggle */} + +
+ + {/* * Fullscreen */} + +
+
+ ) +}