5dcadd1c20
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/.
258 lines
6.4 KiB
Svelte
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}
|