Skip to content

Commit 5dc5b60

Browse files
committed
Initial impl
1 parent 4e1679f commit 5dc5b60

5 files changed

Lines changed: 404 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"volta": {
8181
"node": "24.14.0"
8282
},
83-
"packageManager": "pnpm@10.32.1",
83+
"packageManager": "pnpm@10.33.0",
8484
"pnpm": {
8585
"onlyBuiltDependencies": [
8686
"@tailwindcss/oxide",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script lang="ts">
2+
import type { ShockerResponse } from '$lib/api/internal/v1';
3+
import { Volume2, Waves, Zap } from '@lucide/svelte';
4+
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
5+
import { ControlType } from '$lib/signalr/models/ControlType';
6+
import type { LiveShockerState } from '$lib/state/live-control-state.svelte';
7+
import { cn } from '$lib/utils';
8+
import LiveSlider from './impl/LiveSlider.svelte';
9+
import ShockerMenu from './impl/ShockerMenu.svelte';
10+
11+
interface Props {
12+
shocker: ShockerResponse;
13+
liveState: LiveShockerState;
14+
}
15+
16+
let { shocker, liveState }: Props = $props();
17+
18+
const types = [
19+
{ type: ControlType.Sound, Icon: Volume2, label: 'Sound' },
20+
{ type: ControlType.Vibrate, Icon: Waves, label: 'Vibrate' },
21+
{ type: ControlType.Shock, Icon: Zap, label: 'Shock' },
22+
] as const;
23+
24+
const buttonClasses = buttonVariants({ variant: 'secondary', size: 'default' });
25+
</script>
26+
27+
<div
28+
class="border-surface-400-500-token flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
29+
>
30+
<!-- Title -->
31+
<h2 class="flex w-full justify-between px-4 text-center text-lg font-bold">
32+
<span>{shocker.name}</span>
33+
<ShockerMenu {shocker} />
34+
</h2>
35+
36+
<!-- Type Selector -->
37+
<div class="flex w-full gap-2">
38+
{#each types as { type, Icon, label } (type)}
39+
<button
40+
class={cn(buttonClasses, 'flex-1', {
41+
'border-primary bg-primary/20': liveState.type === type,
42+
})}
43+
title={label}
44+
aria-label={label}
45+
aria-pressed={liveState.type === type}
46+
onclick={() => (liveState.type = type)}
47+
>
48+
<Icon />
49+
</button>
50+
{/each}
51+
</div>
52+
53+
<!-- Live Slider -->
54+
<div class="h-[200px] w-full">
55+
<LiveSlider {liveState} />
56+
</div>
57+
</div>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script lang="ts">
2+
import type { LiveShockerState } from '$lib/state/live-control-state.svelte';
3+
4+
interface Props {
5+
liveState: LiveShockerState;
6+
maxIntensity?: number;
7+
}
8+
9+
let { liveState, maxIntensity = 100 }: Props = $props();
10+
11+
let container: HTMLDivElement | undefined = $state();
12+
let y = $state(1);
13+
14+
let intensity = $derived(Math.round((1 - y) * maxIntensity));
15+
16+
function startDrag(event: PointerEvent) {
17+
if (!container) return;
18+
liveState.isDragging = true;
19+
container.setPointerCapture(event.pointerId);
20+
updatePosition(event);
21+
}
22+
23+
function onPointerMove(event: PointerEvent) {
24+
if (!liveState.isDragging || !container) return;
25+
updatePosition(event);
26+
}
27+
28+
function stopDrag() {
29+
liveState.isDragging = false;
30+
y = 1;
31+
liveState.intensity = 0;
32+
}
33+
34+
function updatePosition(event: PointerEvent) {
35+
if (!container) return;
36+
const rect = container.getBoundingClientRect();
37+
y = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height));
38+
liveState.intensity = intensity;
39+
}
40+
</script>
41+
42+
<div class="relative h-full w-full select-none p-4">
43+
<div
44+
bind:this={container}
45+
class="relative h-full w-full cursor-pointer overflow-hidden rounded-md border border-border"
46+
onpointerdown={startDrag}
47+
onpointermove={onPointerMove}
48+
onpointerup={stopDrag}
49+
onpointercancel={stopDrag}
50+
role="slider"
51+
aria-valuenow={intensity}
52+
aria-valuemin={0}
53+
aria-valuemax={maxIntensity}
54+
aria-label="Live intensity"
55+
tabindex="0"
56+
>
57+
<!-- Fill from bottom -->
58+
<div
59+
class="pointer-events-none absolute bottom-0 left-0 w-full bg-muted transition-none"
60+
style="height: {(1 - y) * 100}%"
61+
></div>
62+
63+
<!-- Handle -->
64+
<div
65+
class="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-none
66+
{liveState.isDragging
67+
? 'h-12 w-12 border border-border bg-primary'
68+
: 'h-10 w-10 border-2 border-transparent bg-primary'}"
69+
style="left: 50%; top: {y * 100}%"
70+
>
71+
<span
72+
class="flex h-full items-center justify-center text-sm font-medium text-primary-foreground"
73+
>
74+
{intensity}%
75+
</span>
76+
</div>
77+
</div>
78+
</div>
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { hubManagementV1Api } from '$lib/api';
2+
import { ControlType } from '$lib/signalr/models/ControlType';
3+
import { SvelteMap } from 'svelte/reactivity';
4+
import { toast } from 'svelte-sonner';
5+
6+
const TICK_INTERVAL_MS = 100;
7+
8+
export enum LiveConnectionState {
9+
Disconnected = 0,
10+
Connecting = 1,
11+
Connected = 2,
12+
}
13+
14+
export class LiveShockerState {
15+
isDragging = $state(false);
16+
intensity = $state(0);
17+
type = $state<ControlType>(ControlType.Vibrate);
18+
}
19+
20+
export class LiveDeviceConnection {
21+
deviceId: string;
22+
state = $state<LiveConnectionState>(LiveConnectionState.Disconnected);
23+
gateway = $state<string | null>(null);
24+
country = $state<string | null>(null);
25+
latency = $state(0);
26+
27+
shockers = new SvelteMap<string, LiveShockerState>();
28+
29+
private ws: WebSocket | null = null;
30+
private tickTimer: ReturnType<typeof setTimeout> | null = null;
31+
32+
constructor(deviceId: string) {
33+
this.deviceId = deviceId;
34+
}
35+
36+
/**
37+
* Ensure a LiveShockerState exists. Call from $effect or event handler, not template.
38+
*/
39+
ensureShockerState(shockerId: string): void {
40+
if (!this.shockers.has(shockerId)) {
41+
this.shockers.set(shockerId, new LiveShockerState());
42+
}
43+
}
44+
45+
/**
46+
* Read-only getter, safe for templates. Returns undefined if not yet initialised.
47+
*/
48+
getShockerState(shockerId: string): LiveShockerState | undefined {
49+
return this.shockers.get(shockerId);
50+
}
51+
52+
async connect() {
53+
this.disconnect();
54+
this.state = LiveConnectionState.Connecting;
55+
56+
try {
57+
const res = await hubManagementV1Api.devicesGetLiveControlGatewayInfo(this.deviceId);
58+
if (!res.data) {
59+
throw new Error('No LCG data returned');
60+
}
61+
62+
this.gateway = res.data.gateway;
63+
this.country = res.data.country;
64+
65+
const ws = new WebSocket(`wss://${this.gateway}/1/ws/live/${this.deviceId}`);
66+
this.ws = ws;
67+
68+
ws.onopen = () => {
69+
this.state = LiveConnectionState.Connected;
70+
this.startTickLoop();
71+
};
72+
73+
ws.onmessage = (event) => {
74+
try {
75+
const msg = JSON.parse(event.data);
76+
switch (msg.ResponseType) {
77+
case 'Ping':
78+
ws.send(
79+
JSON.stringify({
80+
RequestType: 'Pong',
81+
Data: { Timestamp: msg.Data.Timestamp },
82+
}),
83+
);
84+
break;
85+
case 'LatencyAnnounce':
86+
this.latency = msg.Data.OwnLatency;
87+
break;
88+
}
89+
} catch {
90+
// Ignore malformed messages
91+
}
92+
};
93+
94+
ws.onclose = () => {
95+
this.handleDisconnect();
96+
};
97+
98+
ws.onerror = () => {
99+
this.handleDisconnect();
100+
};
101+
} catch (error) {
102+
console.error('Failed to connect to LCG:', error);
103+
toast.error('Failed to connect to live control gateway');
104+
this.handleDisconnect();
105+
}
106+
}
107+
108+
disconnect() {
109+
this.stopTickLoop();
110+
if (this.ws) {
111+
this.ws.onclose = null;
112+
this.ws.onerror = null;
113+
this.ws.close();
114+
this.ws = null;
115+
}
116+
this.state = LiveConnectionState.Disconnected;
117+
this.latency = 0;
118+
}
119+
120+
private handleDisconnect() {
121+
this.stopTickLoop();
122+
this.ws = null;
123+
this.state = LiveConnectionState.Disconnected;
124+
this.latency = 0;
125+
}
126+
127+
private startTickLoop() {
128+
this.stopTickLoop();
129+
const tick = () => {
130+
if (this.state !== LiveConnectionState.Connected || !this.ws) return;
131+
132+
for (const [shockerId, shocker] of this.shockers) {
133+
if (!shocker.isDragging) continue;
134+
135+
this.ws.send(
136+
JSON.stringify({
137+
RequestType: 'Frame',
138+
Data: {
139+
Shocker: shockerId,
140+
Intensity: Math.round(shocker.intensity),
141+
Type: ControlType[shocker.type].toLowerCase(),
142+
},
143+
}),
144+
);
145+
}
146+
147+
this.tickTimer = setTimeout(tick, TICK_INTERVAL_MS);
148+
};
149+
tick();
150+
}
151+
152+
private stopTickLoop() {
153+
if (this.tickTimer !== null) {
154+
clearTimeout(this.tickTimer);
155+
this.tickTimer = null;
156+
}
157+
}
158+
}
159+
160+
/** Map of deviceId → LiveDeviceConnection */
161+
export const liveConnections = new SvelteMap<string, LiveDeviceConnection>();
162+
163+
/**
164+
* Ensure a LiveDeviceConnection exists for the given device.
165+
* Call this from an $effect or event handler — NOT from a template expression or $derived.
166+
*/
167+
export function ensureLiveConnection(deviceId: string): void {
168+
if (!liveConnections.has(deviceId)) {
169+
liveConnections.set(deviceId, new LiveDeviceConnection(deviceId));
170+
}
171+
}
172+
173+
/**
174+
* Get an existing LiveDeviceConnection. Returns undefined if not yet initialised.
175+
* Safe to call from template expressions since it never mutates.
176+
*/
177+
export function getLiveConnection(deviceId: string): LiveDeviceConnection | undefined {
178+
return liveConnections.get(deviceId);
179+
}
180+
181+
export async function toggleLiveControl(deviceId: string) {
182+
ensureLiveConnection(deviceId);
183+
const conn = liveConnections.get(deviceId)!;
184+
if (conn.state !== LiveConnectionState.Disconnected) {
185+
conn.disconnect();
186+
} else {
187+
await conn.connect();
188+
}
189+
}

0 commit comments

Comments
 (0)