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