feat: add buttplug device recording feature (Phase 1 & 2)

Implemented complete infrastructure for recording, saving, and managing
buttplug device patterns with precise event timing.

**Phase 1: Backend & Infrastructure**
- Added Directus schema for sexy_recordings collection with all fields
  (id, status, user_created, title, description, slug, duration, events,
  device_info, tags, linked_video, featured, public)
- Created REST API endpoints in bundle extension:
  * GET /sexy/recordings - list user recordings with filtering
  * GET /sexy/recordings/:id - get single recording
  * POST /sexy/recordings - create new recording with validation
  * PATCH /sexy/recordings/:id - update recording (owner only)
  * DELETE /sexy/recordings/:id - soft delete by archiving
- Added TypeScript types: RecordedEvent, DeviceInfo, Recording
- Created frontend services: getRecordings(), deleteRecording()
- Built RecordingCard component with stats, device info, and actions
- Added Recordings tab to /me dashboard page with grid layout
- Added i18n translations for recordings UI

**Phase 2: Recording Capture**
- Implemented recording state management in /play page
- Added Start/Stop Recording buttons with visual indicators
- Capture device events with precise timestamps during recording
- Normalize actuator values (0-100) for cross-device compatibility
- Created RecordingSaveDialog component with:
  * Recording stats display (duration, events, devices)
  * Form inputs (title, description, tags)
  * Device information preview
- Integrated save recording API call from play page
- Added success/error toast notifications
- Automatic event filtering during recording

**Technical Details**
- Events stored as JSON array with timestamp, deviceIndex, deviceName,
  actuatorIndex, actuatorType, and normalized value
- Device metadata includes name, index, and capability list
- Slug auto-generated from title for SEO-friendly URLs
- Status workflow: draft → published → archived
- Permission checks: users can only access own recordings or public ones
- Frontend uses performance.now() for millisecond precision timing

**User Flow**
1. User scans and connects devices on /play page
2. Clicks "Start Recording" to begin capturing events
3. Manipulates device sliders - all changes are recorded
4. Clicks "Stop Recording" to end capture
5. Save dialog appears with recording preview and form
6. User enters title, description, tags and saves
7. Recording appears in dashboard /me Recordings tab
8. Can play back, edit, or delete recordings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Valknar XXX
2025-10-28 04:05:09 +01:00
parent e587552fcb
commit aa4e376490
10 changed files with 3691 additions and 6 deletions

View File

