Skip to content
Merged
87 changes: 55 additions & 32 deletions src/lib/components/ControlModules/LiveButton.svelte
Original file line number Diff line number Diff line change
@@ -1,53 +1,76 @@
<script lang="ts">
import { Loader } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
type LiveDeviceConnection,
type LiveShockerState,
LiveConnectionState,
getLiveConnection,
setHubLiveControl,
toggleShockerLiveControl,
} from '$lib/state/live-control-state.svelte';

interface Props {
hubId: string;
shockerId: string;
isPaused: boolean;
connection?: LiveDeviceConnection;
liveState?: LiveShockerState;
compact?: boolean;
}

let { hubId, shockerId, isPaused, connection, liveState, compact = false }: Props = $props();
let { hubId, shockerId, compact = false }: Props = $props();

let isActive = $derived(
(liveState?.isLive ?? false) && connection?.state === LiveConnectionState.Connected
);
let isConnecting = $derived(
(liveState?.isLive ?? false) && connection?.state === LiveConnectionState.Connecting
let connection = $derived(getLiveConnection(hubId));
let liveState = $derived(connection?.getShockerState(shockerId));

let isLive = $derived(liveState?.isLive ?? false);
let isPaused = $derived(liveState?.isPaused ?? false);
let hubIsLive = $derived(
connection ? [...connection.shockers.values()].some((s) => s.isLive) : false
);
let isDisabled = $derived(isPaused && !(liveState?.isLive ?? false));
let isActive = $derived(isLive && connection?.state === LiveConnectionState.Connected);
let isConnecting = $derived(isLive && connection?.state === LiveConnectionState.Connecting);
let isDisabled = $derived(isPaused && !isLive);

function onClick(event: MouseEvent) {
if (event.ctrlKey || event.metaKey) {
setHubLiveControl(hubId, !hubIsLive);
} else {
toggleShockerLiveControl(hubId, shockerId);
}
Comment thread
hhvrc marked this conversation as resolved.
}
</script>

<div class="flex items-center gap-1.5 {compact ? '' : 'mb-1'}">
<button
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
{isActive
? '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]'
: ''}
{isConnecting ? 'border-muted-foreground text-muted-foreground' : ''}
{isDisabled ? 'pointer-events-none opacity-40' : ''}"
onclick={() => toggleShockerLiveControl(hubId, shockerId)}
disabled={isDisabled}
title={isActive
? `Live — ${connection?.gateway} (${connection?.country}) — ${connection?.latency}ms`
: isConnecting
? 'Connecting...'
: 'Connect to Live Control'}
>
LIVE
{#if isConnecting}
<Loader class="inline size-3 animate-spin" />
{/if}
</button>
<Tooltip.Root>
<Tooltip.Trigger
onclick={onClick}
disabled={isDisabled}
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
{isActive
? '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]'
: ''}
{isConnecting ? 'border-muted-foreground text-muted-foreground' : ''}
{isDisabled ? 'pointer-events-none opacity-40' : ''}"
>
LIVE
{#if isConnecting}
<Loader class="inline size-3 animate-spin" />
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex flex-col gap-0.5 text-xs">
<span>
{#if isActive}
Live — {connection?.gateway} ({connection?.country}) — {connection?.latency}ms
{:else if isConnecting}
Connecting...
{:else}
Connect to Live Control
{/if}
</span>
<span class="text-muted-foreground">
Ctrl+click to {hubIsLive ? 'stop' : 'start'} the whole hub
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
{#if isActive && connection && !compact}
<span class="text-muted-foreground text-[10px]">
{connection.gateway} ({connection.country}) — {connection.latency}ms
Expand Down
107 changes: 100 additions & 7 deletions src/lib/components/ControlModules/impl/LiveSlider.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { LiveShockerState } from '$lib/state/live-control-state.svelte';

interface Props {
Expand All @@ -10,10 +11,99 @@
let { liveState, maxIntensity = 100, onRelease }: Props = $props();

let container: HTMLDivElement | undefined = $state();
let canvas: HTMLCanvasElement | undefined = $state();
let y = $state(1);

let intensity = $derived(Math.round((1 - y) * maxIntensity));

const WINDOW_MS = 3000;
const SAMPLE_INTERVAL_MS = 16;
const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS) + 2;

const SMOOTHING_TAU_MS = 50;

const samples: { t: number; v: number }[] = [];
let smoothed = 0;
let lastFrameAt = 0;
let lastSampleAt = 0;
let rafId = 0;
let strokeColor = '#ffffff';

function pushSample(now: number) {
samples.push({ t: now, v: smoothed });
if (samples.length > MAX_SAMPLES) samples.shift();
lastSampleAt = now;
}

function draw(now: number) {
rafId = requestAnimationFrame(draw);
if (!canvas) return;

const dt = lastFrameAt === 0 ? 0 : now - lastFrameAt;
lastFrameAt = now;
const alpha = 1 - Math.exp(-dt / SMOOTHING_TAU_MS);
smoothed += (liveState.intensity - smoothed) * alpha;

if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) pushSample(now);

const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
const w = Math.round(cssW * dpr);
const h = Math.round(cssH * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}

const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
if (samples.length < 2) return;

const toX = (t: number) => w - ((now - t) / WINDOW_MS) * w;
Comment thread
hhvrc marked this conversation as resolved.
const toY = (v: number) => h - (Math.min(v, maxIntensity) / maxIntensity) * h;

ctx.beginPath();
const first = samples[0];
ctx.moveTo(toX(first.t), toY(first.v));
for (let i = 1; i < samples.length; i++) {
const s = samples[i];
ctx.lineTo(toX(s.t), toY(s.v));
}

const gradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, 'transparent');
gradient.addColorStop(0.4, strokeColor);
gradient.addColorStop(1, strokeColor);

ctx.lineJoin = 'round';
ctx.lineCap = 'round';

// Outer blurred glow pass
ctx.save();
ctx.lineWidth = 8 * dpr;
ctx.strokeStyle = gradient;
ctx.globalAlpha = 0.35;
ctx.filter = `blur(${4 * dpr}px)`;
ctx.stroke();
ctx.restore();

// Crisp inner pass
ctx.lineWidth = 4 * dpr;
ctx.strokeStyle = gradient;
ctx.stroke();
}

onMount(() => {
strokeColor = getComputedStyle(canvas!).color || strokeColor;
smoothed = liveState.intensity;
pushSample(performance.now());
rafId = requestAnimationFrame(draw);
});

onDestroy(() => cancelAnimationFrame(rafId));

function startDrag(event: PointerEvent) {
if (!container) return;
liveState.isDragging = true;
Expand Down Expand Up @@ -72,7 +162,7 @@
<div class="relative h-full w-full p-4 select-none">
<div
bind:this={container}
class="border-border relative h-full w-full cursor-pointer overflow-hidden rounded-md border"
class="relative h-full w-full cursor-pointer"
onpointerdown={startDrag}
onpointermove={onPointerMove}
onpointerup={stopDrag}
Expand All @@ -85,13 +175,16 @@
aria-label="Live intensity"
tabindex="0"
>
<!-- Fill from bottom -->
<div
class="bg-muted pointer-events-none absolute bottom-0 left-0 w-full transition-none"
style="height: {(1 - y) * 100}%"
></div>
<!-- Intensity-history graph: newest on the right, slides smoothly left over time -->
<div class="border-border absolute inset-0 overflow-hidden rounded-md border">
<canvas
bind:this={canvas}
class="text-primary pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
></canvas>
</div>

<!-- Handle -->
<!-- Handle (outside the clipped rectangle so text can overflow) -->
<div
class="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-none
{liveState.isDragging
Expand Down
72 changes: 71 additions & 1 deletion src/lib/state/live-control-state.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { hubManagementV1Api } from '$lib/api';
import { ControlType } from '$lib/signalr/models/ControlType';
import { toast } from 'svelte-sonner';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';

const TICK_INTERVAL_MS = 100;

Expand All @@ -16,6 +16,7 @@ export class LiveShockerState {
intensity = $state(0);
type = $state<ControlType>(ControlType.Vibrate);
isLive = $state(false);
isPaused = $state(false);
}

export class LiveDeviceConnection {
Expand Down Expand Up @@ -44,6 +45,38 @@ export class LiveDeviceConnection {
}
}

/**
* Register the full set of shockers belonging to this hub. Updates pause state for
* existing entries, creates missing ones, and removes entries no longer present.
* Call from $effect or event handler.
*/
registerHubShockers(shockers: { id: string; isPaused: boolean }[]): void {
const ids = new SvelteSet(shockers.map((s) => s.id));
for (const s of shockers) {
let state = this.shockers.get(s.id);
if (!state) {
state = new LiveShockerState();
this.shockers.set(s.id, state);
}
state.isPaused = s.isPaused;
}
let removedLive = false;
for (const [id, state] of this.shockers) {
if (ids.has(id)) continue;
if (state.isLive) removedLive = true;
state.isLive = false;
state.isDragging = false;
state.intensity = 0;
this.shockers.delete(id);
}
if (removedLive) {
const anyLive = [...this.shockers.values()].some((s) => s.isLive);
if (!anyLive && this.state !== LiveConnectionState.Disconnected) {
this.disconnect();
}
}
}

/**
* Read-only getter, safe for templates. Returns undefined if not yet initialised.
*/
Expand Down Expand Up @@ -216,6 +249,43 @@ export function getLiveConnection(deviceId: string): LiveDeviceConnection | unde
return liveConnections.get(deviceId);
}

/**
* Register a hub's shockers with the live-control state store. Idempotent.
*/
export function registerHubShockers(
deviceId: string,
shockers: { id: string; isPaused: boolean }[]
): void {
ensureLiveConnection(deviceId);
liveConnections.get(deviceId)!.registerHubShockers(shockers);
}

/**
* Start or stop live control for every registered shocker in the hub at once.
* When starting, paused shockers are skipped. When stopping, every shocker is stopped.
*/
export async function setHubLiveControl(deviceId: string, isLive: boolean) {
const conn = liveConnections.get(deviceId);
if (!conn) return;

if (isLive) {
for (const state of conn.shockers.values()) {
if (!state.isPaused) state.isLive = true;
}
const anyLive = [...conn.shockers.values()].some((x) => x.isLive);
if (anyLive && conn.state === LiveConnectionState.Disconnected) {
await conn.connect();
}
} else {
for (const state of conn.shockers.values()) {
state.isLive = false;
}
if (conn.state !== LiveConnectionState.Disconnected) {
conn.disconnect();
}
}
}

export async function toggleShockerLiveControl(deviceId: string, shockerId: string) {
ensureLiveConnection(deviceId);
const conn = liveConnections.get(deviceId)!;
Expand Down
Loading
Loading