Files
valknarandClaude Sonnet 5 69e4fb286c Give the "no devices connected" alerts a title
Splits the placeholder text into an AlertTitle ("No devices connected
yet") and an AlertDescription for the rest, in both ButtplugConsole
and ReplayPlayer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:06:28 +02:00

283 lines
10 KiB
TypeScript

"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 { NameSessionDialog } from "./NameSessionDialog";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info } from "lucide-react";
interface ActiveSession {
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);
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<ActiveSession | null>(null);
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();
};
}, []);
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<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 },
})),
};
const res = await fetch("/api/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<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);
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/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 (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">
This browser doesn&apos;t support Web Bluetooth, so device control isn&apos;t available here. Use a
Chromium-based desktop or Android browser (Chrome, Edge) over HTTPS. iOS and Safari can&apos;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">
<div className="flex flex-wrap items-center gap-4">
<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 ? (
<Alert className="bp-glass">
<Info />
<AlertTitle>No devices connected yet</AlertTitle>
<AlertDescription>
Scan to discover nearby toys, then select one from your browser&apos;s pairing prompt.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{Object.values(devices).map((device) => (
<DeviceCard
key={device.index}
device={device}
batteryLevel={batteryLevels[device.index] ?? null}
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)}
onDisconnect={() => void handleDisconnectDevice(device)}
/>
))}
</div>
)}
<NameSessionDialog
sessionId={savePromptSessionId}
onClose={() => setSavePromptSessionId(null)}
onSaved={() => setSavePromptSessionId(null)}
/>
</div>
);
}