import type { ButtplugClient, ButtplugClientDevice, InputType } from "buttplug"; import { deriveActuators, type ButtplugRuntime } from "./commands"; import { useButtplugStore } from "./store"; import type { ConnectedDeviceInfo } from "./types"; export function isWebBluetoothSupported(): boolean { return typeof navigator !== "undefined" && "bluetooth" in navigator; } interface ClientHandle { client: ButtplugClient; runtime: ButtplugRuntime; } let clientPromise: Promise | null = null; function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo { return { index: device.index, name: device.name, displayName: device.displayName, actuators: deriveActuators(device), // "Battery" is a string enum value - passing the literal avoids needing // InputType as a runtime import here (see commands.ts for the same trick). hasBattery: device.hasInput("Battery" as InputType), }; } async function initClient(): Promise { const [{ ButtplugClient, DeviceOutput, OutputType }, { ButtplugWasmClientConnector }] = await Promise.all([ import("buttplug"), import("buttplug-wasm"), ]); const client = new ButtplugClient("sexy"); client.on("deviceadded", (device: ButtplugClientDevice) => { useButtplugStore.getState().upsertDevice(toDeviceInfo(device)); }); client.on("deviceremoved", (device: ButtplugClientDevice) => { useButtplugStore.getState().removeDevice(device.index); }); client.on("scanningfinished", () => { useButtplugStore.getState().setScanning(false); }); client.on("disconnect", () => { useButtplugStore.getState().reset(); }); const connector = new ButtplugWasmClientConnector(); // EXPERIMENTAL: buttplug-wasm@3.0.0's embedded server was built against // buttplug@^4.0.2's wire format; buttplug@5's OutputCmd message shape // changed (`Value` went from number[] to a bare number), which is a real // protocol break, not just a type mismatch - the cast below silences that // signal rather than fixing it. Scanning/pairing may still work since // DeviceAdded's shape didn't change; device *commands* may be silently // rejected or mis-parsed server-side. Revert to buttplug@^4.0.2 if so. await client.connect(connector as unknown as Parameters[0]); useButtplugStore.getState().setConnected(true); for (const device of client.devices.values()) { useButtplugStore.getState().upsertDevice(toDeviceInfo(device)); } return { client, runtime: { DeviceOutput, OutputType } }; } /** Lazily creates and connects the singleton Buttplug client. Browser-only. */ export function getButtplugClientHandle(): Promise { if (typeof window === "undefined") { return Promise.reject(new Error("Buttplug client is browser-only")); } if (!clientPromise) { clientPromise = initClient().catch((err) => { clientPromise = null; useButtplugStore.getState().setError(err instanceof Error ? err.message : String(err)); throw err; }); } return clientPromise; } export async function startScanning(): Promise { const { client } = await getButtplugClientHandle(); useButtplugStore.getState().setScanning(true); await client.startScanning(); } export async function stopScanning(): Promise { const { client } = await getButtplugClientHandle(); await client.stopScanning(); useButtplugStore.getState().setScanning(false); } export async function disconnectAll(): Promise { if (!clientPromise) return; const { client } = await clientPromise; clientPromise = null; await client.disconnect(); useButtplugStore.getState().reset(); } export async function getDevice(deviceIndex: number): Promise { const { client } = await getButtplugClientHandle(); return client.devices.get(deviceIndex); } /** Reads the current battery level (0-1), or null if unsupported/unreadable. */ export async function getBatteryLevel(deviceIndex: number): Promise { const device = await getDevice(deviceIndex); if (!device || !device.hasInput("Battery" as InputType)) return null; try { return await device.battery(); } catch { return null; } }