diff --git a/src/lib/components/ControlModules/LiveControlModule.svelte b/src/lib/components/ControlModules/LiveControlModule.svelte
new file mode 100644
index 00000000..bf34009a
--- /dev/null
+++ b/src/lib/components/ControlModules/LiveControlModule.svelte
@@ -0,0 +1,96 @@
+
+
+
+ {#if shocker.isPaused}
+
+ {/if}
+
+
+ {shocker.name}
+
+
+
+
+
+ {#each types as { type, Icon, label } (type)}
+
+ {/each}
+
+
+
+
+ connection.sendFrame(shocker.id, 0, liveState.type)} />
+
+
diff --git a/src/lib/components/ControlModules/impl/LiveSlider.svelte b/src/lib/components/ControlModules/impl/LiveSlider.svelte
new file mode 100644
index 00000000..4d8fd82f
--- /dev/null
+++ b/src/lib/components/ControlModules/impl/LiveSlider.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+ {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..119698de
--- /dev/null
+++ b/src/lib/state/live-control-state.svelte.ts
@@ -0,0 +1,237 @@
+import { hubManagementV1Api } from '$lib/api';
+import { ControlType } from '$lib/signalr/models/ControlType';
+import { toast } from 'svelte-sonner';
+import { SvelteMap } from 'svelte/reactivity';
+
+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);
+ isLive = $state(false);
+}
+
+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;
+ private connectAttempt = 0;
+
+ 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;
+ const attempt = ++this.connectAttempt;
+
+ try {
+ const res = await hubManagementV1Api.devicesGetLiveControlGatewayInfo(this.deviceId);
+ if (attempt !== this.connectAttempt) return; // Stale attempt
+ 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 = () => {
+ if (attempt !== this.connectAttempt) return;
+ this.state = LiveConnectionState.Connected;
+ this.startTickLoop();
+ };
+
+ ws.onmessage = (event) => {
+ if (attempt !== this.connectAttempt) return;
+ 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 = () => {
+ if (attempt !== this.connectAttempt) return;
+ this.handleDisconnect();
+ };
+
+ ws.onerror = () => {
+ if (attempt !== this.connectAttempt) return;
+ this.handleDisconnect();
+ };
+ } catch (error) {
+ if (attempt !== this.connectAttempt) return;
+ console.error('Failed to connect to LCG:', error);
+ toast.error('Failed to connect to live control gateway');
+ this.handleDisconnect();
+ }
+ }
+
+ disconnect() {
+ this.connectAttempt++;
+ this.stopTickLoop();
+ this.cleanupWebSocket();
+ this.state = LiveConnectionState.Disconnected;
+ this.latency = 0;
+ }
+
+ private cleanupWebSocket() {
+ if (this.ws) {
+ this.ws.onopen = null;
+ this.ws.onmessage = null;
+ this.ws.onclose = null;
+ this.ws.onerror = null;
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ private handleDisconnect() {
+ this.stopTickLoop();
+ 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;
+ }
+ }
+
+ /**
+ * Send a single control frame immediately (used for the final zero-intensity on release).
+ */
+ sendFrame(shockerId: string, intensity: number, type: ControlType) {
+ if (this.state !== LiveConnectionState.Connected || !this.ws) return;
+ this.ws.send(
+ JSON.stringify({
+ RequestType: 'Frame',
+ Data: {
+ Shocker: shockerId,
+ Intensity: Math.round(intensity),
+ Type: ControlType[type].toLowerCase(),
+ },
+ })
+ );
+ }
+
+ private startTickLoop() {
+ this.stopTickLoop();
+ const tick = () => {
+ if (this.state !== LiveConnectionState.Connected || !this.ws) return;
+
+ for (const [shockerId, shocker] of this.shockers) {
+ if (!shocker.isLive || !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 toggleShockerLiveControl(deviceId: string, shockerId: string) {
+ ensureLiveConnection(deviceId);
+ const conn = liveConnections.get(deviceId)!;
+ 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 {
+ 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 c4150f43..86b4951f 100644
--- a/src/routes/(app)/shockers/own/+page.svelte
+++ b/src/routes/(app)/shockers/own/+page.svelte
@@ -1,5 +1,14 @@