import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { Clock, Layers, Printer, Timer } from 'lucide-react'; import './UpdatedStreamOverlay.css'; import { overlayProgressTextStyle } from '../utils/overlayBranding'; import type { OverlayLayout } from '../utils/overlayLayout'; interface UpdatedStreamOverlayProps { layout?: OverlayLayout; backgroundTransparency?: number; customLogo?: ReactNode; progressBackground?: string; size: 'small' | 'medium' | 'large'; camera: { url: string; rotation: number; onError: () => void } | null; name: string | null; model: string | null; filename: string | null; status: string | null; state: string | null; progress: number | null; layers: string | null; remaining: string | null; eta: string | null; temperatures: { key: string; icon: ReactNode; label: string; current: number; target: number | null }[]; } // The camera slot's size in px, measured only while the camera is turned // sideways. Measured rather than written as 100cqh/100cqw because container // units need Chromium 105+, which older OBS browser sources don't have, and // the viewport units they would fall back to are wrong for the portrait // layout, where the camera fills only the middle row. function useCameraBox(ref: RefObject, active: boolean) { const [box, setBox] = useState<{ width: number; height: number } | null>(null); // Layout effect: measured before paint, so a sideways camera never shows a // frame at the unswapped size. useLayoutEffect(() => { const el = ref.current; if (!active || !el) return; const measure = () => { // Layout dimensions exclude the scale applied by OverlayFrame. const width = el.clientWidth; const height = el.clientHeight; if (width > 0 && height > 0) { setBox((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height })); } }; measure(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); } const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [ref, active]); return active ? box : null; } // Explicit sources keep their orientation; legacy URLs follow the viewport. function useOverlayOrientation(layout?: OverlayLayout): OverlayLayout { const [portrait, setPortrait] = useState(() => window.matchMedia('(orientation: portrait)').matches); useEffect(() => { if (layout) return; const media = window.matchMedia('(orientation: portrait)'); const update = (event: MediaQueryListEvent) => setPortrait(event.matches); setPortrait(media.matches); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, [layout]); return layout ?? (portrait ? 'portrait' : 'landscape'); } export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) { const { t } = useTranslation(); const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100; const backgroundStyle: CSSProperties & { '--overlay-background-alpha': number; '--overlay-identity-alpha': number; '--overlay-panel-alpha': number; } = { '--overlay-background-alpha': backgroundAlpha, '--overlay-identity-alpha': backgroundAlpha * 0.9, '--overlay-panel-alpha': backgroundAlpha * 0.88, }; useLayoutEffect(() => { if (backgroundAlpha === 1) return; // The app's body background would otherwise fill every transparent pixel // in OBS. Restore it when leaving the overlay or resetting transparency. const previous = document.body.style.backgroundColor; document.body.style.backgroundColor = 'transparent'; return () => { document.body.style.backgroundColor = previous; }; }, [backgroundAlpha]); const layout = useOverlayOrientation(props.layout); const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } = props; const progressTextStyle = overlayProgressTextStyle(props.progressBackground); const hasPanel = filename || status || progress != null || layers || remaining || eta || temperatures.length > 0; const rotation = camera?.rotation ?? 0; const sideways = Math.abs(rotation % 180) === 90; const cameraRef = useRef(null); const cameraBox = useCameraBox(cameraRef, sideways && camera != null); const stats = [ { key: 'layers', icon: , label: t('streamOverlay.layer'), value: layers }, { key: 'remaining', icon: , label: t('streamOverlay.remaining'), value: remaining }, { key: 'eta', icon: , label: t('streamOverlay.eta'), value: eta }, ].filter((stat) => stat.value != null); return (
{(name || model) && (
)}
{props.customLogo} Bambuddy
{camera && (
{t('streamOverlay.cameraStream')}
)} {hasPanel && (
{(filename || status) && (
{filename &&

{filename}

} {status && (
)}
)} {progress != null && (
{t('streamOverlay.progress')}
{Math.round(progress)}%
)} {(stats.length > 0 || temperatures.length > 0) && (
{stats.length > 0 && (
{stats.map((stat) => (
{stat.icon}
{stat.label} {stat.value}
))}
)} {temperatures.length > 0 && (
{temperatures.map((reading) => (
{reading.icon}
{reading.label} {Math.round(reading.current)}°C {reading.target != null && reading.target > 0 && Math.round(reading.target) !== Math.round(reading.current) && ( / {Math.round(reading.target)}°C )}
))}
)}
)}
)}
); }