Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
disconnectAll,
|
||||
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<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 storeError = useButtplugStore((s) => s.error);
|
||||
|
||||
const [activeSession, setActiveSession] = useState<ActivePlaySession | 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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 = {
|
||||
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<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);
|
||||
} catch {
|
||||
toast.error("Could not start session.");
|
||||
} finally {
|
||||
setSessionBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndSession() {
|
||||
if (!activeSession) return;
|
||||
setSessionBusy(true);
|
||||
eventBuffer.stop();
|
||||
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) return;
|
||||
setTransmittingKey(key);
|
||||
const cmd = buildOutputCommand(runtime, actuator, v);
|
||||
await feature.runOutput(cmd);
|
||||
eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v });
|
||||
} 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));
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex flex-wrap items-center justify-between 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 ? (
|
||||
<Card className="bp-glass">
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
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}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SaveRecordingDialog
|
||||
playSessionId={savePromptSessionId}
|
||||
onClose={() => setSavePromptSessionId(null)}
|
||||
onSaved={() => setSavePromptSessionId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user