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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user