Initial implementation of Bluetooth toy control app
CI / Static checks (push) Successful in 1m27s
CI / Build and push image (push) Skipped

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:
2026-08-25 07:51:38 +02:00
co-authored by Claude Sonnet 5
commit 1119c8eea0
112 changed files with 15522 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Slider } from "@/components/ui/slider";
import type { ActuatorInfo } from "@/lib/buttplug/types";
interface ActuatorSliderProps {
actuator: ActuatorInfo;
value: number;
transmitting: boolean;
onChange: (value: number) => void;
}
export function ActuatorSlider({ actuator, value, transmitting, onChange }: ActuatorSliderProps) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{actuator.descriptor}</span>
<span className={transmitting ? "bp-gradient-text font-semibold" : "text-muted-foreground"}>
{Math.round(value * 100)}%
</span>
</div>
<Slider
value={[value * 100]}
min={0}
max={100}
step={1}
onValueChange={([v]) => onChange(v / 100)}
/>
</div>
);
}
+239
View File
@@ -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&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 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&apos;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>
);
}
@@ -0,0 +1,13 @@
"use client";
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/ui/skeleton";
const ButtplugConsole = dynamic(() => import("./ButtplugConsole").then((m) => m.ButtplugConsole), {
ssr: false,
loading: () => <Skeleton className="h-64 w-full" />,
});
export function ButtplugConsoleLoader() {
return <ButtplugConsole />;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
interface DeviceCardProps {
device: ConnectedDeviceInfo;
actuatorValues: Record<number, number>;
transmittingFeatureIndex: number | null;
onActuatorChange: (featureIndex: number, value: number) => void;
onStop: () => void;
}
export function DeviceCard({
device,
actuatorValues,
transmittingFeatureIndex,
onActuatorChange,
onStop,
}: DeviceCardProps) {
return (
<Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{device.displayName ?? device.name}</CardTitle>
<Button variant="outline" size="sm" onClick={onStop}>
Stop
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{device.actuators.length === 0 ? (
<p className="text-sm text-muted-foreground">No controllable actuators reported.</p>
) : (
device.actuators.map((actuator) => (
<ActuatorSlider
key={actuator.featureIndex}
actuator={actuator}
value={actuatorValues[actuator.featureIndex] ?? 0}
transmitting={transmittingFeatureIndex === actuator.featureIndex}
onChange={(value) => onActuatorChange(actuator.featureIndex, value)}
/>
))
)}
</CardContent>
</Card>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { Button } from "@/components/ui/button";
import { Bluetooth, LoaderCircle } from "lucide-react";
interface DeviceScanPanelProps {
scanning: boolean;
onScan: () => void;
onStopScan: () => void;
}
export function DeviceScanPanel({ scanning, onScan, onStopScan }: DeviceScanPanelProps) {
return (
<Button onClick={scanning ? onStopScan : onScan} variant={scanning ? "outline" : "default"}>
{scanning ? (
<>
<LoaderCircle className="size-4 animate-spin" /> Stop scanning
</>
) : (
<>
<Bluetooth className="size-4" /> Scan for devices
</>
)}
</Button>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { Button } from "@/components/ui/button";
import { Circle, Square } from "lucide-react";
interface RecordControlsProps {
active: boolean;
elapsedLabel: string;
disabled: boolean;
busy: boolean;
onStart: () => void;
onEnd: () => void;
}
export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) {
if (active) {
return (
<div className="flex items-center gap-3">
<span className="flex items-center gap-2 text-sm font-medium">
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel}
</span>
<Button variant="outline" size="sm" onClick={onEnd} disabled={busy}>
<Square className="size-3.5" /> End session
</Button>
</div>
);
}
return (
<Button onClick={onStart} disabled={disabled || busy} size="sm">
<Circle className="size-3.5" /> Start session
</Button>
);
}