"use client"; import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { DeviceScanPanel } from "@/components/control/DeviceScanPanel"; import { DeviceRemapDialog } from "./DeviceRemapDialog"; import { getButtplugClientHandle, startScanning, stopScanning } from "@/lib/buttplug/client"; import { eventBuffer } from "@/lib/buttplug/event-buffer"; import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player"; import { useButtplugStore } from "@/lib/buttplug/store"; import type { ActuatorInfo } from "@/lib/buttplug/types"; import type { RecordingDeviceSlot } from "@/lib/db/schema"; import { Pause, Play } from "lucide-react"; interface RecordingResponse { recording: { id: number; name: string; durationMs: number; deviceSlots: RecordingDeviceSlot[]; }; events: RecordingEventRow[]; } function formatTime(ms: number): string { const totalSeconds = Math.floor(ms / 1000); return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`; } export function ReplayPlayer({ recordingId }: { recordingId: number }) { const router = useRouter(); const scanning = useButtplugStore((s) => s.scanning); const devices = useButtplugStore((s) => s.devices); const connectedDevices = useMemo(() => Object.values(devices), [devices]); const [data, setData] = useState(null); const [showRemap, setShowRemap] = useState(false); const [player, setPlayer] = useState(null); const [playing, setPlaying] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); useEffect(() => { fetch(`/api/recordings/${recordingId}`) .then((r) => r.json()) .then(setData) .catch(() => toast.error("Could not load recording")); }, [recordingId]); async function handleConfirmRemap(mapping: Map) { if (!data) return; setShowRemap(false); const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId)); const body = { kind: "replay" as const, replayedRecordingId: data.recording.id, devices: orderedSlots.map((slot) => { const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const device = connectedDevices.find((d) => d.index === deviceIndex)!; return { slotLabel: slot.slotLabel, bleName: device.name }; }), }; const res = await fetch("/api/play-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { toast.error("Could not start replay session"); return; } const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } = await res.json(); eventBuffer.start(result.session.id, result.session.startedAt); const sessionDeviceIdToDeviceIndex = new Map(); orderedSlots.forEach((slot, i) => { const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const newSessionDeviceId = result.sessionDevices[i]?.id; if (newSessionDeviceId !== undefined) { eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId); sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex); } }); const actuatorsByDeviceIndex = new Map( connectedDevices.map((d) => [d.index, d.actuators]), ); const { runtime } = await getButtplugClientHandle(); const instance = new RecordingPlayer({ events: data.events, sessionDeviceIdToDeviceIndex, actuatorsByDeviceIndex, runtime, onProgress: (elapsed) => setElapsedMs(elapsed), onComplete: async () => { setPlaying(false); eventBuffer.stop(); await fetch(`/api/play-sessions/${result.session.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "completed" }), }); toast.success("Replay finished"); }, }); setPlayer(instance); instance.play(); setPlaying(true); } if (!data) { return

Loading recording...

; } return (
{data.recording.name} {!player ? ( <>

Connect the devices you want to replay onto, then match them to the recording's original device slots.

void startScanning()} onStopScan={() => void stopScanning()} />
) : (
player.seek(v)} />
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)}
)}
setShowRemap(false)} onConfirm={(mapping) => void handleConfirmRemap(mapping)} />
); }