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:
@@ -20,6 +20,7 @@ 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";
|
||||
|
||||
@@ -41,6 +42,8 @@ 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();
|
||||
@@ -237,11 +240,26 @@ function handleCancelSave() {
|
||||
|
||||
// Playback functions
|
||||
function startPlayback() {
|
||||
if (!data.recording || devices.length === 0) {
|
||||
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;
|
||||
@@ -249,6 +267,16 @@ function startPlayback() {
|
||||
scheduleNextEvent();
|
||||
}
|
||||
|
||||
function handleMappingConfirm(mappings: Map<string, BluetoothDevice>) {
|
||||
deviceMappings = mappings;
|
||||
showMappingDialog = false;
|
||||
beginPlayback();
|
||||
}
|
||||
|
||||
function handleMappingCancel() {
|
||||
showMappingDialog = false;
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
isPlaying = false;
|
||||
if (playbackTimeoutId !== null) {
|
||||
@@ -309,19 +337,19 @@ function scheduleNextEvent() {
|
||||
}
|
||||
|
||||
function executeEvent(event: RecordedEvent) {
|
||||
// Find matching device by name
|
||||
const device = devices.find(d => d.name === event.deviceName);
|
||||
// Get mapped device
|
||||
const device = deviceMappings.get(event.deviceName);
|
||||
if (!device) {
|
||||
console.warn(`Device not found: ${event.deviceName}`);
|
||||
console.warn(`No device mapping for: ${event.deviceName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching actuator
|
||||
// Find matching actuator by type
|
||||
const scalarCmd = device.info.messageAttributes.ScalarCmd.find(
|
||||
cmd => cmd.ActuatorType === event.actuatorType
|
||||
);
|
||||
if (!scalarCmd) {
|
||||
console.warn(`Actuator not found: ${event.actuatorType} on ${device.name}`);
|
||||
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -576,4 +604,15 @@ onMount(() => {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user