From 5dc5b6078f6551e78effcee8884f91686117854f Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sun, 29 Mar 2026 18:04:50 +0200 Subject: [PATCH 1/4] Initial impl --- package.json | 2 +- .../ControlModules/LiveControlModule.svelte | 57 ++++++ .../ControlModules/impl/LiveSlider.svelte | 78 ++++++++ src/lib/state/live-control-state.svelte.ts | 189 ++++++++++++++++++ src/routes/(app)/shockers/own/+page.svelte | 92 +++++++-- 5 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/ControlModules/LiveControlModule.svelte create mode 100644 src/lib/components/ControlModules/impl/LiveSlider.svelte create mode 100644 src/lib/state/live-control-state.svelte.ts diff --git a/package.json b/package.json index a6788814..c9a1bb07 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "volta": { "node": "24.14.0" }, - "packageManager": "pnpm@10.32.1", + "packageManager": "pnpm@10.33.0", "pnpm": { "onlyBuiltDependencies": [ "@tailwindcss/oxide", diff --git a/src/lib/components/ControlModules/LiveControlModule.svelte b/src/lib/components/ControlModules/LiveControlModule.svelte new file mode 100644 index 00000000..bf3c0e5b --- /dev/null +++ b/src/lib/components/ControlModules/LiveControlModule.svelte @@ -0,0 +1,57 @@ + + +
+ +

+ {shocker.name} + +

+ + +
+ {#each types as { type, Icon, label } (type)} + + {/each} +
+ + +
+ +
+
diff --git a/src/lib/components/ControlModules/impl/LiveSlider.svelte b/src/lib/components/ControlModules/impl/LiveSlider.svelte new file mode 100644 index 00000000..328b38eb --- /dev/null +++ b/src/lib/components/ControlModules/impl/LiveSlider.svelte @@ -0,0 +1,78 @@ + + +
+
+ +
+ + +
+ + {intensity}% + +
+
+
diff --git a/src/lib/state/live-control-state.svelte.ts b/src/lib/state/live-control-state.svelte.ts new file mode 100644 index 00000000..462b50ac --- /dev/null +++ b/src/lib/state/live-control-state.svelte.ts @@ -0,0 +1,189 @@ +import { hubManagementV1Api } from '$lib/api'; +import { ControlType } from '$lib/signalr/models/ControlType'; +import { SvelteMap } from 'svelte/reactivity'; +import { toast } from 'svelte-sonner'; + +const TICK_INTERVAL_MS = 100; + +export enum LiveConnectionState { + Disconnected = 0, + Connecting = 1, + Connected = 2, +} + +export class LiveShockerState { + isDragging = $state(false); + intensity = $state(0); + type = $state(ControlType.Vibrate); +} + +export class LiveDeviceConnection { + deviceId: string; + state = $state(LiveConnectionState.Disconnected); + gateway = $state(null); + country = $state(null); + latency = $state(0); + + shockers = new SvelteMap(); + + private ws: WebSocket | null = null; + private tickTimer: ReturnType | null = null; + + constructor(deviceId: string) { + this.deviceId = deviceId; + } + + /** + * Ensure a LiveShockerState exists. Call from $effect or event handler, not template. + */ + ensureShockerState(shockerId: string): void { + if (!this.shockers.has(shockerId)) { + this.shockers.set(shockerId, new LiveShockerState()); + } + } + + /** + * Read-only getter, safe for templates. Returns undefined if not yet initialised. + */ + getShockerState(shockerId: string): LiveShockerState | undefined { + return this.shockers.get(shockerId); + } + + async connect() { + this.disconnect(); + this.state = LiveConnectionState.Connecting; + + try { + const res = await hubManagementV1Api.devicesGetLiveControlGatewayInfo(this.deviceId); + if (!res.data) { + throw new Error('No LCG data returned'); + } + + this.gateway = res.data.gateway; + this.country = res.data.country; + + const ws = new WebSocket(`wss://${this.gateway}/1/ws/live/${this.deviceId}`); + this.ws = ws; + + ws.onopen = () => { + this.state = LiveConnectionState.Connected; + this.startTickLoop(); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.ResponseType) { + case 'Ping': + ws.send( + JSON.stringify({ + RequestType: 'Pong', + Data: { Timestamp: msg.Data.Timestamp }, + }), + ); + break; + case 'LatencyAnnounce': + this.latency = msg.Data.OwnLatency; + break; + } + } catch { + // Ignore malformed messages + } + }; + + ws.onclose = () => { + this.handleDisconnect(); + }; + + ws.onerror = () => { + this.handleDisconnect(); + }; + } catch (error) { + console.error('Failed to connect to LCG:', error); + toast.error('Failed to connect to live control gateway'); + this.handleDisconnect(); + } + } + + disconnect() { + this.stopTickLoop(); + if (this.ws) { + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.close(); + this.ws = null; + } + this.state = LiveConnectionState.Disconnected; + this.latency = 0; + } + + private handleDisconnect() { + this.stopTickLoop(); + this.ws = null; + this.state = LiveConnectionState.Disconnected; + this.latency = 0; + } + + private startTickLoop() { + this.stopTickLoop(); + const tick = () => { + if (this.state !== LiveConnectionState.Connected || !this.ws) return; + + for (const [shockerId, shocker] of this.shockers) { + if (!shocker.isDragging) continue; + + this.ws.send( + JSON.stringify({ + RequestType: 'Frame', + Data: { + Shocker: shockerId, + Intensity: Math.round(shocker.intensity), + Type: ControlType[shocker.type].toLowerCase(), + }, + }), + ); + } + + this.tickTimer = setTimeout(tick, TICK_INTERVAL_MS); + }; + tick(); + } + + private stopTickLoop() { + if (this.tickTimer !== null) { + clearTimeout(this.tickTimer); + this.tickTimer = null; + } + } +} + +/** Map of deviceId → LiveDeviceConnection */ +export const liveConnections = new SvelteMap(); + +/** + * Ensure a LiveDeviceConnection exists for the given device. + * Call this from an $effect or event handler — NOT from a template expression or $derived. + */ +export function ensureLiveConnection(deviceId: string): void { + if (!liveConnections.has(deviceId)) { + liveConnections.set(deviceId, new LiveDeviceConnection(deviceId)); + } +} + +/** + * Get an existing LiveDeviceConnection. Returns undefined if not yet initialised. + * Safe to call from template expressions since it never mutates. + */ +export function getLiveConnection(deviceId: string): LiveDeviceConnection | undefined { + return liveConnections.get(deviceId); +} + +export async function toggleLiveControl(deviceId: string) { + ensureLiveConnection(deviceId); + const conn = liveConnections.get(deviceId)!; + if (conn.state !== LiveConnectionState.Disconnected) { + conn.disconnect(); + } else { + await conn.connect(); + } +} diff --git a/src/routes/(app)/shockers/own/+page.svelte b/src/routes/(app)/shockers/own/+page.svelte index a54afd08..2e6b6173 100644 --- a/src/routes/(app)/shockers/own/+page.svelte +++ b/src/routes/(app)/shockers/own/+page.svelte @@ -1,5 +1,5 @@
+ {#if shocker.isPaused} + + {/if}

{shocker.name} diff --git a/src/lib/state/live-control-state.svelte.ts b/src/lib/state/live-control-state.svelte.ts index 75459b88..119698de 100644 --- a/src/lib/state/live-control-state.svelte.ts +++ b/src/lib/state/live-control-state.svelte.ts @@ -15,6 +15,7 @@ export class LiveShockerState { isDragging = $state(false); intensity = $state(0); type = $state(ControlType.Vibrate); + isLive = $state(false); } export class LiveDeviceConnection { @@ -137,6 +138,11 @@ export class LiveDeviceConnection { this.cleanupWebSocket(); this.state = LiveConnectionState.Disconnected; this.latency = 0; + for (const shocker of this.shockers.values()) { + shocker.isLive = false; + shocker.isDragging = false; + shocker.intensity = 0; + } } /** @@ -162,7 +168,7 @@ export class LiveDeviceConnection { if (this.state !== LiveConnectionState.Connected || !this.ws) return; for (const [shockerId, shocker] of this.shockers) { - if (!shocker.isDragging) continue; + if (!shocker.isLive || !shocker.isDragging) continue; this.ws.send( JSON.stringify({ @@ -210,12 +216,22 @@ export function getLiveConnection(deviceId: string): LiveDeviceConnection | unde return liveConnections.get(deviceId); } -export async function toggleLiveControl(deviceId: string) { +export async function toggleShockerLiveControl(deviceId: string, shockerId: string) { ensureLiveConnection(deviceId); const conn = liveConnections.get(deviceId)!; - if (conn.state !== LiveConnectionState.Disconnected) { - conn.disconnect(); + conn.ensureShockerState(shockerId); + const shockerState = conn.shockers.get(shockerId)!; + + if (shockerState.isLive) { + shockerState.isLive = false; + const anyLive = [...conn.shockers.values()].some((s) => s.isLive); + if (!anyLive && conn.state !== LiveConnectionState.Disconnected) { + conn.disconnect(); + } } else { - await conn.connect(); + shockerState.isLive = true; + if (conn.state === LiveConnectionState.Disconnected) { + await conn.connect(); + } } } diff --git a/src/routes/(app)/shockers/own/+page.svelte b/src/routes/(app)/shockers/own/+page.svelte index cad43f47..86b4951f 100644 --- a/src/routes/(app)/shockers/own/+page.svelte +++ b/src/routes/(app)/shockers/own/+page.svelte @@ -35,14 +35,20 @@ getLiveConnection, liveConnections, LiveConnectionState, - toggleLiveControl, + toggleShockerLiveControl, } from '$lib/state/live-control-state.svelte'; + import { LocalStorageState } from '$lib/state/classes/local-storage-state.svelte'; import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; registerBreadcrumbs(() => [{ label: 'Shockers' }]); let shockers = $derived(Array.from(ownHubs).flatMap(([, hub]) => hub.shockers)); + let flatShockers = $derived( + Array.from(ownHubs).flatMap(([hubId, hub]) => + hub.shockers.map((shocker) => ({ shocker, hubId })) + ) + ); // Eagerly create LiveDeviceConnection and LiveShockerState entries // so template reads never mutate state (Svelte 5 forbids mutation in $derived/templates). @@ -68,6 +74,7 @@ }); let moduleType = $state(ModuleType.ClassicControlModule); + const groupByHub = new LocalStorageState('shockerGroupByHub', false); let loading = $state(true); let refreshing = $state(false); @@ -191,6 +198,13 @@ {/snippet} + - {#if isLive && liveConn} - - {liveConn.gateway} ({liveConn.country}) — {liveConn.latency}ms - + {#snippet shockerCard(shocker: import('$lib/api/internal/v1').ShockerResponse, hubId: string)} + {@const liveConn = getLiveConnection(hubId)} + {@const liveState = liveConn?.getShockerState(shocker.id)} + {@const isShockerLiveActive = (liveState?.isLive ?? false) && liveConn?.state === LiveConnectionState.Connected} + {@const isShockerConnecting = (liveState?.isLive ?? false) && liveConn?.state === LiveConnectionState.Connecting} +
+
+
+ + {#if isShockerLiveActive && liveConn} + + {liveConn.gateway} ({liveConn.country}) — {liveConn.latency}ms + + {/if} +
+ {#if isShockerLiveActive && liveState && liveConn} + + {:else if moduleType === ModuleType.ClassicControlModule} + + {:else if moduleType === ModuleType.RichControlModule} + + {:else if moduleType === ModuleType.SimpleControlModule} + + {:else} +

Unknown module type

+ {/if} +

+ {/snippet} - -
- {#each hub.shockers as shocker (shocker.id)} - {#if isLive && liveConn} - {@const liveState = liveConn.getShockerState(shocker.id)} - {#if liveState} - - {/if} - {:else if moduleType === ModuleType.ClassicControlModule} - - {:else if moduleType === ModuleType.RichControlModule} - - {:else if moduleType === ModuleType.SimpleControlModule} - - {:else} -

Unknown module type

- {/if} - {/each} + {#if groupByHub.value} +
+ {#each Array.from(ownHubs) as [hubId, hub] (hubId)} + {@const online = onlineHubs.get(hubId)?.isOnline ?? false} +
+
+ {hub.name} + +
+
+ {#each hub.shockers as shocker (shocker.id)} + {@render shockerCard(shocker, hubId)} + {/each} +
-
- {/each} -
+ {/each} + + {:else} +
+ {#each flatShockers as { shocker, hubId } (shocker.id)} + {@render shockerCard(shocker, hubId)} + {/each} +
+ {/if} {/if} {/if}