@@ -19,13 +19,24 @@ import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte";
import type { BluetoothDevice } from "$lib/types";
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner";
import { customEndpoint } from "@directus/sdk";
import { getDirectusInstance } from "$lib/directus";
const client = new ButtplugClient("Sexy.Art");
let connected = $state(client.connected);
let scanning = $state(false);
let devices = $state<BluetoothDevice[]>([]);
// Recording state
let isRecording = $state(false);
let recordingStartTime = $state<number | null>(null);
let recordedEvents = $state<RecordedEvent[]>([]);
let showSaveDialog = $state(false);
let recordingDuration = $state(0);
async function init() {
const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info");
@@ -99,6 +110,49 @@ async function handleChange(
device.info.index,
),
);
// Capture event if recording
if (isRecording && recordingStartTime) {
captureEvent(device, scalarIndex, value);
}
}
function startRecording() {
if (devices.length === 0) {
return;
}
isRecording = true;
recordingStartTime = performance.now();
recordedEvents = [];
recordingDuration = 0;
}
function stopRecording() {
isRecording = false;
if (recordedEvents.length > 0) {
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
showSaveDialog = true;
}
}
function captureEvent(
device: BluetoothDevice,
scalarIndex: number,
value: number,
) {
if (!recordingStartTime) return;
const timestamp = performance.now() - recordingStartTime;
const scalarCmd = device.info.messageAttributes.ScalarCmd[scalarIndex];
recordedEvents.push({
timestamp,
deviceIndex: device.info.index,
deviceName: device.name,
actuatorIndex: scalarIndex,
actuatorType: scalarCmd.ActuatorType,
value: (value / scalarCmd.StepCount) * 100, // Normalize to 0-100
});
}
async function handleStop(device: BluetoothDevice) {
@@ -125,6 +179,57 @@ function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
};
}
async function handleSaveRecording(data: {
title: string;
description: string;
tags: string[];
}) {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
}));
try {
const directus = getDirectusInstance();
await directus.request(
customEndpoint({
method: "POST",
path: "/sexy/recordings",
body: JSON.stringify({
title: data.title,
description: data.description,
duration: recordingDuration,
events: recordedEvents,
device_info: deviceInfo,
tags: data.tags,
status: "draft",
}),
headers: {
"Content-Type": "application/json",
},
}),
);
toast.success("Recording saved successfully!");
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
// Optionally navigate to dashboard
// goto("/me?tab=recordings");
} catch (error) {
console.error("Failed to save recording:", error);
toast.error("Failed to save recording. Please try again.");
}
}
function handleCancelSave() {
showSaveDialog = false;
recordedEvents = [];
recordingDuration = 0;
}
const { data } = $props();
onMount(() => {
@@ -166,7 +271,7 @@ onMount(() => {
<p class="text-lg text-muted-foreground mb-10">
{$_("play.description")}
</p>
<div class="flex justify-center">
<div class="flex justify-center gap-4 items-center">
<Button
size="lg"
disabled={!connected || scanning}
@@ -182,6 +287,29 @@ onMount(() => {
{$_("play.scan")}
{/if}
</Button>
{#if devices.length > 0}
{#if !isRecording}
<Button
size="lg"
variant="outline"
onclick={startRecording}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
Start Recording
</Button>
{:else}
<Button
size="lg"
onclick={stopRecording}
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
>
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span>
Stop Recording ({recordedEvents.length} events)
</Button>
{/if}
{/if}
</div>
</div>
</div>
@@ -207,4 +335,18 @@ onMount(() => {
</div>
{/if}
</div>
<!-- Recording Save Dialog -->
<RecordingSaveDialog
open={showSaveDialog}
events={recordedEvents}
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}
onCancel={handleCancelSave}
/>
</div>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import * as Dialog from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import { TagsInput } from "$lib/components/ui/tags-input";
import type { RecordedEvent, DeviceInfo } from "$lib/types";
interface Props {
open: boolean;
events: RecordedEvent[];
deviceInfo: DeviceInfo[];
duration: number;
onSave: (data: {
title: string;
description: string;
tags: string[];
}) => Promise<void>;
onCancel: () => void;
}
let { open, events, deviceInfo, duration, onSave, onCancel }: Props = $props();
let title = $state("");
let description = $state("");
let tags = $state<string[]>([]);
let isSaving = $state(false);
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
async function handleSave() {
if (!title.trim()) return;
isSaving = true;
try {
await onSave({ title: title.trim(), description: description.trim(), tags });
// Reset form
title = "";
description = "";
tags = [];
} finally {
isSaving = false;
}
}
function handleCancel() {
title = "";
description = "";
tags = [];
onCancel();
}
</script>
<Dialog.Root {open} onOpenChange={(isOpen) => !isOpen && handleCancel()}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Save Recording</Dialog.Title>
<Dialog.Description>
Save your recording to view and play it later from your dashboard
</Dialog.Description>
</Dialog.Header>
<div class="space-y-6 py-4">
<!-- Recording Stats -->
<div class="grid grid-cols-3 gap-4">
<div
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--time-line] w-5 h-5 text-primary mb-2"></span>
<span class="text-xs text-muted-foreground mb-1">Duration</span>
<span class="font-semibold">{formatDuration(duration)}</span>
</div>
<div
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--pulse-line] w-5 h-5 text-accent mb-2"></span>
<span class="text-xs text-muted-foreground mb-1">Events</span>
<span class="font-semibold">{events.length}</span>
</div>
<div
class="flex flex-col items-center p-4 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--gamepad-line] w-5 h-5 text-primary mb-2"></span>
<span class="text-xs text-muted-foreground mb-1">Devices</span>
<span class="font-semibold">{deviceInfo.length}</span>
</div>
</div>
<!-- Device Info -->
<div class="space-y-2">
<Label>Devices Used</Label>
{#each deviceInfo as device}
<div
class="flex items-center gap-2 text-sm bg-muted/20 rounded px-3 py-2"
>
<span class="icon-[ri--rocket-line] w-4 h-4"></span>
<span class="font-medium">{device.name}</span>
<span class="text-muted-foreground text-xs">
{device.capabilities.join(", ")}
</span>
</div>
{/each}
</div>
<!-- Form Fields -->
<div class="space-y-4">
<div class="space-y-2">
<Label for="title">Title *</Label>
<Input
id="title"
bind:value={title}
placeholder="My awesome pattern"
required
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={description}
placeholder="Describe your recording..."
rows={3}
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
<div class="space-y-2">
<Label for="tags">Tags</Label>
<TagsInput
id="tags"
bind:value={tags}
placeholder="Add tags..."
class="bg-background/50 border-primary/20 focus:border-primary"
/>
</div>
</div>
</div>
<Dialog.Footer>
<Button
variant="outline"
onclick={handleCancel}
disabled={isSaving}
class="cursor-pointer"
>
Cancel
</Button>
<Button
onclick={handleSave}
disabled={!title.trim() || isSaving}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if isSaving}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
></div>
Saving...
{:else}
<span class="icon-[ri--save-line] w-4 h-4 mr-2"></span>
Save Recording
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>