feat: add recording playback functionality

- Add getRecording service function to fetch single recording
- Update play page server to load recording from URL parameter
- Implement playback engine with event scheduling
- Add playback controls (play, pause, stop, seek)
- Display playback progress bar with clickable seek
- Show recording metadata and stats during playback
- Match recorded events to connected devices by name and actuator type
- Convert normalized values back to device scale for playback

🤖 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 05:28:04 +01:00
parent a252da6d9d
commit 50ceda94b7
3 changed files with 262 additions and 2 deletions

View File

@@ -613,3 +613,20 @@ export async function deleteRecording(id: string) {
{ id }, { id },
); );
} }
export async function getRecording(id: string, fetch?: typeof globalThis.fetch) {
return loggedApiCall(
"getRecording",
async () => {
const directus = getDirectusInstance(fetch);
const response = await directus.request<Recording>(
customEndpoint({
method: "GET",
path: `/sexy/recordings/${id}`,
}),
);
return response;
},
{ id },
);
}

View File

@@ -1,5 +1,19 @@
export async function load({ locals }) { import { getRecording } from "$lib/services";
export async function load({ locals, url, fetch }) {
const recordingId = url.searchParams.get("recording");
let recording = null;
if (recordingId && locals.authStatus.authenticated) {
try {
recording = await getRecording(recordingId, fetch);
} catch (error) {
console.error("Failed to load recording:", error);
}
}
return { return {
authStatus: locals.authStatus, authStatus: locals.authStatus,
recording,
}; };
} }

View File

