Files
sexy/packages/frontend/src/routes/play/components/device-mapping-dialog.svelte
Valknar XXX a959186de7 fix: proper state binding for Select component in device mapping
- Add separate selectedValues state map for Select component binding
- Update handleDeviceSelect to manage both mappings and selectedValues
- Bind currentSelected directly to Select.Root selected prop
- Pass full selected object in onSelectedChange callback
- Ensures Select component properly reflects user selections in Svelte 5
2025-10-28 05:48:25 +01:00

192 lines
6.2 KiB
Svelte

<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());
// Selected values for each device (for Select component binding)
let selectedValues = $state<Map<string, { value: string; label: string }>>(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>();
const newSelectedValues = new Map<string, { value: string; label: string }>();
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);
newSelectedValues.set(recordedDevice.name, { value: match.id, label: match.name });
}
});
mappings = newMappings;
selectedValues = newSelectedValues;
}
});
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, selected: { value: string; label: string } | undefined) {
if (!selected?.value) return;
const device = connectedDevices.find(d => d.id === selected.value);
if (device) {
const newMappings = new Map(mappings);
newMappings.set(recordedDeviceName, device);
mappings = newMappings;
const newSelectedValues = new Map(selectedValues);
newSelectedValues.set(recordedDeviceName, selected);
selectedValues = newSelectedValues;
}
}
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 currentSelected = selectedValues.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={currentSelected}
onSelectedChange={(selected) => {
handleDeviceSelect(recordedDevice.name, selected);
}}
>
<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>