Files
web-app-launcher/src/lib/components/widget/CameraStreamWidget.svelte
T
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00

258 lines
6.4 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { Maximize2, X, AlertCircle } from 'lucide-svelte';
import type { CameraWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: CameraWidgetConfig;
}
let { config }: Props = $props();
let imgSrc = $state('');
let loading = $state(true);
let error = $state(false);
let fullscreen = $state(false);
let videoEl: HTMLVideoElement | null = $state(null);
let fullscreenVideoEl: HTMLVideoElement | null = $state(null);
const streamType = $derived(config.type ?? 'image');
const refreshMs = $derived((config.refreshInterval ?? 10) * 1000);
const aspectRatio = $derived(config.aspectRatio ?? '16/9');
// For snapshot mode, fetch through our proxy
function buildProxyUrl(): string {
const params = new URLSearchParams({ streamUrl: config.streamUrl });
return `/api/widgets/camera?${params}&t=${Date.now()}`;
}
async function fetchSnapshot() {
error = false;
try {
const url = buildProxyUrl();
// Pre-validate the response
const res = await fetch(url);
if (res.ok) {
const blob = await res.blob();
if (imgSrc) URL.revokeObjectURL(imgSrc);
imgSrc = URL.createObjectURL(blob);
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
if (streamType === 'image') {
fetchSnapshot();
} else if (streamType === 'mjpeg') {
// MJPEG streams directly via img src
imgSrc = config.streamUrl;
loading = false;
} else if (streamType === 'hls') {
loading = false;
// HLS setup via $effect below
}
return () => {
if (imgSrc && streamType === 'image') {
URL.revokeObjectURL(imgSrc);
}
};
});
// Auto-refresh for snapshot mode
$effect(() => {
if (streamType !== 'image') return;
const interval = setInterval(fetchSnapshot, refreshMs);
return () => clearInterval(interval);
});
// HLS.js lazy loading
$effect(() => {
if (streamType !== 'hls' || !videoEl) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hls: any = null;
(async () => {
try {
const { default: Hls } = await import('hls.js');
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(config.streamUrl);
hls.attachMedia(videoEl!);
} else if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
videoEl!.src = config.streamUrl;
} else {
error = true;
}
} catch {
// HLS.js not available — try native
if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
videoEl!.src = config.streamUrl;
} else {
error = true;
}
}
})();
return () => {
if (hls) {
hls.destroy();
}
};
});
// HLS.js for fullscreen video element
$effect(() => {
if (streamType !== 'hls' || !fullscreenVideoEl) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hls: any = null;
(async () => {
try {
const { default: Hls } = await import('hls.js');
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(config.streamUrl);
hls.attachMedia(fullscreenVideoEl!);
} else if (fullscreenVideoEl!.canPlayType('application/vnd.apple.mpegurl')) {
fullscreenVideoEl!.src = config.streamUrl;
}
} catch {
if (fullscreenVideoEl!.canPlayType('application/vnd.apple.mpegurl')) {
fullscreenVideoEl!.src = config.streamUrl;
}
}
})();
return () => {
if (hls) hls.destroy();
};
});
function handleImgError() {
error = true;
loading = false;
}
function handleImgLoad() {
loading = false;
error = false;
}
function openFullscreen() {
fullscreen = true;
}
function closeFullscreen() {
fullscreen = false;
}
</script>
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] overflow-hidden">
<!-- Stream view -->
<div
class="relative w-full bg-black"
style="aspect-ratio: {aspectRatio}"
>
{#if loading}
<div class="absolute inset-0 flex items-center justify-center">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<svg
class="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
</div>
{/if}
{#if error}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-muted/80">
<AlertCircle class="h-8 w-8 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Stream unavailable</span>
</div>
{/if}
{#if streamType === 'hls'}
<video
bind:this={videoEl}
autoplay
muted
playsinline
class="h-full w-full object-contain"
></video>
{:else}
<img
src={imgSrc}
alt="Camera stream"
class="h-full w-full object-contain {loading ? 'opacity-0' : 'opacity-100'} transition-opacity"
onload={handleImgLoad}
onerror={handleImgError}
/>
{/if}
<!-- Fullscreen button overlay -->
{#if !error}
<button
type="button"
onclick={openFullscreen}
class="absolute bottom-2 right-2 rounded-md bg-black/50 p-1.5 text-white/80 transition-colors hover:bg-black/70 hover:text-white"
title="Fullscreen"
>
<Maximize2 class="h-4 w-4" />
</button>
{/if}
</div>
</div>
<!-- Fullscreen modal -->
{#if fullscreen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onclick={closeFullscreen}
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
>
<button
type="button"
onclick={closeFullscreen}
class="absolute right-4 top-4 rounded-md p-2 text-white/80 transition-colors hover:text-white"
>
<X class="h-6 w-6" />
</button>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="max-h-[90vh] max-w-[90vw]" onclick={(e) => e.stopPropagation()}>
{#if streamType === 'hls'}
<!-- eslint-disable-next-line svelte/no-unused-svelte-ignore -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
autoplay
muted
playsinline
controls
class="max-h-[90vh] max-w-[90vw] object-contain"
bind:this={fullscreenVideoEl}
></video>
{:else}
<img
src={imgSrc}
alt="Camera stream fullscreen"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
{/if}
</div>
</div>
{/if}