- 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>
146 lines
4.7 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|