'use client' import { useEffect, useState, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, type Site } from '@/lib/api/sites' import { getReplay, getReplayData, deleteReplay, formatDuration, type SessionReplay } from '@/lib/api/replays' 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' // Fixed player dimensions (16:9 aspect ratio) const PLAYER_WIDTH = 880 const PLAYER_HEIGHT = 495 function formatDate(dateString: string) { const date = new Date(dateString) return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } function getFlagEmoji(countryCode: string | null) { if (!countryCode || countryCode.length !== 2) return '🌍' const codePoints = countryCode .toUpperCase() .split('') .map(char => 127397 + char.charCodeAt(0)) return String.fromCodePoint(...codePoints) } export default function ReplayViewerPage() { const params = useParams() const router = useRouter() const siteId = params.id as string const replayId = params.replayId as string const [site, setSite] = useState(null) const [replay, setReplay] = useState(null) const [replayData, setReplayData] = useState(null) const [loading, setLoading] = useState(true) 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) // Load site and replay info useEffect(() => { const init = async () => { try { const [siteData, replayInfo] = await Promise.all([ getSite(siteId), getReplay(siteId, replayId) ]) setSite(siteData) setReplay(replayInfo) } catch (error: unknown) { toast.error('Failed to load replay') } finally { setLoading(false) } } init() }, [siteId, replayId]) // Load replay data useEffect(() => { const loadData = async () => { if (!replay) return setLoadingData(true) try { const data = await getReplayData(siteId, replayId) setReplayData(data) } catch (error: unknown) { toast.error('Failed to load replay data') } finally { setLoadingData(false) } } loadData() }, [replay, siteId, replayId]) // Initialize rrweb player when data is ready (no built-in controller; we use ReplayPlayerControls) useEffect(() => { if (!replayData || !playerContainerRef.current || replayData.length === 0) return setPlayerReady(false) setCurrentTimeMs(0) setIsPlaying(false) const initPlayer = async () => { try { const rrwebPlayer = await import('rrweb-player') if (!playerContainerRef.current) return playerContainerRef.current.innerHTML = '' const player = new rrwebPlayer.default({ target: playerContainerRef.current, props: { events: replayData, width: PLAYER_WIDTH, height: PLAYER_HEIGHT, autoPlay: false, showController: false, skipInactive: true, showWarning: false, showDebug: false, }, }) 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) toast.error('Failed to initialize replay player') } } initPlayer() return () => { 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) toast.success('Replay deleted') router.push(`/sites/${siteId}/replays`) } catch (error: unknown) { toast.error('Failed to delete replay') } } if (loading) return if (!site || !replay) return
Replay not found
return (
{/* Header */}

Session Replay

{/* Player */}
{/* 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 */} {playerReady && (
{replay.events_count} events • {formatDuration(replay.duration_ms)}
{replay.is_skeleton_mode && ( Skeleton Mode )}
)}
{/* Session Info Sidebar */}

Session Details

Session ID

{replay.session_id}

Entry Page

{replay.entry_page}

Duration

{formatDuration(replay.duration_ms)}

Events

{replay.events_count}

Device

{replay.device_type || 'Unknown'}

Browser

{replay.browser || 'Unknown'}

OS

{replay.os || 'Unknown'}

Location

{getFlagEmoji(replay.country)} {replay.country || 'Unknown'}

Started

{formatDate(replay.started_at)}

{replay.ended_at && (
Ended

{formatDate(replay.ended_at)}

)}
{replay.is_skeleton_mode ? ( Skeleton Mode (Anonymous) ) : replay.consent_given ? ( âś“ Consent Given ) : ( âš  No Consent Record )}
{/* Delete Confirmation Modal */} {showDeleteModal && (

Delete Replay?

This action cannot be undone. The replay data will be permanently deleted.

)} {/* * rrweb player frame and cursor – Ciphera branding (controller is custom, not rrweb) */}
) }