feat: add device mapping UI for playback compatibility

- Create DeviceMappingDialog component with compatibility checking
- Check actuator type compatibility between recorded and connected devices
- Auto-map devices by name with fallback to compatible devices
- Show device mapping dialog before playback starts
- Store and use device mappings during playback execution
- Update executeEvent to use mapped devices instead of name matching
- Validate all devices are mapped before starting playback

Features:
- Visual device pairing interface
- Compatibility badges showing actuator types
- Exact name match highlighting
- Auto-mapping with smart fallback
- Real-time mapping validation

🤖 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:41:37 +01:00
parent 50ceda94b7
commit 9d9f72dec1
2 changed files with 224 additions and 6 deletions

View File

@@ -20,6 +20,7 @@ import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import DeviceCard from "$lib/components/device-card/device-card.svelte"; import DeviceCard from "$lib/components/device-card/device-card.svelte";
import RecordingSaveDialog from "./components/recording-save-dialog.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 type { BluetoothDevice, RecordedEvent, DeviceInfo } from "$lib/types";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
@@ -41,6 +42,8 @@ let playbackProgress = $state(0);
let playbackStartTime = $state<number | null>(null); let playbackStartTime = $state<number | null>(null);
let playbackTimeoutId = $state<number | null>(null); let playbackTimeoutId = $state<number | null>(null);
let currentEventIndex = $state(0); let currentEventIndex = $state(0);
let showMappingDialog = $state(false);
let deviceMappings = $state<Map<string, BluetoothDevice>>(new Map());
async function init() { async function init() {
const connector = new ButtplugWasmClientConnector(); const connector = new ButtplugWasmClientConnector();
@@ -237,11 +240,26 @@ function handleCancelSave() {
// Playback functions // Playback functions
function startPlayback() { function startPlayback() {
if (!data.recording || devices.length === 0) { if (!data.recording) {
return;
}
if (devices.length === 0) {
toast.error("Please connect devices before playing recording"); toast.error("Please connect devices before playing recording");
return; 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; isPlaying = true;
playbackStartTime = performance.now(); playbackStartTime = performance.now();
playbackProgress = 0; playbackProgress = 0;
@@ -249,6 +267,16 @@ function startPlayback() {
scheduleNextEvent(); scheduleNextEvent();
} }
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
deviceMappings = mappings;
showMappingDialog = false;
beginPlayback();
}
function handleMappingCancel() {
showMappingDialog = false;
}
function stopPlayback() { function stopPlayback() {
isPlaying = false; isPlaying = false;
if (playbackTimeoutId !== null) { if (playbackTimeoutId !== null) {
@@ -309,19 +337,19 @@ function scheduleNextEvent() {
} }
function executeEvent(event: RecordedEvent) { function executeEvent(event: RecordedEvent) {
// Find matching device by name // Get mapped device
const device = devices.find(d => d.name === event.deviceName); const device = deviceMappings.get(event.deviceName);
if (!device) { if (!device) {
console.warn(`Device not found: ${event.deviceName}`); console.warn(`No device mapping for: ${event.deviceName}`);
return; return;
} }
// Find matching actuator // Find matching actuator by type
const scalarCmd = device.info.messageAttributes.ScalarCmd.find( const scalarCmd = device.info.messageAttributes.ScalarCmd.find(
cmd => cmd.ActuatorType === event.actuatorType cmd => cmd.ActuatorType === event.actuatorType
); );
if (!scalarCmd) { if (!scalarCmd) {
console.warn(`Actuator not found: ${event.actuatorType} on ${device.name}`); console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
return; return;
} }
@@ -576,4 +604,15 @@ onMount(() => {
onSave={handleSaveRecording} onSave={handleSaveRecording}
onCancel={handleCancelSave} onCancel={handleCancelSave}
/> />
<!-- Device Mapping Dialog -->
{#if data.recording}
<DeviceMappingDialog
open={showMappingDialog}
recordedDevices={data.recording.device_info}
connectedDevices={devices}
onConfirm={handleMappingConfirm}
onCancel={handleMappingCancel}
/>
{/if}
</div> </div>

View File

@@ -0,0 +1,179 @@
<script lang="ts">
import { _ } from "svelte-i18n";
import * as Dialog from "$lib/components/ui/dialog";
import * as Select from "$lib/components/ui/select";
import Button from "$lib/components/ui/button/button.svelte";
import type { BluetoothDevice, DeviceInfo } from "$lib/types";
interface Props {
open: boolean;
recordedDevices: DeviceInfo[];
connectedDevices: BluetoothDevice[];
onConfirm: (mappings: Map<string, BluetoothDevice>) => void;
onCancel: () => void;
}
let { open, recordedDevices, connectedDevices, onConfirm, onCancel }: Props = $props();
// Device mappings: recorded device name -> connected device
let mappings = $state<Map<string, BluetoothDevice>>(new Map());
// Check if a connected device is compatible with a recorded device
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
const connectedActuators = connectedDevice.info.messageAttributes.ScalarCmd.map(
cmd => cmd.ActuatorType
);
// Check if all required actuator types from recording exist on connected device
return recordedDevice.capabilities.every(requiredType =>
connectedActuators.includes(requiredType)
);
}
// Get compatible devices for a recorded device
function getCompatibleDevices(recordedDevice: DeviceInfo): BluetoothDevice[] {
return connectedDevices.filter(device => isCompatible(recordedDevice, device));
}
// Auto-map devices on open
$effect(() => {
if (open && recordedDevices.length > 0 && connectedDevices.length > 0) {
const newMappings = new Map<string, BluetoothDevice>();
recordedDevices.forEach(recordedDevice => {
// Try to find exact name match first
let match = connectedDevices.find(d => d.name === recordedDevice.name);
// If no exact match, find first compatible device
if (!match) {
const compatible = getCompatibleDevices(recordedDevice);
if (compatible.length > 0) {
match = compatible[0];
}
}
if (match) {
newMappings.set(recordedDevice.name, match);
}
});
mappings = newMappings;
}
});
function handleConfirm() {
// Validate that all devices are mapped
const allMapped = recordedDevices.every(rd => mappings.has(rd.name));
if (!allMapped) {
return;
}
onConfirm(mappings);
}
function handleDeviceSelect(recordedDeviceName: string, selectedDeviceId: string) {
const device = connectedDevices.find(d => d.id === selectedDeviceId);
if (device) {
mappings.set(recordedDeviceName, device);
}
}
const allDevicesMapped = $derived(
recordedDevices.every(rd => mappings.has(rd.name))
);
</script>
<Dialog.Root {open}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Map Devices for Playback</Dialog.Title>
<Dialog.Description>
Assign your connected devices to match the recorded devices. Only compatible devices are shown.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-4">
{#each recordedDevices as recordedDevice}
{@const compatibleDevices = getCompatibleDevices(recordedDevice)}
{@const currentMapping = mappings.get(recordedDevice.name)}
<div class="flex items-center gap-4 p-4 bg-muted/30 rounded-lg border border-border/50">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="icon-[ri--router-line] w-5 h-5 text-primary"></span>
<h3 class="font-semibold text-card-foreground">{recordedDevice.name}</h3>
</div>
<div class="flex flex-wrap gap-1">
{#each recordedDevice.capabilities as capability}
<span class="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20">
{capability}
</span>
{/each}
</div>
</div>
<div class="w-px h-12 bg-border"></div>
<div class="flex-1">
{#if compatibleDevices.length === 0}
<div class="flex items-center gap-2 text-destructive">
<span class="icon-[ri--error-warning-line] w-5 h-5"></span>
<span class="text-sm">No compatible devices</span>
</div>
{:else}
<Select.Root
selected={{ value: currentMapping?.id || '', label: currentMapping?.name || 'Select device...' }}
onSelectedChange={(selected) => {
if (selected?.value) {
handleDeviceSelect(recordedDevice.name, selected.value);
}
}}
>
<Select.Trigger class="w-full">
<Select.Value placeholder="Select device..." />
</Select.Trigger>
<Select.Content>
{#each compatibleDevices as device}
<Select.Item value={device.id} label={device.name}>
<div class="flex items-center gap-2">
<span class="icon-[ri--bluetooth-line] w-4 h-4"></span>
<span>{device.name}</span>
{#if device.name === recordedDevice.name}
<span class="icon-[ri--checkbox-circle-fill] w-4 h-4 text-green-500"></span>
{/if}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
</div>
</div>
{/each}
{#if recordedDevices.length === 0}
<div class="text-center py-8 text-muted-foreground">
No devices in this recording
</div>
{/if}
</div>
<Dialog.Footer class="flex gap-2">
<Button variant="outline" onclick={onCancel} class="cursor-pointer">
Cancel
</Button>
<Button
onclick={handleConfirm}
disabled={!allDevicesMapped}
class="cursor-pointer bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
>
{#if !allDevicesMapped}
<span class="icon-[ri--error-warning-line] w-4 h-4 mr-2"></span>
Map All Devices
{:else}
<span class="icon-[ri--play-fill] w-4 h-4 mr-2"></span>
Start Playback
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>