mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
streams when one viewer closes
1) Offline tiles now show OFF (not LIVE)
CameraWall.modeByPrinter assigned 'live' to any visible printer
without considering status.connected, so a disconnected X1C wasted
a live-budget slot AND rendered the red LIVE chip on top of the
WifiOff placeholder. Disconnected printers now map to 'paused' and
don't decrement liveBudget — the existing WifiOff + Off chip
rendering takes over.
2) /camera/stop no longer kills other viewers' streams
The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
all subscribe to the same fan-out broadcaster for a printer.
/camera/stop used to unconditionally shutdown_broadcaster() + kill
every ffmpeg process for the printer, so closing the embedded viewer
while the cam-wall tile of the same printer was live force-killed
the source the tile was pulling from — the tile's <img> errored.
New get_subscriber_count(key) accessor in camera_fanout.py exposes
the broadcaster's subscriber list length. /camera/stop now reads
that first; when >= 1 subscriber is still attached, return
{stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
processes alone. The leaving viewer's HTTP teardown still runs the
natural iter_subscriber.finally -> unsubscribe path, so its slot is
released; the broadcaster keeps serving the other viewers. Single-
viewer close still hits the immediate force-teardown (count is 0).
249 lines
8.5 KiB
TypeScript
249 lines
8.5 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
|
|
import { getAuthToken, withStreamToken } from '../api/client';
|
|
import { formatDuration } from '../utils/date';
|
|
|
|
export type CameraTileMode = 'live' | 'snapshot' | 'paused';
|
|
export type CameraTileStatusMode = 'off' | 'compact' | 'full';
|
|
|
|
interface CameraTileProps {
|
|
printerId: number;
|
|
printerName: string;
|
|
cameraRotation?: number;
|
|
mode: CameraTileMode;
|
|
snapshotIntervalMs: number;
|
|
connected: boolean;
|
|
onClick?: () => void;
|
|
// Optional status overlay — wired by CameraWall from the shared
|
|
// ['printerStatus', id] query. All optional so existing tests don't break.
|
|
statusMode?: CameraTileStatusMode;
|
|
printerState?: string | null;
|
|
progress?: number | null;
|
|
remainingMin?: number | null;
|
|
layerNum?: number | null;
|
|
totalLayers?: number | null;
|
|
printName?: string | null;
|
|
hmsErrorCount?: number;
|
|
}
|
|
|
|
// Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
|
|
// no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
|
|
// still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
|
|
const LIVE_FPS = 8;
|
|
|
|
type StatusBucket = 'printing' | 'paused' | 'finished' | 'error' | 'idle';
|
|
|
|
function classifyState(state: string | null | undefined, hmsErrorCount: number): StatusBucket {
|
|
if (hmsErrorCount > 0) return 'error';
|
|
switch (state) {
|
|
case 'RUNNING':
|
|
return 'printing';
|
|
case 'PAUSE':
|
|
return 'paused';
|
|
case 'FINISH':
|
|
case 'FAILED':
|
|
return 'finished';
|
|
default:
|
|
return 'idle';
|
|
}
|
|
}
|
|
|
|
const BUCKET_CHIP_CLASS: Record<StatusBucket, string> = {
|
|
printing: 'bg-bambu-green/85 text-black',
|
|
paused: 'bg-amber-500/85 text-black',
|
|
finished: 'bg-sky-500/80 text-white',
|
|
error: 'bg-red-500/85 text-white',
|
|
idle: 'bg-bambu-dark-tertiary/80 text-bambu-gray',
|
|
};
|
|
|
|
export function CameraTile({
|
|
printerId,
|
|
printerName,
|
|
cameraRotation = 0,
|
|
mode,
|
|
snapshotIntervalMs,
|
|
connected,
|
|
onClick,
|
|
statusMode = 'off',
|
|
printerState = null,
|
|
progress = null,
|
|
remainingMin = null,
|
|
layerNum = null,
|
|
totalLayers = null,
|
|
printName = null,
|
|
hmsErrorCount = 0,
|
|
}: CameraTileProps) {
|
|
const { t } = useTranslation();
|
|
const [bust, setBust] = useState(0);
|
|
const [errored, setErrored] = useState(false);
|
|
const lastModeRef = useRef<CameraTileMode>(mode);
|
|
|
|
// Tell the backend to release its MJPEG transcoder when this tile stops
|
|
// being live — either by unmounting or by transitioning to snapshot/paused.
|
|
// EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
|
|
useEffect(() => {
|
|
const wasLive = lastModeRef.current === 'live';
|
|
const isLive = mode === 'live';
|
|
lastModeRef.current = mode;
|
|
if (wasLive && !isLive) {
|
|
const headers: Record<string, string> = {};
|
|
const token = getAuthToken();
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
fetch(`/api/v1/printers/${printerId}/camera/stop`, {
|
|
method: 'POST',
|
|
keepalive: true,
|
|
headers,
|
|
}).catch(() => {});
|
|
}
|
|
setErrored(false);
|
|
setBust((b) => b + 1);
|
|
}, [mode, printerId]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (lastModeRef.current === 'live') {
|
|
const headers: Record<string, string> = {};
|
|
const token = getAuthToken();
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
fetch(`/api/v1/printers/${printerId}/camera/stop`, {
|
|
method: 'POST',
|
|
keepalive: true,
|
|
headers,
|
|
}).catch(() => {});
|
|
}
|
|
};
|
|
}, [printerId]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== 'snapshot') return;
|
|
const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
|
|
return () => clearInterval(interval);
|
|
}, [mode, snapshotIntervalMs]);
|
|
|
|
const liveUrl = withStreamToken(
|
|
`/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
|
|
);
|
|
const snapshotUrl = withStreamToken(
|
|
`/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
|
|
);
|
|
|
|
const handleClick = () => {
|
|
if (onClick) onClick();
|
|
};
|
|
|
|
const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
|
|
|
|
const bucket = classifyState(printerState, hmsErrorCount);
|
|
// Hide chip for idle to keep cold walls clean; always show when something
|
|
// is happening (printing/paused/finished/error).
|
|
const showChip = connected && statusMode !== 'off' && bucket !== 'idle';
|
|
const isPrintingOrPaused = bucket === 'printing' || bucket === 'paused';
|
|
const showInfoStrip = connected && statusMode === 'full' && isPrintingOrPaused;
|
|
const fileLabel = printName ?? null;
|
|
const progressPct = progress != null ? Math.round(progress) : null;
|
|
const hasLayers = layerNum != null && totalLayers != null && totalLayers > 0;
|
|
const hasRemaining = remainingMin != null && remainingMin > 0;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
className="group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left focus:outline-none focus:ring-2 focus:ring-bambu-green"
|
|
title={printerName}
|
|
>
|
|
{!connected || mode === 'paused' ? (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
|
|
{connected ? (
|
|
<VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
|
|
) : (
|
|
<WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
) : errored ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
|
|
<VideoOff className="h-7 w-7" aria-hidden="true" />
|
|
<span className="text-xs">{t('printers.camWall.noSignal')}</span>
|
|
</div>
|
|
) : (
|
|
<img
|
|
key={`${mode}-${bust}`}
|
|
src={mode === 'live' ? liveUrl : snapshotUrl}
|
|
alt={printerName}
|
|
draggable={false}
|
|
loading="lazy"
|
|
className="h-full w-full select-none object-contain"
|
|
style={{ transform }}
|
|
onError={() => setErrored(true)}
|
|
/>
|
|
)}
|
|
|
|
{/* Status chip (top-left) */}
|
|
{showChip && (
|
|
<span
|
|
className={`absolute left-2 top-2 flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${BUCKET_CHIP_CLASS[bucket]}`}
|
|
>
|
|
{hmsErrorCount > 0 && (
|
|
<AlertTriangle
|
|
className="h-3 w-3"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<span>{t(`printers.status.${bucket}`)}</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* Mode indicator (top-right) */}
|
|
<span
|
|
className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
|
|
mode === 'live'
|
|
? 'bg-red-500/80 text-white'
|
|
: mode === 'snapshot'
|
|
? 'bg-amber-500/70 text-black'
|
|
: 'bg-bambu-dark-tertiary/70 text-bambu-gray'
|
|
}`}
|
|
>
|
|
{mode === 'live'
|
|
? t('printers.camWall.live')
|
|
: mode === 'snapshot'
|
|
? t('printers.camWall.snap')
|
|
: t('printers.camWall.off')}
|
|
</span>
|
|
|
|
{/* Bottom overlay: name + (when full) print info */}
|
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-2 pb-1.5 pt-3 text-white">
|
|
{showInfoStrip && (
|
|
<div className="mb-0.5 space-y-0.5 text-[11px] leading-tight text-white/90">
|
|
{fileLabel && (
|
|
<div className="truncate" title={fileLabel}>
|
|
{fileLabel}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-bambu-gray">
|
|
{progressPct != null && (
|
|
<span className="font-semibold text-white">{progressPct}%</span>
|
|
)}
|
|
{hasLayers && (
|
|
<span>
|
|
{t('printers.camWall.layer', {
|
|
cur: layerNum,
|
|
total: totalLayers,
|
|
})}
|
|
</span>
|
|
)}
|
|
{hasRemaining && (
|
|
<span>
|
|
{t('printers.camWall.timeLeft', {
|
|
time: formatDuration((remainingMin ?? 0) * 60),
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<span className="block truncate text-xs font-medium">{printerName}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|