Files
sexy/lib/buttplug/player.ts
T
valknarandClaude Sonnet 5 5484d3cefe
CI / Static checks (push) Successful in 1m7s
CI / Build and push image (push) Successful in 1m2s
Fix device/replay/session UX issues, add pagination, bump to 0.3.0
- Fix device rename input collapsing on mobile (fixed width vs w-full
  inside an auto-layout table column).
- Add cascade-delete confirmation for sessions with a saved recording.
- Fix header connection LED: derive state from scanning/device-count/
  recording instead of the Buttplug client's raw connected flag; drop
  the label text and hide the indicator entirely when idle.
- Add a per-device disconnect button (stop + remove from store, the
  closest equivalent Buttplug's protocol allows per device).
- Fix replay ending early: duration now comes from the recording's
  actual durationMs, not the last event's timestamp.
- Replay robustness: show which devices are being replayed to, reset
  actuators to zero on start/play/pause, fully disconnect devices and
  the whole client on stop/unmount, surface command failures via toast.
- Stop flagging the header LED red during replay - recording is only
  for live sessions.
- Add page-number pagination to recordings/sessions/devices lists.
- Wordmark SEXY -> Sexy; add proper per-page <title>s including
  dynamic titles for recording/session detail and replay pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:26:18 +02:00

146 lines
4.7 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[];
/** 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<number, number>;
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
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<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: RecordingEventRow): 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);
}
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));
}
}
}