Files
sexy/lib/buttplug/player.ts
T

138 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import { getDevice } from "./client";
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
import type { ActuatorInfo, CommandEvent } from "./types";
export interface SessionEventRow {
tsMs: number;
commandType: CommandEvent["commandType"];
featureIndex: number;
value: number;
durationMs: number | null;
sessionDeviceId: number;
}
export interface PlayerOptions {
events: SessionEventRow[];
/** The source session's actual duration (sessions.duration_ms from the DB) - this is
* the source of truth for playback length, NOT the last event's timestamp: a session 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;
/** Source session'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;
onError?: (message: string) => void;
}
/**
* Replays a session'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 SessionPlayer {
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.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: SessionEventRow): Promise<void> {
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);
}
} catch (err) {
this.options.onError?.(err instanceof Error ? err.message : String(err));
}
}
}