2026-08-25 07:51:38 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import {
|
|
|
|
|
disconnectAll,
|
2026-08-25 17:33:48 +02:00
|
|
|
getBatteryLevel,
|
2026-08-25 07:51:38 +02:00
|
|
|
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";
|
2026-08-27 21:38:51 +02:00
|
|
|
import { NameSessionDialog } from "./NameSessionDialog";
|
2026-08-25 07:51:38 +02:00
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
|
|
2026-08-27 21:38:51 +02:00
|
|
|
interface ActiveSession {
|
2026-08-25 07:51:38 +02:00
|
|
|
id: number;
|
|
|
|
|
startedAt: number;
|
|
|
|
|
sessionDeviceIdByDeviceIndex: Map<number, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-25 17:33:48 +02:00
|
|
|
const batteryLevels = useButtplugStore((s) => s.batteryLevels);
|
|
|
|
|
const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel);
|
2026-08-25 21:26:18 +02:00
|
|
|
const removeDevice = useButtplugStore((s) => s.removeDevice);
|
|
|
|
|
const setRecording = useButtplugStore((s) => s.setRecording);
|
2026-08-25 07:51:38 +02:00
|
|
|
const storeError = useButtplugStore((s) => s.error);
|
|
|
|
|
|
2026-08-27 21:38:51 +02:00
|
|
|
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
2026-08-25 07:51:38 +02:00
|
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
|
|
|
const [sessionBusy, setSessionBusy] = useState(false);
|
|
|
|
|
const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null);
|
|
|
|
|
const [transmittingKey, setTransmittingKey] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const runtimeRef = useRef<ButtplugRuntime | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeSession) return;
|
|
|
|
|
const interval = setInterval(() => setElapsedMs(Date.now() - activeSession.startedAt), 250);
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [activeSession]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
void disconnectAll();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-25 17:33:48 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-08-25 07:51:38 +02:00
|
|
|
async function ensureRuntime(): Promise<ButtplugRuntime> {
|
|
|
|
|
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 = {
|
|
|
|
|
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 },
|
|
|
|
|
})),
|
|
|
|
|
};
|
2026-08-27 21:38:51 +02:00
|
|
|
const res = await fetch("/api/sessions", {
|
2026-08-25 07:51:38 +02:00
|
|
|
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<number, number>();
|
|
|
|
|
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);
|
2026-08-25 21:26:18 +02:00
|
|
|
setRecording(true);
|
2026-08-25 07:51:38 +02:00
|
|
|
} catch {
|
|
|
|
|
toast.error("Could not start session.");
|
|
|
|
|
} finally {
|
|
|
|
|
setSessionBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleEndSession() {
|
|
|
|
|
if (!activeSession) return;
|
|
|
|
|
setSessionBusy(true);
|
|
|
|
|
eventBuffer.stop();
|
2026-08-25 21:26:18 +02:00
|
|
|
setRecording(false);
|
2026-08-27 21:38:51 +02:00
|
|
|
await fetch(`/api/sessions/${activeSession.id}`, {
|
2026-08-25 07:51:38 +02:00
|
|
|
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);
|
2026-08-25 17:33:48 +02:00
|
|
|
if (!liveDevice || !feature) {
|
|
|
|
|
toast.error(`${device.name}: device not found - try reconnecting.`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-25 07:51:38 +02:00
|
|
|
setTransmittingKey(key);
|
|
|
|
|
const cmd = buildOutputCommand(runtime, actuator, v);
|
|
|
|
|
await feature.runOutput(cmd);
|
|
|
|
|
eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v });
|
2026-08-25 17:33:48 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
toast.error(`${device.name}: command failed - ${message}`);
|
2026-08-25 07:51:38 +02:00
|
|
|
} 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 21:26:18 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 07:51:38 +02:00
|
|
|
if (!supported) {
|
|
|
|
|
return (
|
|
|
|
|
<Card className="bp-glass">
|
|
|
|
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
|
|
|
|
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.
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-6">
|
2026-08-31 17:31:51 +02:00
|
|
|
<div className="flex flex-wrap items-center gap-4">
|
2026-08-25 07:51:38 +02:00
|
|
|
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
|
|
|
|
|
<RecordControls
|
|
|
|
|
active={activeSession !== null}
|
|
|
|
|
elapsedLabel={formatElapsed(elapsedMs)}
|
|
|
|
|
disabled={!connected || Object.keys(devices).length === 0}
|
|
|
|
|
busy={sessionBusy}
|
|
|
|
|
onStart={handleStartSession}
|
|
|
|
|
onEnd={handleEndSession}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{storeError && <p className="text-sm text-destructive">{storeError}</p>}
|
|
|
|
|
|
|
|
|
|
{Object.keys(devices).length === 0 ? (
|
|
|
|
|
<Card className="bp-glass">
|
2026-08-31 19:25:51 +02:00
|
|
|
<CardContent className="text-sm text-muted-foreground">
|
2026-08-25 07:51:38 +02:00
|
|
|
No devices connected yet. Scan to discover nearby toys, then select one from your browser's
|
|
|
|
|
pairing prompt.
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
{Object.values(devices).map((device) => (
|
|
|
|
|
<DeviceCard
|
|
|
|
|
key={device.index}
|
|
|
|
|
device={device}
|
2026-08-25 17:33:48 +02:00
|
|
|
batteryLevel={batteryLevels[device.index] ?? null}
|
2026-08-25 07:51:38 +02:00
|
|
|
actuatorValues={Object.fromEntries(
|
|
|
|
|
device.actuators.map((a) => [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)}
|
2026-08-25 21:26:18 +02:00
|
|
|
onDisconnect={() => void handleDisconnectDevice(device)}
|
2026-08-25 07:51:38 +02:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-08-27 21:38:51 +02:00
|
|
|
<NameSessionDialog
|
|
|
|
|
sessionId={savePromptSessionId}
|
2026-08-25 07:51:38 +02:00
|
|
|
onClose={() => setSavePromptSessionId(null)}
|
|
|
|
|
onSaved={() => setSavePromptSessionId(null)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|