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

@@ -0,0 +1,180 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import type { Recording } from "$lib/types";
import { cn } from "$lib/utils";
interface Props {
recording: Recording;
onPlay?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { recording, onPlay, onDelete }: Props = $props();
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")}`;
}
function getStatusColor(status: string): string {
switch (status) {
case "published":
return "text-green-400 bg-green-400/20";
case "draft":
return "text-yellow-400 bg-yellow-400/20";
case "archived":
return "text-red-400 bg-red-400/20";
default:
return "text-gray-400 bg-gray-400/20";
}
}
</script>
<Card
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3
class="font-semibold text-card-foreground group-hover:text-primary transition-colors"
>
{recording.title}
</h3>
<span
class={cn(
"text-xs px-2 py-0.5 rounded-full",
getStatusColor(recording.status),
)}
>
{$_(`recording_card.status_${recording.status}`)}
</span>
</div>
{#if recording.description}
<p class="text-sm text-muted-foreground line-clamp-2">
{recording.description}
</p>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3">
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--time-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground"
>{$_("recording_card.duration")}</span
>
<span class="font-medium text-sm">{formatDuration(recording.duration)}</span>
</div>
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--pulse-line] w-4 h-4 text-accent mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.events")}</span>
<span class="font-medium text-sm">{recording.events.length}</span>
</div>
<div
class="flex flex-col items-center p-3 rounded-lg bg-muted/30 border border-border/30"
>
<span class="icon-[ri--gamepad-line] w-4 h-4 text-primary mb-1"></span>
<span class="text-xs text-muted-foreground">{$_("recording_card.devices")}</span>
<span class="font-medium text-sm">{recording.device_info.length}</span>
</div>
</div>
<!-- Device Info -->
<div class="space-y-1">
{#each recording.device_info.slice(0, 2) as device}
<div
class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/20 rounded px-2 py-1"
>
<span class="icon-[ri--rocket-line] w-3 h-3"></span>
<span>{device.name}</span>
<span class="text-xs opacity-60">{device.capabilities.join(", ")}</span>
</div>
{/each}
{#if recording.device_info.length > 2}
<div class="text-xs text-muted-foreground/60 px-2">
+{recording.device_info.length - 2} more device{recording.device_info.length -
2 >
1
? "s"
: ""}
</div>
{/if}
</div>
<!-- Tags -->
{#if recording.tags && recording.tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each recording.tags as tag}
<span
class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
>
{tag}
</span>
{/each}
</div>
{/if}
<!-- Metadata -->
<div class="flex items-center justify-between text-xs text-muted-foreground pt-2">
<div class="flex items-center gap-3">
<span>
{new Date(recording.date_created).toLocaleDateString()}
</span>
{#if recording.public}
<span class="flex items-center gap-1">
<span class="icon-[ri--global-line] w-3 h-3"></span>
{$_("recording_card.public")}
</span>
{:else}
<span class="flex items-center gap-1">
<span class="icon-[ri--lock-line] w-3 h-3"></span>
{$_("recording_card.private")}
</span>
{/if}
</div>
{#if recording.linked_video}
<span class="flex items-center gap-1 text-accent">
<span class="icon-[ri--video-line] w-3 h-3"></span>
{$_("recording_card.linked_video")}
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2 pt-2">
{#if onPlay}
<Button
size="sm"
onclick={() => onPlay?.(recording.id)}
class="flex-1 cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
<span class="icon-[ri--play-fill] w-4 h-4 mr-1"></span>
{$_("recording_card.play")}
</Button>
{/if}
{#if onDelete}
<Button
size="sm"
variant="outline"
onclick={() => onDelete?.(recording.id)}
class="cursor-pointer border-destructive/20 hover:bg-destructive/10 hover:text-destructive"
>
<span class="icon-[ri--delete-bin-line] w-4 h-4"></span>
</Button>
{/if}
</div>
</CardContent>
</Card>

View File

@@ -118,6 +118,33 @@ export default {
confirm_password: "Confirm Password",
confirm_password_placeholder: "Confirm your password",
},
recordings: {
title: "Recordings",
description: "Manage your device recordings",
no_recordings: "You haven't created any recordings yet",
no_recordings_description:
"Start recording device patterns from the Play page to create interactive content",
go_to_play: "Go to Play",
loading: "Loading recordings...",
delete_confirm: "Are you sure you want to delete this recording?",
delete_success: "Recording deleted successfully",
delete_error: "Failed to delete recording",
},
},
recording_card: {
duration: "Duration",
events: "Events",
devices: "Devices",
created: "Created",
status_draft: "Draft",
status_published: "Published",
status_archived: "Archived",
play: "Play",
edit: "Edit",
delete: "Delete",
public: "Public",
private: "Private",
linked_video: "Linked to video",
},
auth: {
login: {

View File

@@ -16,7 +16,7 @@ import {
readComments,
aggregate,
} from "@directus/sdk";
import type { Article, Model, Stats, User, Video } from "$lib/types";
import type { Article, Model, Recording, Stats, User, Video } from "$lib/types";
import { PUBLIC_URL } from "$env/static/public";
import { logger } from "$lib/logger";
@@ -548,3 +548,36 @@ export async function getItemsByTag(
{ category, tag },
);
}
export async function getRecordings(fetch?: typeof globalThis.fetch) {
return loggedApiCall(
"getRecordings",
async () => {
const directus = getDirectusInstance(fetch);
const response = await directus.request<Recording[]>(
customEndpoint({
method: "GET",
path: "/sexy/recordings",
}),
);
return response;
},
{},
);
}
export async function deleteRecording(id: string) {
return loggedApiCall(
"deleteRecording",
async () => {
const directus = getDirectusInstance();
await directus.request(
customEndpoint({
method: "DELETE",
path: `/sexy/recordings/${id}`,
}),
);
},
{ id },
);
}

View File

@@ -122,3 +122,36 @@ export interface ShareContent {
url: string;
type: "video" | "model" | "article" | "link";
}
export interface RecordedEvent {
timestamp: number;
deviceIndex: number;
deviceName: string;
actuatorIndex: number;
actuatorType: string;
value: number;
}
export interface DeviceInfo {
name: string;
index: number;
capabilities: string[];
}
export interface Recording {
id: string;
title: string;
description?: string;
slug: string;
duration: number;
events: RecordedEvent[];
device_info: DeviceInfo[];
user_created: string | User;
date_created: Date;
date_updated?: Date;
status: "draft" | "published" | "archived";
tags?: string[];
linked_video?: string | Video;
featured?: boolean;
public?: boolean;
}