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[]; /** The recording's actual duration (recordings.durationMs from the DB) - this is the source * of truth for playback length, NOT the last event's timestamp: a recording can run for a * while after its last command (e.g. the user stopped the toy but let the session continue), * so deriving duration from events would end playback early and misreport it as "finished". */ durationMs: number; /** Recording's session_device_id -> currently-connected device index, from the remap step. */ sessionDeviceIdToDeviceIndex: Map; actuatorsByDeviceIndex: Map; runtime: ButtplugRuntime; speed?: number; onProgress?: (elapsedMs: number, durationMs: number) => void; onComplete?: () => void; onError?: (message: string) => 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[] = []; private progressTimer: ReturnType | null = null; private playing = false; constructor(private readonly options: PlayerOptions) { this.durationMs = options.durationMs; } 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 { const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId); if (deviceIndex === undefined) return; try { const device = await getDevice(deviceIndex); if (!device) return; if (event.commandType === "stop") { await device.stop(); } else { 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); } eventBuffer.record({ deviceIndex, commandType: event.commandType, featureIndex: event.featureIndex, value: event.value, durationMs: event.durationMs ?? undefined, }); } catch (err) { this.options.onError?.(err instanceof Error ? err.message : String(err)); } } }