"use client"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { disconnectAll, getBatteryLevel, getButtplugClientHandle, getDevice, isWebBluetoothSupported, startScanning, stopScanning, } from "@/lib/buttplug/client"; import { buildOutputCommand, findFeature } from "@/lib/buttplug/commands"; import type { ButtplugRuntime } from "@/lib/buttplug/commands"; import { throttledSend } from "@/lib/buttplug/throttle"; import { eventBuffer } from "@/lib/buttplug/event-buffer"; import { actuatorKey, useButtplugStore } from "@/lib/buttplug/store"; import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; import { DeviceScanPanel } from "./DeviceScanPanel"; import { DeviceCard } from "./DeviceCard"; import { RecordControls } from "./RecordControls"; import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog"; import { Card, CardContent } from "@/components/ui/card"; interface ActivePlaySession { id: number; startedAt: number; sessionDeviceIdByDeviceIndex: Map; } function formatElapsed(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } export function ButtplugConsole() { const supported = isWebBluetoothSupported(); const connected = useButtplugStore((s) => s.connected); const scanning = useButtplugStore((s) => s.scanning); const devices = useButtplugStore((s) => s.devices); const actuatorValues = useButtplugStore((s) => s.actuatorValues); const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const batteryLevels = useButtplugStore((s) => s.batteryLevels); const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel); const removeDevice = useButtplugStore((s) => s.removeDevice); const setRecording = useButtplugStore((s) => s.setRecording); const storeError = useButtplugStore((s) => s.error); const [activeSession, setActiveSession] = useState(null); const [elapsedMs, setElapsedMs] = useState(0); const [sessionBusy, setSessionBusy] = useState(false); const [savePromptSessionId, setSavePromptSessionId] = useState(null); const [transmittingKey, setTransmittingKey] = useState(null); const runtimeRef = useRef(null); useEffect(() => { if (!activeSession) return; const interval = setInterval(() => setElapsedMs(Date.now() - activeSession.startedAt), 250); return () => clearInterval(interval); }, [activeSession]); useEffect(() => { return () => { void disconnectAll(); }; }, []); useEffect(() => { const devicesWithBattery = Object.values(devices).filter((d) => d.hasBattery); if (devicesWithBattery.length === 0) return; function refresh() { for (const device of devicesWithBattery) { void getBatteryLevel(device.index).then((level) => setBatteryLevel(device.index, level)); } } refresh(); const interval = setInterval(refresh, 60_000); return () => clearInterval(interval); }, [devices, setBatteryLevel]); async function ensureRuntime(): Promise { if (runtimeRef.current) return runtimeRef.current; const { runtime } = await getButtplugClientHandle(); runtimeRef.current = runtime; return runtime; } async function handleScan() { try { await ensureRuntime(); await startScanning(); } catch { toast.error("Could not start scanning - check Bluetooth is on and permitted."); } } async function handleStartSession() { setSessionBusy(true); try { const deviceList = Object.values(devices); const body = { kind: "live" as const, devices: deviceList.map((d) => ({ slotLabel: d.displayName ?? d.name, bleName: d.name, capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length }, })), }; const res = await fetch("/api/play-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error("failed to start session"); const data: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } = await res.json(); const sessionDeviceIdByDeviceIndex = new Map(); deviceList.forEach((d, i) => { const row = data.sessionDevices[i]; if (row) sessionDeviceIdByDeviceIndex.set(d.index, row.id); }); eventBuffer.start(data.session.id, data.session.startedAt); sessionDeviceIdByDeviceIndex.forEach((sessionDeviceId, deviceIndex) => { eventBuffer.registerSessionDevice(deviceIndex, sessionDeviceId); }); setActiveSession({ id: data.session.id, startedAt: data.session.startedAt, sessionDeviceIdByDeviceIndex, }); setElapsedMs(0); setRecording(true); } catch { toast.error("Could not start session."); } finally { setSessionBusy(false); } } async function handleEndSession() { if (!activeSession) return; setSessionBusy(true); eventBuffer.stop(); setRecording(false); await fetch(`/api/play-sessions/${activeSession.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "completed" }), }); setSavePromptSessionId(activeSession.id); setActiveSession(null); setSessionBusy(false); } async function handleActuatorChange(device: ConnectedDeviceInfo, featureIndex: number, value: number) { const key = actuatorKey(device.index, featureIndex); setActuatorValue(device.index, featureIndex, value); const actuator = device.actuators.find((a) => a.featureIndex === featureIndex); if (!actuator) return; throttledSend( key, async (v) => { try { const runtime = await ensureRuntime(); const liveDevice = await getDevice(device.index); const feature = liveDevice && findFeature(liveDevice, featureIndex); if (!liveDevice || !feature) { toast.error(`${device.name}: device not found - try reconnecting.`); return; } setTransmittingKey(key); const cmd = buildOutputCommand(runtime, actuator, v); await feature.runOutput(cmd); eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v }); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(`${device.name}: command failed - ${message}`); } finally { setTransmittingKey((k) => (k === key ? null : k)); } }, value, ); } async function handleStopDevice(device: ConnectedDeviceInfo) { const liveDevice = await getDevice(device.index); await liveDevice?.stop(); eventBuffer.record({ deviceIndex: device.index, commandType: "stop", featureIndex: 0, value: 0 }); device.actuators.forEach((a) => setActuatorValue(device.index, a.featureIndex, 0)); } async function handleDisconnectDevice(device: ConnectedDeviceInfo) { // Buttplug's protocol has no per-device disconnect message - only a // whole-client disconnect() and stop() (halts actuators). Stopping it and // removing it from local state is the closest equivalent: the card // disappears and it stops receiving commands, though the underlying BLE // link may persist until the whole client disconnects. const liveDevice = await getDevice(device.index); await liveDevice?.stop().catch(() => {}); removeDevice(device.index); } if (!supported) { return ( This browser doesn't support Web Bluetooth, so device control isn't available here. Use a Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can't run Web Bluetooth at all, on any browser - this is an Apple platform limitation, not a bug. ); } return (
void stopScanning()} />
{storeError &&

{storeError}

} {Object.keys(devices).length === 0 ? ( No devices connected yet. Scan to discover nearby toys, then select one from your browser's pairing prompt. ) : (
{Object.values(devices).map((device) => ( [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]), )} transmittingFeatureIndex={ transmittingKey?.startsWith(`${device.index}:`) ? Number(transmittingKey.split(":")[1]) : null } onActuatorChange={(featureIndex, value) => void handleActuatorChange(device, featureIndex, value)} onStop={() => void handleStopDevice(device)} onDisconnect={() => void handleDisconnectDevice(device)} /> ))}
)} setSavePromptSessionId(null)} onSaved={() => setSavePromptSessionId(null)} />
); }