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 },
|
{ 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 {
|
return {
|
||||||
authStatus: locals.authStatus,
|
authStatus: locals.authStatus,
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user