diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 81e6085..794c03e 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -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( + customEndpoint({ + method: "GET", + path: `/sexy/recordings/${id}`, + }), + ); + return response; + }, + { id }, + ); +} diff --git a/packages/frontend/src/routes/play/+page.server.ts b/packages/frontend/src/routes/play/+page.server.ts index c94572d..a812006 100644 --- a/packages/frontend/src/routes/play/+page.server.ts +++ b/packages/frontend/src/routes/play/+page.server.ts @@ -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, }; } diff --git a/packages/frontend/src/routes/play/+page.svelte b/packages/frontend/src/routes/play/+page.svelte index ecb01ef..6bc09a3 100644 --- a/packages/frontend/src/routes/play/+page.svelte +++ b/packages/frontend/src/routes/play/+page.svelte @@ -35,6 +35,13 @@ let recordedEvents = $state([]); let showSaveDialog = $state(false); let recordingDuration = $state(0); +// Playback state +let isPlaying = $state(false); +let playbackProgress = $state(0); +let playbackStartTime = $state(null); +let playbackTimeoutId = $state(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} - {#if devices.length > 0} + {#if devices.length > 0 && !data.recording} {#if !isRecording} + {#if !isPlaying} + + {:else} + + {/if} + + + +
+
+

Events

+

{data.recording.events.length}

+
+
+

Devices

+

{data.recording.device_info.length}

+
+
+

Status

+

{data.recording.status}

+
+
+ + {/if}