"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 { disconnectAll, getButtplugClientHandle, getDevice, 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 setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const removeDevice = useButtplugStore((s) => s.removeDevice); const connectedDevices = useMemo(() => Object.values(devices), [devices]); const [data, setData] = useState(null); const [showRemap, setShowRemap] = useState(false); const [starting, setStarting] = useState(false); const [player, setPlayer] = useState(null); const [playing, setPlaying] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]); const [replayDeviceIndexes, setReplayDeviceIndexes] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); useEffect(() => { fetch(`/api/recordings/${recordingId}`) .then((r) => r.json()) .then(setData) .catch(() => toast.error("Could not load recording")); }, [recordingId]); useEffect(() => { return () => { void disconnectAll(); }; }, []); async function resetReplayDevices() { await Promise.all( replayDeviceIndexes.map(async (deviceIndex) => { const liveDevice = await getDevice(deviceIndex); await liveDevice?.stop().catch(() => {}); devices[deviceIndex]?.actuators.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0)); }), ); } async function handleConfirmRemap(mapping: Map) { if (!data) return; setShowRemap(false); setStarting(true); try { const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId)); const targets = orderedSlots.map((slot) => { const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const device = connectedDevices.find((d) => d.index === deviceIndex)!; return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name }; }); const body = { kind: "replay" as const, replayedRecordingId: data.recording.id, devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })), }; 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]), ); // Reset every actuator on every device that will take part in this replay before // the first scheduled event fires, so playback always starts from a known-zero // state rather than whatever intensity was left over from manual control. const deviceIndexes = new Set(sessionDeviceIdToDeviceIndex.values()); await Promise.all( [...deviceIndexes].map(async (deviceIndex) => { const liveDevice = await getDevice(deviceIndex); await liveDevice?.stop().catch(() => {}); actuatorsByDeviceIndex.get(deviceIndex)?.forEach((a) => setActuatorValue(deviceIndex, a.featureIndex, 0)); }), ); const { runtime } = await getButtplugClientHandle(); const instance = new RecordingPlayer({ events: data.events, durationMs: data.recording.durationMs, sessionDeviceIdToDeviceIndex, actuatorsByDeviceIndex, runtime, onProgress: (elapsed) => setElapsedMs(elapsed), onError: (message) => toast.error(`Replay command failed: ${message}`), 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" }), }).catch(() => {}); toast.success("Replay finished"); }, }); setReplayTargets(targets); setReplayDeviceIndexes([...deviceIndexes]); setActiveSessionId(result.session.id); setPlayer(instance); instance.play(); setPlaying(true); } catch { toast.error("Could not start replay"); } finally { setStarting(false); } } 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()} />
) : (
{replayTargets.length > 0 && (
{replayTargets.map((t) => ( {t.slotLabel} → {t.deviceName} ))}
)} player.seek(v)} />
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)}
)}
setShowRemap(false)} onConfirm={(mapping) => void handleConfirmRemap(mapping)} />
); }