@@ -35,6 +35,13 @@ let recordedEvents = $state<RecordedEvent[]>([]);
let showSaveDialog = $state(false); let showSaveDialog = $state(false);
let recordingDuration = $state(0); let recordingDuration = $state(0);
// Playback state
let isPlaying = $state(false);
let playbackProgress = $state(0);
let playbackStartTime = $state<number | null>(null);
let playbackTimeoutId = $state<number | null>(null);
let currentEventIndex = $state(0);
async function init() { async function init() {
const connector = new ButtplugWasmClientConnector(); const connector = new ButtplugWasmClientConnector();
// await ButtplugWasmClientConnector.activateLogging("info"); // await ButtplugWasmClientConnector.activateLogging("info");
@@ -228,6 +235,142 @@ function handleCancelSave() {
recordingDuration = 0; recordingDuration = 0;
} }
// Playback functions
function startPlayback() {
if (!data.recording || devices.length === 0) {
toast.error("Please connect devices before playing recording");
return;
}
isPlaying = true;
playbackStartTime = performance.now();
playbackProgress = 0;
currentEventIndex = 0;
scheduleNextEvent();
}
function stopPlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
playbackProgress = 0;
currentEventIndex = 0;
// Stop all devices
devices.forEach((device) => handleStop(device));
}
function pausePlayback() {
isPlaying = false;
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
playbackTimeoutId = null;
}
}
function resumePlayback() {
if (!data.recording) return;
isPlaying = true;
playbackStartTime = performance.now() - playbackProgress;
scheduleNextEvent();
}
function scheduleNextEvent() {
if (!data.recording || !isPlaying || !playbackStartTime) return;
const events = data.recording.events;
if (currentEventIndex >= events.length) {
stopPlayback();
toast.success("Playback finished");
return;
}
const event = events[currentEventIndex];
const currentTime = performance.now() - playbackStartTime;
const delay = event.timestamp - currentTime;
if (delay <= 0) {
// Execute event immediately
executeEvent(event);
currentEventIndex++;
scheduleNextEvent();
} else {
// Schedule event
playbackTimeoutId = setTimeout(() => {
executeEvent(event);
currentEventIndex++;
playbackProgress = event.timestamp;
scheduleNextEvent();
}, delay) as unknown as number;
}
}
function executeEvent(event: RecordedEvent) {
// Find matching device by name
const device = devices.find(d => d.name === event.deviceName);
if (!device) {
console.warn(`Device not found: ${event.deviceName}`);
return;
}
// Find matching actuator
const scalarCmd = device.info.messageAttributes.ScalarCmd.find(
cmd => cmd.ActuatorType === event.actuatorType
);
if (!scalarCmd) {
console.warn(`Actuator not found: ${event.actuatorType} on ${device.name}`);
return;
}
// Convert normalized value (0-100) back to device scale
const deviceValue = (event.value / 100) * scalarCmd.StepCount;
// Send command to device
client.sendDeviceMessage(
{ index: device.info.index },
new ScalarCmd(
[
new ScalarSubcommand(
scalarCmd.Index,
deviceValue,
scalarCmd.ActuatorType,
),
],
device.info.index,
),
);
// Update UI
const scalarIndex = device.info.messageAttributes.ScalarCmd.indexOf(scalarCmd);
if (scalarIndex !== -1) {
device.actuatorValues[scalarIndex] = deviceValue;
}
}
function seek(percentage: number) {
if (!data.recording) return;
const targetTime = (percentage / 100) * data.recording.duration;
playbackProgress = targetTime;
// Find the event index at this time
currentEventIndex = data.recording.events.findIndex(e => e.timestamp >= targetTime);
if (currentEventIndex === -1) {
currentEventIndex = data.recording.events.length;
}
if (isPlaying) {
if (playbackTimeoutId !== null) {
clearTimeout(playbackTimeoutId);
}
playbackStartTime = performance.now() - targetTime;
scheduleNextEvent();
}
}
const { data } = $props(); const { data } = $props();
onMount(() => { onMount(() => {
@@ -286,7 +429,7 @@ onMount(() => {
{/if} {/if}
</Button> </Button>
{#if devices.length > 0} {#if devices.length > 0 && !data.recording}
{#if !isRecording} {#if !isRecording}
<Button <Button
size="lg" size="lg"
@@ -310,6 +453,92 @@ onMount(() => {
{/if} {/if}
</div> </div>
</div> </div>
<!-- Playback Controls (only shown when recording is loaded) -->
{#if data.recording}
<div class="bg-card/50 border border-primary/20 rounded-lg p-6 backdrop-blur-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-card-foreground mb-2">
{data.recording.title}
</h2>
{#if data.recording.description}
<p class="text-sm text-muted-foreground">
{data.recording.description}
</p>
{/if}
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm text-muted-foreground min-w-[50px]">
{Math.floor(playbackProgress / 1000 / 60)}:{(Math.floor(playbackProgress / 1000) % 60).toString().padStart(2, '0')}
</span>
<div class="flex-1 h-2 bg-muted rounded-full overflow-hidden cursor-pointer relative"
onclick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
seek(percentage);
}}>
<div class="absolute inset-0 bg-gradient-to-r from-primary to-accent transition-all duration-150"
style="width: {(playbackProgress / data.recording.duration) * 100}%"></div>
</div>
<span class="text-sm text-muted-foreground min-w-[50px] text-right">
{Math.floor(data.recording.duration / 1000 / 60)}:{(Math.floor(data.recording.duration / 1000) % 60).toString().padStart(2, '0')}
</span>
</div>
</div>
<!-- Playback Buttons -->
<div class="flex gap-2 justify-center">
<Button
size="lg"
variant="outline"
onclick={stopPlayback}
disabled={!isPlaying && playbackProgress === 0}
class="cursor-pointer border-primary/30 hover:bg-primary/10"
>
<span class="icon-[ri--stop-fill] w-5 h-5"></span>
</Button>
{#if !isPlaying}
<Button
size="lg"
onclick={playbackProgress > 0 ? resumePlayback : startPlayback}
disabled={devices.length === 0}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
>
<span class="icon-[ri--play-fill] w-5 h-5 mr-2"></span>
{playbackProgress > 0 ? 'Resume' : 'Play'}
</Button>
{:else}
<Button
size="lg"
onclick={pausePlayback}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 min-w-[120px]"
>
<span class="icon-[ri--pause-fill] w-5 h-5 mr-2"></span>
Pause
</Button>
{/if}
</div>
<!-- Recording Info -->
<div class="mt-4 pt-4 border-t border-border/50 grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-muted-foreground">Events</p>
<p class="text-sm font-medium">{data.recording.events.length}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Devices</p>
<p class="text-sm font-medium">{data.recording.device_info.length}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Status</p>
<p class="text-sm font-medium capitalize">{data.recording.status}</p>
</div>
</div>
</div>
{/if}
</div> </div>
</div> </div>
<div class="container mx-auto px-4 py-12"> <div class="container mx-auto px-4 py-12">