Files
sexy/lib/buttplug/player.ts
T
valknarandClaude Sonnet 5 1119c8eea0
CI / Static checks (push) Successful in 1m27s
CI / Build and push image (push) Skipped
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
2026-08-25 07:51:38 +02:00

137 lines
4.1 KiB
TypeScript

import { getDevice } from "./client";
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
import { eventBuffer } from "./event-buffer";
import type { ActuatorInfo, CommandEvent } from "./types";
export interface RecordingEventRow {
tsMs: number;
commandType: CommandEvent["commandType"];
featureIndex: number;
value: number;
durationMs: number | null;
sessionDeviceId: number;
}
export interface PlayerOptions {
events: RecordingEventRow[];
/** Recording's session_device_id -> currently-connected device index, from the remap step. */
sessionDeviceIdToDeviceIndex: Map<number, number>;
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
runtime: ButtplugRuntime;
speed?: number;
onProgress?: (elapsedMs: number, durationMs: number) => void;
onComplete?: () => void;
}
/**
* Replays a recording's events against the currently-connected devices,
* using performance.now()-relative scheduling (not wall-clock Date.now())
* so long sessions don't accumulate drift from setTimeout jitter.
*/
export class RecordingPlayer {
private readonly durationMs: number;
private startedAtPerf = 0;
private pausedAtMs = 0;
private timers: ReturnType<typeof setTimeout>[] = [];
private progressTimer: ReturnType<typeof setInterval> | null = null;
private playing = false;
constructor(private readonly options: PlayerOptions) {
this.durationMs = options.events.at(-1)?.tsMs ?? 0;
}
get isPlaying(): boolean {
return this.playing;
}
play(): void {
if (this.playing) return;
const speed = this.options.speed ?? 1;
this.playing = true;
this.startedAtPerf = performance.now() - this.pausedAtMs / speed;
this.scheduleFrom(this.pausedAtMs);
}
pause(): void {
if (!this.playing) return;
const speed = this.options.speed ?? 1;
this.pausedAtMs = (performance.now() - this.startedAtPerf) * speed;
this.playing = false;
this.clearTimers();
}
seek(ms: number): void {
const wasPlaying = this.playing;
this.clearTimers();
this.playing = false;
this.pausedAtMs = Math.max(0, Math.min(ms, this.durationMs));
if (wasPlaying) this.play();
}
stop(): void {
this.playing = false;
this.pausedAtMs = 0;
this.clearTimers();
}
private clearTimers(): void {
this.timers.forEach(clearTimeout);
this.timers = [];
if (this.progressTimer) clearInterval(this.progressTimer);
this.progressTimer = null;
}
private scheduleFrom(fromMs: number): void {
const speed = this.options.speed ?? 1;
for (const event of this.options.events.filter((e) => e.tsMs >= fromMs)) {
const delay = (event.tsMs - fromMs) / speed;
this.timers.push(setTimeout(() => void this.dispatch(event), delay));
}
this.timers.push(
setTimeout(() => {
this.playing = false;
this.clearTimers();
this.options.onComplete?.();
}, (this.durationMs - fromMs) / speed),
);
this.progressTimer = setInterval(() => {
if (!this.playing) return;
const elapsed = Math.min((performance.now() - this.startedAtPerf) * speed, this.durationMs);
this.options.onProgress?.(elapsed, this.durationMs);
}, 200);
}
private async dispatch(event: RecordingEventRow): Promise<void> {
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
if (deviceIndex === undefined) return;
const device = await getDevice(deviceIndex);
if (!device) return;
eventBuffer.record({
deviceIndex,
commandType: event.commandType,
featureIndex: event.featureIndex,
value: event.value,
durationMs: event.durationMs ?? undefined,
});
if (event.commandType === "stop") {
await device.stop();
return;
}
const actuator = this.options.actuatorsByDeviceIndex
.get(deviceIndex)
?.find((a) => a.featureIndex === event.featureIndex);
const feature = findFeature(device, event.featureIndex);
if (!actuator || !feature) return;
const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
await feature.runOutput(cmd);
}
}