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:
@@ -613,3 +613,20 @@ export async function deleteRecording(id: string) {
|
||||
{ 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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
authStatus: locals.authStatus,
|
||||
recording,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ let recordedEvents = $state<RecordedEvent[]>([]);
|
||||
let showSaveDialog = $state(false);
|
||||
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() {
|
||||
const connector = new ButtplugWasmClientConnector();
|
||||
// await ButtplugWasmClientConnector.activateLogging("info");
|
||||
@@ -228,6 +235,142 @@ function handleCancelSave() {
|
||||
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();
|
||||
|
||||
onMount(() => {
|
||||
@@ -286,7 +429,7 @@ onMount(() => {
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if devices.length > 0}
|
||||
{#if devices.length > 0 && !data.recording}
|
||||
{#if !isRecording}
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -310,6 +453,92 @@ onMount(() => {
|
||||
{/if}
|
||||
</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 class="container mx-auto px-4 py-12">
|
||||
|
||||
Reference in New Issue
Block a user