Skip to content

Commit e9bbf5a

Browse files
authored
feat: Add hub-wide live toggle and real-time intensity graph (#201)
1 parent e3f2584 commit e9bbf5a

6 files changed

Lines changed: 269 additions & 113 deletions

File tree

src/lib/components/ControlModules/LiveButton.svelte

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,76 @@
11
<script lang="ts">
22
import { Loader } from '@lucide/svelte';
3+
import * as Tooltip from '$lib/components/ui/tooltip';
34
import {
4-
type LiveDeviceConnection,
5-
type LiveShockerState,
65
LiveConnectionState,
6+
getLiveConnection,
7+
setHubLiveControl,
78
toggleShockerLiveControl,
89
} from '$lib/state/live-control-state.svelte';
910
1011
interface Props {
1112
hubId: string;
1213
shockerId: string;
13-
isPaused: boolean;
14-
connection?: LiveDeviceConnection;
15-
liveState?: LiveShockerState;
1614
compact?: boolean;
1715
}
1816
19-
let { hubId, shockerId, isPaused, connection, liveState, compact = false }: Props = $props();
17+
let { hubId, shockerId, compact = false }: Props = $props();
2018
21-
let isActive = $derived(
22-
(liveState?.isLive ?? false) && connection?.state === LiveConnectionState.Connected
23-
);
24-
let isConnecting = $derived(
25-
(liveState?.isLive ?? false) && connection?.state === LiveConnectionState.Connecting
19+
let connection = $derived(getLiveConnection(hubId));
20+
let liveState = $derived(connection?.getShockerState(shockerId));
21+
22+
let isLive = $derived(liveState?.isLive ?? false);
23+
let isPaused = $derived(liveState?.isPaused ?? false);
24+
let hubIsLive = $derived(
25+
connection ? [...connection.shockers.values()].some((s) => s.isLive) : false
2626
);
27-
let isDisabled = $derived(isPaused && !(liveState?.isLive ?? false));
27+
let isActive = $derived(isLive && connection?.state === LiveConnectionState.Connected);
28+
let isConnecting = $derived(isLive && connection?.state === LiveConnectionState.Connecting);
29+
let isDisabled = $derived(isPaused && !isLive);
30+
31+
function onClick(event: MouseEvent) {
32+
if (event.ctrlKey || event.metaKey) {
33+
setHubLiveControl(hubId, !hubIsLive);
34+
} else {
35+
toggleShockerLiveControl(hubId, shockerId);
36+
}
37+
}
2838
</script>
2939

3040
<div class="flex items-center gap-1.5 {compact ? '' : 'mb-1'}">
31-
<button
32-
class="border-border text-muted-foreground hover:border-foreground hover:text-foreground cursor-pointer rounded border bg-transparent px-1.5 py-px text-[10px] font-bold tracking-wider transition-all duration-200
33-
{isActive
34-
? 'bg-linear-to-r from-[rgb(185,123,255)] to-[#e100ff] bg-clip-text text-transparent [border-image:linear-gradient(to_right,rgb(167,89,255),#e100ff)_1]'
35-
: ''}
36-
{isConnecting ? 'border-muted-foreground text-muted-foreground' : ''}
37-
{isDisabled ? 'pointer-events-none opacity-40' : ''}"
38-
onclick={() => toggleShockerLiveControl(hubId, shockerId)}
39-
disabled={isDisabled}
40-
title={isActive
41-
? `Live — ${connection?.gateway} (${connection?.country}) — ${connection?.latency}ms`
42-
: isConnecting
43-
? 'Connecting...'
44-
: 'Connect to Live Control'}
45-
>
46-
LIVE
47-
{#if isConnecting}
48-
<Loader class="inline size-3 animate-spin" />
49-
{/if}
50-
</button>
41+
<Tooltip.Root>
42+
<Tooltip.Trigger
43+
onclick={onClick}
44+
disabled={isDisabled}
45+
class="border-border text-muted-foreground hover:border-foreground hover:text-foreground cursor-pointer rounded border bg-transparent px-1.5 py-px text-[10px] font-bold tracking-wider transition-all duration-200
46+
{isActive
47+
? 'bg-linear-to-r from-[rgb(185,123,255)] to-[#e100ff] bg-clip-text text-transparent [border-image:linear-gradient(to_right,rgb(167,89,255),#e100ff)_1]'
48+
: ''}
49+
{isConnecting ? 'border-muted-foreground text-muted-foreground' : ''}
50+
{isDisabled ? 'pointer-events-none opacity-40' : ''}"
51+
>
52+
LIVE
53+
{#if isConnecting}
54+
<Loader class="inline size-3 animate-spin" />
55+
{/if}
56+
</Tooltip.Trigger>
57+
<Tooltip.Content>
58+
<div class="flex flex-col gap-0.5 text-xs">
59+
<span>
60+
{#if isActive}
61+
Live — {connection?.gateway} ({connection?.country}) — {connection?.latency}ms
62+
{:else if isConnecting}
63+
Connecting...
64+
{:else}
65+
Connect to Live Control
66+
{/if}
67+
</span>
68+
<span class="text-muted-foreground">
69+
Ctrl+click to {hubIsLive ? 'stop' : 'start'} the whole hub
70+
</span>
71+
</div>
72+
</Tooltip.Content>
73+
</Tooltip.Root>
5174
{#if isActive && connection && !compact}
5275
<span class="text-muted-foreground text-[10px]">
5376
{connection.gateway} ({connection.country}) — {connection.latency}ms

src/lib/components/ControlModules/impl/LiveSlider.svelte

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { onDestroy, onMount } from 'svelte';
23
import type { LiveShockerState } from '$lib/state/live-control-state.svelte';
34
45
interface Props {
@@ -10,10 +11,99 @@
1011
let { liveState, maxIntensity = 100, onRelease }: Props = $props();
1112
1213
let container: HTMLDivElement | undefined = $state();
14+
let canvas: HTMLCanvasElement | undefined = $state();
1315
let y = $state(1);
1416
1517
let intensity = $derived(Math.round((1 - y) * maxIntensity));
1618
19+
const WINDOW_MS = 3000;
20+
const SAMPLE_INTERVAL_MS = 16;
21+
const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS) + 2;
22+
23+
const SMOOTHING_TAU_MS = 50;
24+
25+
const samples: { t: number; v: number }[] = [];
26+
let smoothed = 0;
27+
let lastFrameAt = 0;
28+
let lastSampleAt = 0;
29+
let rafId = 0;
30+
let strokeColor = '#ffffff';
31+
32+
function pushSample(now: number) {
33+
samples.push({ t: now, v: smoothed });
34+
if (samples.length > MAX_SAMPLES) samples.shift();
35+
lastSampleAt = now;
36+
}
37+
38+
function draw(now: number) {
39+
rafId = requestAnimationFrame(draw);
40+
if (!canvas) return;
41+
42+
const dt = lastFrameAt === 0 ? 0 : now - lastFrameAt;
43+
lastFrameAt = now;
44+
const alpha = 1 - Math.exp(-dt / SMOOTHING_TAU_MS);
45+
smoothed += (liveState.intensity - smoothed) * alpha;
46+
47+
if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) pushSample(now);
48+
49+
const dpr = window.devicePixelRatio || 1;
50+
const cssW = canvas.clientWidth;
51+
const cssH = canvas.clientHeight;
52+
const w = Math.round(cssW * dpr);
53+
const h = Math.round(cssH * dpr);
54+
if (canvas.width !== w || canvas.height !== h) {
55+
canvas.width = w;
56+
canvas.height = h;
57+
}
58+
59+
const ctx = canvas.getContext('2d');
60+
if (!ctx) return;
61+
ctx.clearRect(0, 0, w, h);
62+
if (samples.length < 2) return;
63+
64+
const toX = (t: number) => w - ((now - t) / WINDOW_MS) * w;
65+
const toY = (v: number) => h - (Math.min(v, maxIntensity) / maxIntensity) * h;
66+
67+
ctx.beginPath();
68+
const first = samples[0];
69+
ctx.moveTo(toX(first.t), toY(first.v));
70+
for (let i = 1; i < samples.length; i++) {
71+
const s = samples[i];
72+
ctx.lineTo(toX(s.t), toY(s.v));
73+
}
74+
75+
const gradient = ctx.createLinearGradient(0, 0, w, 0);
76+
gradient.addColorStop(0, 'transparent');
77+
gradient.addColorStop(0.4, strokeColor);
78+
gradient.addColorStop(1, strokeColor);
79+
80+
ctx.lineJoin = 'round';
81+
ctx.lineCap = 'round';
82+
83+
// Outer blurred glow pass
84+
ctx.save();
85+
ctx.lineWidth = 8 * dpr;
86+
ctx.strokeStyle = gradient;
87+
ctx.globalAlpha = 0.35;
88+
ctx.filter = `blur(${4 * dpr}px)`;
89+
ctx.stroke();
90+
ctx.restore();
91+
92+
// Crisp inner pass
93+
ctx.lineWidth = 4 * dpr;
94+
ctx.strokeStyle = gradient;
95+
ctx.stroke();
96+
}
97+
98+
onMount(() => {
99+
strokeColor = getComputedStyle(canvas!).color || strokeColor;
100+
smoothed = liveState.intensity;
101+
pushSample(performance.now());
102+
rafId = requestAnimationFrame(draw);
103+
});
104+
105+
onDestroy(() => cancelAnimationFrame(rafId));
106+
17107
function startDrag(event: PointerEvent) {
18108
if (!container) return;
19109
liveState.isDragging = true;
@@ -72,7 +162,7 @@
72162
<div class="relative h-full w-full p-4 select-none">
73163
<div
74164
bind:this={container}
75-
class="border-border relative h-full w-full cursor-pointer overflow-hidden rounded-md border"
165+
class="relative h-full w-full cursor-pointer"
76166
onpointerdown={startDrag}
77167
onpointermove={onPointerMove}
78168
onpointerup={stopDrag}
@@ -85,13 +175,16 @@
85175
aria-label="Live intensity"
86176
tabindex="0"
87177
>
88-
<!-- Fill from bottom -->
89-
<div
90-
class="bg-muted pointer-events-none absolute bottom-0 left-0 w-full transition-none"
91-
style="height: {(1 - y) * 100}%"
92-
></div>
178+
<!-- Intensity-history graph: newest on the right, slides smoothly left over time -->
179+
<div class="border-border absolute inset-0 overflow-hidden rounded-md border">
180+
<canvas
181+
bind:this={canvas}
182+
class="text-primary pointer-events-none absolute inset-0 h-full w-full"
183+
aria-hidden="true"
184+
></canvas>
185+
</div>
93186

94-
<!-- Handle -->
187+
<!-- Handle (outside the clipped rectangle so text can overflow) -->
95188
<div
96189
class="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-none
97190
{liveState.isDragging

src/lib/state/live-control-state.svelte.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { devicesGetLiveControlGatewayInfo } from '$lib/api';
22
import { ControlType } from '$lib/signalr/models/ControlType';
33
import { toast } from 'svelte-sonner';
4-
import { SvelteMap } from 'svelte/reactivity';
4+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
55

66
const TICK_INTERVAL_MS = 100;
77

@@ -16,6 +16,7 @@ export class LiveShockerState {
1616
intensity = $state(0);
1717
type = $state<ControlType>(ControlType.Vibrate);
1818
isLive = $state(false);
19+
isPaused = $state(false);
1920
}
2021

2122
export class LiveDeviceConnection {
@@ -44,6 +45,38 @@ export class LiveDeviceConnection {
4445
}
4546
}
4647

48+
/**
49+
* Register the full set of shockers belonging to this hub. Updates pause state for
50+
* existing entries, creates missing ones, and removes entries no longer present.
51+
* Call from $effect or event handler.
52+
*/
53+
registerHubShockers(shockers: { id: string; isPaused: boolean }[]): void {
54+
const ids = new SvelteSet(shockers.map((s) => s.id));
55+
for (const s of shockers) {
56+
let state = this.shockers.get(s.id);
57+
if (!state) {
58+
state = new LiveShockerState();
59+
this.shockers.set(s.id, state);
60+
}
61+
state.isPaused = s.isPaused;
62+
}
63+
let removedLive = false;
64+
for (const [id, state] of this.shockers) {
65+
if (ids.has(id)) continue;
66+
if (state.isLive) removedLive = true;
67+
state.isLive = false;
68+
state.isDragging = false;
69+
state.intensity = 0;
70+
this.shockers.delete(id);
71+
}
72+
if (removedLive) {
73+
const anyLive = [...this.shockers.values()].some((s) => s.isLive);
74+
if (!anyLive && this.state !== LiveConnectionState.Disconnected) {
75+
this.disconnect();
76+
}
77+
}
78+
}
79+
4780
/**
4881
* Read-only getter, safe for templates. Returns undefined if not yet initialised.
4982
*/
@@ -213,6 +246,43 @@ export function getLiveConnection(deviceId: string): LiveDeviceConnection | unde
213246
return liveConnections.get(deviceId);
214247
}
215248

249+
/**
250+
* Register a hub's shockers with the live-control state store. Idempotent.
251+
*/
252+
export function registerHubShockers(
253+
deviceId: string,
254+
shockers: { id: string; isPaused: boolean }[]
255+
): void {
256+
ensureLiveConnection(deviceId);
257+
liveConnections.get(deviceId)!.registerHubShockers(shockers);
258+
}
259+
260+
/**
261+
* Start or stop live control for every registered shocker in the hub at once.
262+
* When starting, paused shockers are skipped. When stopping, every shocker is stopped.
263+
*/
264+
export async function setHubLiveControl(deviceId: string, isLive: boolean) {
265+
const conn = liveConnections.get(deviceId);
266+
if (!conn) return;
267+
268+
if (isLive) {
269+
for (const state of conn.shockers.values()) {
270+
if (!state.isPaused) state.isLive = true;
271+
}
272+
const anyLive = [...conn.shockers.values()].some((x) => x.isLive);
273+
if (anyLive && conn.state === LiveConnectionState.Disconnected) {
274+
await conn.connect();
275+
}
276+
} else {
277+
for (const state of conn.shockers.values()) {
278+
state.isLive = false;
279+
}
280+
if (conn.state !== LiveConnectionState.Disconnected) {
281+
conn.disconnect();
282+
}
283+
}
284+
}
285+
216286
export async function toggleShockerLiveControl(deviceId: string, shockerId: string) {
217287
ensureLiveConnection(deviceId);
218288
const conn = liveConnections.get(deviceId)!;

0 commit comments

Comments
 (0)