Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/lib/components/ControlModules/LiveControlModule.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts">
import type { ShockerResponse } from '$lib/api/internal/v1';
import { LoaderCircle, Pause, Play, Volume2, Waves, Zap } from '@lucide/svelte';
import { shockersV1Api } from '$lib/api';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import { ControlType } from '$lib/signalr/models/ControlType';
import {
type LiveShockerState,
type LiveDeviceConnection,
} from '$lib/state/live-control-state.svelte';
import { cn } from '$lib/utils';
import LiveSlider from './impl/LiveSlider.svelte';
import ShockerMenu from './impl/ShockerMenu.svelte';

interface Props {
shocker: ShockerResponse;
liveState: LiveShockerState;
connection: LiveDeviceConnection;
}

let { shocker, liveState, connection }: Props = $props();

let resuming = $state(false);

async function resume() {
resuming = true;
try {
const result = await shockersV1Api.shockerPauseShocker(shocker.id, { pause: false });
shocker.isPaused = result.data;
} catch (error) {
handleApiError(error);
} finally {
resuming = false;
}
}

const types = [
{ type: ControlType.Sound, Icon: Volume2, label: 'Sound' },
{ type: ControlType.Vibrate, Icon: Waves, label: 'Vibrate' },
{ type: ControlType.Shock, Icon: Zap, label: 'Shock' },
] as const;

const buttonClasses = buttonVariants({ variant: 'secondary', size: 'default' });
</script>

<div
class="border-surface-400-500-token relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-md border p-2"
>
{#if shocker.isPaused}
<button
class="group absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md bg-black/60 backdrop-blur-sm transition-colors hover:bg-black/50"
onclick={resume}
disabled={resuming}
>
{#if resuming}
<LoaderCircle class="size-8 animate-spin text-white" />
{:else}
<Pause class="size-8 text-white/60 group-hover:hidden" />
<Play class="hidden size-8 text-white group-hover:block" />
{/if}
<span class="text-sm font-semibold text-white">
{#if resuming}Resuming...{:else}<span class="group-hover:hidden">Paused</span><span
class="hidden group-hover:inline">Resume</span
>{/if}
</span>
</button>
{/if}
<!-- Title -->
<h2 class="flex w-full justify-between px-4 text-center text-lg font-bold">
<span>{shocker.name}</span>
<ShockerMenu {shocker} />
</h2>

<!-- Type Selector -->
<div class="flex w-full gap-2">
{#each types as { type, Icon, label } (type)}
<button
class={cn(buttonClasses, 'flex-1', {
'border-primary bg-primary/20': liveState.type === type,
})}
title={label}
aria-label={label}
aria-pressed={liveState.type === type}
onclick={() => (liveState.type = type)}
>
<Icon />
</button>
{/each}
</div>

<!-- Live Slider -->
<div class="h-[200px] w-full">
<LiveSlider {liveState} onRelease={() => connection.sendFrame(shocker.id, 0, liveState.type)} />
</div>
</div>
109 changes: 109 additions & 0 deletions src/lib/components/ControlModules/impl/LiveSlider.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script lang="ts">
import type { LiveShockerState } from '$lib/state/live-control-state.svelte';

interface Props {
liveState: LiveShockerState;
maxIntensity?: number;
onRelease?: () => void;
}

let { liveState, maxIntensity = 100, onRelease }: Props = $props();

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

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

function startDrag(event: PointerEvent) {
if (!container) return;
liveState.isDragging = true;
container.setPointerCapture(event.pointerId);
updatePosition(event);
}

function onPointerMove(event: PointerEvent) {
if (!liveState.isDragging || !container) return;
updatePosition(event);
}

function stopDrag() {
liveState.isDragging = false;
y = 1;
liveState.intensity = 0;
Comment on lines +30 to +32
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user releases the slider, stopDrag() sets liveState.intensity = 0, but the gateway send loop only transmits frames while liveState.isDragging is true. This means no message is sent at release time, so the last non-zero intensity frame may remain effective until the server times out. Consider sending an explicit final frame with intensity 0 (and/or ControlType.Stop) on pointerup/cancel.

Suggested change
liveState.isDragging = false;
y = 1;
liveState.intensity = 0;
y = 1;
liveState.intensity = 0;
liveState.isDragging = false;

Copilot uses AI. Check for mistakes.
onRelease?.();
}

function updatePosition(event: PointerEvent) {
if (!container) return;
const rect = container.getBoundingClientRect();
y = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height));
liveState.intensity = intensity;
}

const STEP = 0.05;

function onKeydown(event: KeyboardEvent) {
let handled = true;
switch (event.key) {
case 'ArrowUp':
case 'ArrowRight':
y = Math.max(0, y - STEP);
break;
case 'ArrowDown':
case 'ArrowLeft':
y = Math.min(1, y + STEP);
break;
case 'Home':
y = 0;
break;
case 'End':
y = 1;
break;
default:
handled = false;
}
if (handled) {
event.preventDefault();
liveState.intensity = intensity;
}
}
</script>

<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"
onpointerdown={startDrag}
onpointermove={onPointerMove}
onpointerup={stopDrag}
onpointercancel={stopDrag}
onkeydown={onKeydown}
role="slider"
aria-valuenow={intensity}
aria-valuemin={0}
aria-valuemax={maxIntensity}
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>

<!-- Handle -->
<div
class="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-none
{liveState.isDragging
? 'border-border bg-primary h-12 w-12 border'
: 'bg-primary h-10 w-10 border-2 border-transparent'}"
style="left: 50%; top: {y * 100}%"
>
<span
class="text-primary-foreground flex h-full items-center justify-center text-sm font-medium"
>
{intensity}%
</span>
</div>
</div>
</div>
Loading
Loading