613 lines
19 KiB
Svelte
613 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { _ } from "svelte-i18n";
|
|
import Meta from "$lib/components/meta/meta.svelte";
|
|
import {
|
|
ButtplugClient,
|
|
ButtplugWasmClientConnector,
|
|
type ButtplugClientDevice,
|
|
type OutputType,
|
|
InputType,
|
|
DeviceOutputValueConstructor,
|
|
} from "@sexy.pivoine.art/buttplug";
|
|
import type { ButtplugMessage } from "@sexy.pivoine.art/buttplug";
|
|
import Button from "$lib/components/ui/button/button.svelte";
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import DeviceCard from "$lib/components/device-card/device-card.svelte";
|
|
import RecordingSaveDialog from "./components/recording-save-dialog.svelte";
|
|
import DeviceMappingDialog from "./components/device-mapping-dialog.svelte";
|
|
import type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
|
|
import { toast } from "svelte-sonner";
|
|
|
|
const client = new ButtplugClient("Sexy.Art");
|
|
let connected = $state(client.connected);
|
|
let scanning = $state(false);
|
|
let devices = $state<BluetoothDevice[]>([]);
|
|
|
|
// Recording state
|
|
let isRecording = $state(false);
|
|
let recordingStartTime = $state<number | null>(null);
|
|
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);
|
|
let showMappingDialog = $state(false);
|
|
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
|
|
|
|
async function init() {
|
|
const connector = new ButtplugWasmClientConnector();
|
|
// await ButtplugWasmClientConnector.activateLogging("info");
|
|
await client.connect(connector);
|
|
client.on("deviceadded", onDeviceAdded);
|
|
client.on("deviceremoved", (dev: ButtplugClientDevice) => {
|
|
const idx = devices.findIndex((d) => d.info.index === dev.index);
|
|
if (idx !== -1) devices.splice(idx, 1);
|
|
});
|
|
client.on("scanningfinished", () => (scanning = false));
|
|
client.on("inputreading", handleInputReading);
|
|
connected = client.connected;
|
|
}
|
|
|
|
async function startScanning() {
|
|
await client.startScanning();
|
|
scanning = true;
|
|
}
|
|
|
|
async function onDeviceAdded(dev: ButtplugClientDevice) {
|
|
const device = convertDevice(dev);
|
|
devices.push(device);
|
|
|
|
// Try to read battery level — access through the reactive array so Svelte detects the mutation
|
|
const idx = devices.length - 1;
|
|
if (device.hasBattery) {
|
|
try {
|
|
devices[idx].batteryLevel = await dev.battery();
|
|
} catch (e) {
|
|
console.warn(`Failed to read battery for ${dev.name}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleInputReading(msg: ButtplugMessage) {
|
|
if (msg.InputReading === undefined) return;
|
|
const reading = msg.InputReading;
|
|
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
|
|
if (!device) return;
|
|
|
|
if (reading.Reading[InputType.Battery] !== undefined) {
|
|
device.batteryLevel = reading.Reading[InputType.Battery].Value;
|
|
}
|
|
device.lastSeen = new Date();
|
|
}
|
|
|
|
async function handleChange(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
|
const actuator = device.actuators[actuatorIdx];
|
|
const feature = device.info.features.get(actuator.featureIndex);
|
|
if (!feature) return;
|
|
|
|
actuator.value = value;
|
|
const outputType = actuator.outputType as OutputType;
|
|
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
|
|
|
|
// Capture event if recording
|
|
if (isRecording && recordingStartTime) {
|
|
captureEvent(device, actuatorIdx, value);
|
|
}
|
|
}
|
|
|
|
function startRecording() {
|
|
if (devices.length === 0) {
|
|
return;
|
|
}
|
|
isRecording = true;
|
|
recordingStartTime = performance.now();
|
|
recordedEvents = [];
|
|
recordingDuration = 0;
|
|
}
|
|
|
|
function stopRecording() {
|
|
isRecording = false;
|
|
if (recordedEvents.length > 0) {
|
|
recordingDuration = recordedEvents[recordedEvents.length - 1].timestamp;
|
|
showSaveDialog = true;
|
|
}
|
|
}
|
|
|
|
function captureEvent(device: BluetoothDevice, actuatorIdx: number, value: number) {
|
|
if (!recordingStartTime) return;
|
|
|
|
const timestamp = performance.now() - recordingStartTime;
|
|
const actuator = device.actuators[actuatorIdx];
|
|
|
|
recordedEvents.push({
|
|
timestamp,
|
|
deviceIndex: device.info.index,
|
|
deviceName: device.name,
|
|
actuatorIndex: actuatorIdx,
|
|
actuatorType: actuator.outputType,
|
|
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
|
|
});
|
|
}
|
|
|
|
async function handleStop(device: BluetoothDevice) {
|
|
await device.info.stop();
|
|
device.actuators.forEach((a) => (a.value = 0));
|
|
}
|
|
|
|
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
|
|
const actuators: import("$lib/types").DeviceActuator[] = []; // eslint-disable-line @typescript-eslint/consistent-type-imports
|
|
for (const [, feature] of device.features) {
|
|
for (const outputType of feature.outputTypes) {
|
|
actuators.push({
|
|
featureIndex: feature.featureIndex,
|
|
outputType,
|
|
maxSteps: feature.outputMaxValue(outputType),
|
|
descriptor: feature.featureDescriptor,
|
|
value: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: String(device.index),
|
|
name: device.name,
|
|
actuators,
|
|
batteryLevel: 0,
|
|
hasBattery: device.hasInput(InputType.Battery),
|
|
isConnected: true,
|
|
lastSeen: new Date(),
|
|
info: device,
|
|
};
|
|
}
|
|
|
|
async function handleSaveRecording(data: { title: string; description: string; tags: string[] }) {
|
|
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
|
|
name: d.name,
|
|
index: d.info.index,
|
|
capabilities: d.actuators.map((a) => a.outputType),
|
|
}));
|
|
|
|
try {
|
|
const response = await fetch("/api/sexy/recordings", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
title: data.title,
|
|
description: data.description,
|
|
duration: recordingDuration,
|
|
events: recordedEvents,
|
|
device_info: deviceInfo,
|
|
tags: data.tags,
|
|
status: "draft",
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
toast.success("Recording saved successfully!");
|
|
showSaveDialog = false;
|
|
recordedEvents = [];
|
|
recordingDuration = 0;
|
|
|
|
// Optionally navigate to dashboard
|
|
// goto("/me?tab=recordings");
|
|
} catch (error) {
|
|
console.error("Failed to save recording:", error);
|
|
toast.error("Failed to save recording. Please try again.");
|
|
}
|
|
}
|
|
|
|
function handleCancelSave() {
|
|
showSaveDialog = false;
|
|
recordedEvents = [];
|
|
recordingDuration = 0;
|
|
}
|
|
|
|
// Playback functions
|
|
function startPlayback() {
|
|
if (!data.recording) {
|
|
return;
|
|
}
|
|
|
|
if (devices.length === 0) {
|
|
toast.error("Please connect devices before playing recording");
|
|
return;
|
|
}
|
|
|
|
// Check if we need to map devices
|
|
if (deviceMappings.size === 0 && data.recording.device_info.length > 0) {
|
|
showMappingDialog = true;
|
|
return;
|
|
}
|
|
|
|
// Start playback with existing mappings
|
|
beginPlayback();
|
|
}
|
|
|
|
function beginPlayback() {
|
|
isPlaying = true;
|
|
playbackStartTime = performance.now();
|
|
playbackProgress = 0;
|
|
currentEventIndex = 0;
|
|
scheduleNextEvent();
|
|
}
|
|
|
|
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
|
|
deviceMappings = mappings;
|
|
showMappingDialog = false;
|
|
beginPlayback();
|
|
}
|
|
|
|
function handleMappingCancel() {
|
|
showMappingDialog = false;
|
|
}
|
|
|
|
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) {
|
|
// Get mapped device
|
|
const device = deviceMappings.get(event.deviceName);
|
|
if (!device) {
|
|
console.warn(`No device mapping for: ${event.deviceName}`);
|
|
return;
|
|
}
|
|
|
|
// Find matching actuator by type
|
|
const actuator = device.actuators.find((a) => a.outputType === event.actuatorType);
|
|
if (!actuator) {
|
|
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
|
|
return;
|
|
}
|
|
|
|
// Convert normalized value (0-100) back to device scale
|
|
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
|
|
|
|
// Send command to device via feature
|
|
const feature = device.info.features.get(actuator.featureIndex);
|
|
if (feature) {
|
|
const outputType = actuator.outputType as OutputType;
|
|
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
|
|
}
|
|
|
|
// Update UI
|
|
actuator.value = 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(() => {
|
|
if (data.authStatus.authenticated) {
|
|
init();
|
|
return;
|
|
}
|
|
goto("/login");
|
|
});
|
|
</script>
|
|
|
|
<Meta title={$_("play.title")} description={$_("play.description")} />
|
|
|
|
<div
|
|
class="relative min-h-screen bg-gradient-to-br from-background via-primary/5 to-accent/5 overflow-hidden"
|
|
>
|
|
<!-- Global Plasma Background -->
|
|
<div class="absolute inset-0 pointer-events-none">
|
|
<div
|
|
class="absolute top-40 left-1/4 w-80 h-80 bg-gradient-to-r from-primary/16 via-accent/20 to-primary/12 rounded-full blur-3xl animate-blob-slow"
|
|
></div>
|
|
<div
|
|
class="absolute bottom-40 right-1/4 w-96 h-96 bg-gradient-to-r from-accent/16 via-primary/20 to-accent/12 rounded-full blur-3xl animate-blob-slow animation-delay-5000"
|
|
></div>
|
|
<div
|
|
class="absolute top-1/3 right-1/3 w-64 h-64 bg-gradient-to-r from-primary/14 via-accent/18 to-primary/10 rounded-full blur-2xl animate-blob-reverse animation-delay-2500"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="container mx-auto py-20 relative px-4">
|
|
<div class="max-w-4xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="text-center mb-12">
|
|
<h1
|
|
class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent"
|
|
>
|
|
{$_("play.title")}
|
|
</h1>
|
|
<p class="text-lg text-muted-foreground mb-6">
|
|
{$_("play.description")}
|
|
</p>
|
|
<div class="flex justify-center gap-3 mb-10">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
href="/leaderboard"
|
|
class="border-primary/30 hover:bg-primary/10"
|
|
>
|
|
<span class="icon-[ri--trophy-line] w-4 h-4 mr-2"></span>
|
|
{$_("gamification.leaderboard")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
href="/me"
|
|
class="border-primary/30 hover:bg-primary/10"
|
|
>
|
|
<span class="icon-[ri--user-line] w-4 h-4 mr-2"></span>
|
|
{$_("common.my_profile")}
|
|
</Button>
|
|
</div>
|
|
<div class="flex justify-center gap-4 items-center">
|
|
<Button
|
|
size="lg"
|
|
disabled={!connected || scanning}
|
|
onclick={startScanning}
|
|
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
|
>
|
|
{#if scanning}
|
|
<div
|
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
|
></div>
|
|
{$_("play.scanning")}
|
|
{:else}
|
|
{$_("play.scan")}
|
|
{/if}
|
|
</Button>
|
|
|
|
{#if devices.length > 0 && !data.recording}
|
|
{#if !isRecording}
|
|
<Button
|
|
size="lg"
|
|
variant="outline"
|
|
onclick={startRecording}
|
|
class="cursor-pointer border-primary/30 hover:bg-primary/10"
|
|
>
|
|
<span class="icon-[ri--record-circle-line] w-5 h-5 mr-2"></span>
|
|
Start Recording
|
|
</Button>
|
|
{:else}
|
|
<Button
|
|
size="lg"
|
|
onclick={stopRecording}
|
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
<span class="icon-[ri--stop-circle-fill] w-5 h-5 mr-2 animate-pulse"></span>
|
|
Stop Recording ({recordedEvents.length} events)
|
|
</Button>
|
|
{/if}
|
|
{/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">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
{#if devices}
|
|
{#each devices as device (device.name)}
|
|
<DeviceCard
|
|
{device}
|
|
onChange={(scalarIndex, val) => handleChange(device, scalarIndex, val)}
|
|
onStop={() => handleStop(device)}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if devices?.length === 0}
|
|
<div class="text-center py-12">
|
|
<p class="text-muted-foreground text-lg mb-4">
|
|
{$_("play.no_results")}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Recording Save Dialog -->
|
|
<RecordingSaveDialog
|
|
open={showSaveDialog}
|
|
events={recordedEvents}
|
|
deviceInfo={devices.map((d) => ({
|
|
name: d.name,
|
|
index: d.info.index,
|
|
capabilities: d.actuators.map((a) => a.outputType),
|
|
}))}
|
|
duration={recordingDuration}
|
|
onSave={handleSaveRecording}
|
|
onCancel={handleCancelSave}
|
|
/>
|
|
|
|
<!-- Device Mapping Dialog -->
|
|
{#if data.recording}
|
|
<DeviceMappingDialog
|
|
open={showMappingDialog}
|
|
recordedDevices={data.recording.device_info}
|
|
connectedDevices={devices}
|
|
onConfirm={handleMappingConfirm}
|
|
onCancel={handleMappingCancel}
|
|
/>
|
|
{/if}
|
|
</div>
|