Remove recordings feature, replay sessions directly, bump to 0.6.0
Recordings were just a thin named pointer over an already-captured session's events, so the whole separate feature (recordings table, API routes, pages, UI) is gone: any completed session can now be named and replayed directly. Replaying no longer creates a session or duplicates events of its own - it just bumps the source session's playCount/lastPlayedAt. Also renames play_sessions/playSession(s) to sessions/session(s) throughout the schema, queries, API routes, and UI for consistency, and updates the README to match the new flow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import type { RecordingDeviceSlot } from "@/lib/db/schema";
|
||||
import type { ConnectedDeviceInfo } from "./types";
|
||||
import type { ConnectedDeviceInfo, ReplayDeviceSlot } from "./types";
|
||||
|
||||
export interface DeviceRemapEntry {
|
||||
slotLabel: string;
|
||||
@@ -8,14 +7,14 @@ export interface DeviceRemapEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort name match from a recording's saved device slots to the
|
||||
* Best-effort name match from a session's device slots to the
|
||||
* currently-connected devices. Web Bluetooth exposes no stable hardware id
|
||||
* across sessions, so this is inherently approximate: two devices sharing an
|
||||
* identical advertised name are indistinguishable and must be disambiguated
|
||||
* manually in the remap UI - this isn't a bug, it's a hard BLE limitation.
|
||||
*/
|
||||
export function autoMapDeviceSlots(
|
||||
slots: RecordingDeviceSlot[],
|
||||
slots: ReplayDeviceSlot[],
|
||||
connectedDevices: ConnectedDeviceInfo[],
|
||||
): DeviceRemapEntry[] {
|
||||
const usedIndexes = new Set<number>();
|
||||
|
||||
@@ -13,21 +13,20 @@ interface ApiEvent {
|
||||
|
||||
/**
|
||||
* Buffers every dispatched command (live or replay) and flushes it in
|
||||
* batches to the play-session's events endpoint, so a dragged slider never
|
||||
* batches to the session's events endpoint, so a dragged slider never
|
||||
* fires one HTTP request per tick. Every command is always recorded here
|
||||
* regardless of whether the session is later saved as a named recording -
|
||||
* "recording" is a save decision made after the fact, not a separate
|
||||
* capture pipeline (see lib/db/queries/recordings.ts).
|
||||
* regardless of whether the session is later named - any completed session
|
||||
* can be replayed directly, there's no separate "save as recording" step.
|
||||
*/
|
||||
class EventBuffer {
|
||||
private buffer: CommandEvent[] = [];
|
||||
private playSessionId: number | null = null;
|
||||
private sessionId: number | null = null;
|
||||
private sessionStartedAt = 0;
|
||||
private sessionDeviceIdByDeviceIndex = new Map<number, number>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
start(playSessionId: number, sessionStartedAt: number): void {
|
||||
this.playSessionId = playSessionId;
|
||||
start(sessionId: number, sessionStartedAt: number): void {
|
||||
this.sessionId = sessionId;
|
||||
this.sessionStartedAt = sessionStartedAt;
|
||||
this.sessionDeviceIdByDeviceIndex = new Map();
|
||||
this.buffer = [];
|
||||
@@ -46,16 +45,16 @@ class EventBuffer {
|
||||
}
|
||||
|
||||
record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void {
|
||||
if (this.playSessionId === null) return;
|
||||
if (this.sessionId === null) return;
|
||||
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
|
||||
this.buffer.push({ ...event, tsMs });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0 || this.playSessionId === null) return;
|
||||
if (this.buffer.length === 0 || this.sessionId === null) return;
|
||||
const events = this.drain();
|
||||
try {
|
||||
await fetch(`/api/play-sessions/${this.playSessionId}/events`, {
|
||||
await fetch(`/api/sessions/${this.sessionId}/events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events }),
|
||||
@@ -71,7 +70,7 @@ class EventBuffer {
|
||||
void this.flush();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
this.playSessionId = null;
|
||||
this.sessionId = null;
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("beforeunload", this.flushBeacon);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
@@ -83,10 +82,10 @@ class EventBuffer {
|
||||
};
|
||||
|
||||
private flushBeacon = (): void => {
|
||||
if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return;
|
||||
if (this.buffer.length === 0 || this.sessionId === null || typeof navigator === "undefined") return;
|
||||
const events = this.drain();
|
||||
navigator.sendBeacon(
|
||||
`/api/play-sessions/${this.playSessionId}/events`,
|
||||
`/api/sessions/${this.sessionId}/events`,
|
||||
new Blob([JSON.stringify({ events })], { type: "application/json" }),
|
||||
);
|
||||
};
|
||||
|
||||
+11
-19
@@ -1,9 +1,8 @@
|
||||
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 {
|
||||
export interface SessionEventRow {
|
||||
tsMs: number;
|
||||
commandType: CommandEvent["commandType"];
|
||||
featureIndex: number;
|
||||
@@ -13,13 +12,14 @@ export interface RecordingEventRow {
|
||||
}
|
||||
|
||||
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". */
|
||||
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;
|
||||
/** Recording's session_device_id -> currently-connected device index, from the remap step. */
|
||||
/** Source session's session_device_id -> currently-connected device index, from the remap step. */
|
||||
sessionDeviceIdToDeviceIndex: Map<number, number>;
|
||||
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
|
||||
runtime: ButtplugRuntime;
|
||||
@@ -30,11 +30,11 @@ export interface PlayerOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a recording's events against the currently-connected devices,
|
||||
* 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 RecordingPlayer {
|
||||
export class SessionPlayer {
|
||||
private readonly durationMs: number;
|
||||
private startedAtPerf = 0;
|
||||
private pausedAtMs = 0;
|
||||
@@ -110,7 +110,7 @@ export class RecordingPlayer {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private async dispatch(event: RecordingEventRow): Promise<void> {
|
||||
private async dispatch(event: SessionEventRow): Promise<void> {
|
||||
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
|
||||
if (deviceIndex === undefined) return;
|
||||
|
||||
@@ -130,14 +130,6 @@ export class RecordingPlayer {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* rows) never has to import the client library directly.
|
||||
*/
|
||||
|
||||
import type { DeviceCapabilities } from "@/lib/db/schema";
|
||||
|
||||
export type NormalizedOutputType = "vibrate" | "rotate" | "linear";
|
||||
export type CommandType = NormalizedOutputType | "stop";
|
||||
|
||||
@@ -32,3 +34,18 @@ export interface CommandEvent {
|
||||
value: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A device that took part in a session, as needed to remap it onto a
|
||||
* currently-connected device for replay. Computed live from that session's
|
||||
* session_devices/devices rows - not a frozen snapshot.
|
||||
*/
|
||||
export interface ReplayDeviceSlot {
|
||||
slotLabel: string;
|
||||
recordedBleName: string;
|
||||
deviceClass: string | null;
|
||||
capabilities: DeviceCapabilities | null;
|
||||
/** The session_devices.id this slot was captured from - lets the replay UI
|
||||
* map a chosen live device back to this slot's session_events. */
|
||||
sourceSessionDeviceId: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user