Add opt-in portrait and landscape stream overlay artwork (#3183)

This commit is contained in:
Adam Spice
2026-09-30 12:11:25 +02:00
committed by GitHub
parent 8b5d8e3170
commit 8781edffb7
23 changed files with 644 additions and 1 deletions
@@ -47,6 +47,9 @@ export function StreamOverlayBuilder() {
const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
const [size, setSize] = useState<OverlaySize>('medium');
const [fps, setFps] = useState(DEFAULT_FPS);
// '1' is the original overlay; the renderer is picked by version, not by a
// name like "updated" that stops being true once there's a newer one.
const [artwork, setArtwork] = useState<'1' | '2'>('1');
const [showCamera, setShowCamera] = useState(true);
const [token, setToken] = useState('');
const [preview, setPreview] = useState(false);
@@ -80,10 +83,11 @@ export function StreamOverlayBuilder() {
params.set('show', selected.join(','));
if (size !== 'medium') params.set('size', size);
if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
if (artwork !== '1') params.set('artwork', artwork);
if (!showCamera) params.set('camera', 'false');
if (token.trim()) params.set('token', token.trim());
return `${window.location.origin}/overlay/${id}?${params.toString()}`;
}, [printerId, fields, size, fps, showCamera, token]);
}, [printerId, fields, size, fps, showCamera, token, artwork]);
const toggleField = (key: string) => {
setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
@@ -162,6 +166,21 @@ export function StreamOverlayBuilder() {
</select>
</div>
<div>
<label htmlFor="overlay-builder-artwork" className="block text-sm font-medium text-white mb-1">
{t('streamOverlay.builder.artwork', 'Artwork')}
</label>
<select
id="overlay-builder-artwork"
value={artwork}
onChange={(e) => setArtwork(e.target.value as '1' | '2')}
className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
>
<option value="1">{t('streamOverlay.builder.artworkClassic', 'Classic')}</option>
<option value="2">{t('streamOverlay.builder.artworkV2', 'Version 2')}</option>
</select>
</div>
<div>
<label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
{t('streamOverlay.builder.fps', 'Frame rate')}
@@ -0,0 +1,245 @@
/* Scoped to the opt-in renderer. Existing OBS sources retain their styling. */
.updated-overlay {
--overlay-scale: 1;
--overlay-space: clamp(12px, 2.5vmin, 32px);
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
color: #fff;
background: #10171b;
font-size: calc(clamp(14px, 2.2vmin, 28px) * var(--overlay-scale));
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
.updated-overlay[data-size='small'] {
--overlay-scale: 0.8;
}
.updated-overlay[data-size='large'] {
--overlay-scale: 1.2;
}
.updated-overlay__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--overlay-space);
padding: var(--overlay-space);
z-index: 1;
}
.updated-overlay__identity {
display: flex;
align-items: center;
gap: 0.7em;
min-width: 0;
padding: 0.65em 0.9em;
border-radius: 0.7em;
background: rgb(12 19 23 / 90%);
}
.updated-overlay__identity svg {
flex-shrink: 0;
width: 2em;
height: 2em;
}
.updated-overlay__identity div {
min-width: 0;
overflow-wrap: anywhere;
}
.updated-overlay__identity strong {
display: block;
font-size: 1.35em;
}
.updated-overlay__identity span {
display: block;
color: #b8c0c7;
}
.updated-overlay__logo {
flex-shrink: 0;
margin-left: auto;
}
.updated-overlay__logo img {
width: clamp(100px, 19vmin, 230px);
height: auto;
}
.updated-overlay__camera {
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
}
.updated-overlay__camera img {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
}
.updated-overlay__panel {
padding: var(--overlay-space);
background: rgb(12 19 23 / 88%);
}
.updated-overlay__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.8em;
}
.updated-overlay__summary h1 {
font-size: 1.65em;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
min-width: 0;
}
.updated-overlay__status {
display: flex;
align-items: center;
gap: 0.5em;
font-size: 1.15em;
font-weight: 600;
color: #b8c0c7;
}
.updated-overlay__status > span {
width: 0.75em;
height: 0.75em;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.updated-overlay[data-state='RUNNING'] .updated-overlay__status,
.updated-overlay[data-state='FINISH'] .updated-overlay__status {
color: #00d76a;
}
.updated-overlay[data-state='PAUSE'] .updated-overlay__status {
color: #fbbf24;
}
.updated-overlay[data-state='FAILED'] .updated-overlay__status {
color: #f87171;
}
.updated-overlay__progress {
display: flex;
align-items: center;
gap: 1em;
}
.updated-overlay__progress > span {
color: #b8c0c7;
font-size: 0.8em;
text-transform: uppercase;
}
.updated-overlay__progress > strong {
color: #00d76a;
font-size: 1.4em;
}
.updated-overlay__progress > div {
flex: 1;
height: 0.65em;
border-radius: 1em;
overflow: hidden;
background: #3c454c;
}
.updated-overlay__progress > div > div {
height: 100%;
border-radius: inherit;
background: #00d76a;
transition: width 0.5s;
}
.updated-overlay__readings {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin-top: 0.8em;
padding-top: 0.8em;
border-top: 1px solid #ffffff35;
}
.updated-overlay__stats,
.updated-overlay__temperatures {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
gap: 1em;
}
.updated-overlay__reading {
display: flex;
align-items: center;
gap: 0.6em;
flex: 1 1 auto;
min-width: 0;
}
.updated-overlay__reading svg {
width: 1.8em;
height: 1.8em;
flex-shrink: 0;
}
.updated-overlay__reading > div > span {
display: block;
color: #b8c0c7;
font-size: 0.7em;
text-transform: uppercase;
}
.updated-overlay__reading strong {
display: block;
font-size: 1.3em;
overflow-wrap: anywhere;
}
.updated-overlay__reading small {
font-size: 0.65em;
color: #b8c0c7;
}
@media (orientation: portrait) {
.updated-overlay {
display: grid;
font-size: calc(clamp(14px, 3vmin, 36px) * var(--overlay-scale));
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
.updated-overlay__header {
grid-row: 1;
align-items: center;
background: #10171b;
}
.updated-overlay__identity {
background: none;
padding: 0;
}
.updated-overlay__camera {
position: relative;
grid-row: 2;
min-height: 30vh;
z-index: 0;
}
.updated-overlay__panel {
grid-row: 3;
background: #10171b;
}
.updated-overlay__summary {
flex-direction: column-reverse;
align-items: stretch;
gap: 0.65em;
}
.updated-overlay__progress {
flex-wrap: wrap;
justify-content: space-between;
gap: 0.3em;
}
.updated-overlay__progress > div {
flex: 0 0 100%;
order: 1;
}
.updated-overlay__readings {
flex-direction: column;
}
.updated-overlay__temperatures:not(:first-child) {
border-top: 1px solid #ffffff35;
padding-top: 0.8em;
}
}
@media (prefers-reduced-motion: reduce) {
.updated-overlay__progress > div > div {
transition: none;
}
}
@@ -0,0 +1,173 @@
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Clock, Layers, Printer, Timer } from 'lucide-react';
import './UpdatedStreamOverlay.css';
interface UpdatedStreamOverlayProps {
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<HTMLDivElement | null>, 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 = () => {
const { width, height } = el.getBoundingClientRect();
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;
}
export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
const { t } = useTranslation();
const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
props;
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<HTMLDivElement>(null);
const cameraBox = useCameraBox(cameraRef, sideways && camera != null);
const stats = [
{ key: 'layers', icon: <Layers />, label: t('streamOverlay.layer'), value: layers },
{ key: 'remaining', icon: <Timer />, label: t('streamOverlay.remaining'), value: remaining },
{ key: 'eta', icon: <Clock />, label: t('streamOverlay.eta'), value: eta },
].filter((stat) => stat.value != null);
return (
<div className="updated-overlay" data-size={props.size} data-state={state}>
<header className="updated-overlay__header">
{(name || model) && (
<div className="updated-overlay__identity">
<Printer aria-hidden="true" />
<div>
{name && <strong>{name}</strong>}
{model && <span>{model}</span>}
</div>
</div>
)}
<a
className="updated-overlay__logo"
href="https://github.com/maziggy/bambuddy"
target="_blank"
rel="noopener noreferrer"
>
<img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
</a>
</header>
{camera && (
<div className="updated-overlay__camera" ref={cameraRef}>
<img
key={camera.url}
src={camera.url}
alt={t('streamOverlay.cameraStream')}
style={{
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
// A quarter turn swaps the image's axes, so give it the slot's
// height as width and vice versa; cover then still fills the slot.
...(sideways && cameraBox ? { width: cameraBox.height, height: cameraBox.width } : {}),
}}
onError={camera.onError}
/>
</div>
)}
{hasPanel && (
<section className="updated-overlay__panel">
{(filename || status) && (
<div className="updated-overlay__summary">
{filename && <h1>{filename}</h1>}
{status && (
<div className="updated-overlay__status">
<span aria-hidden="true" />
{status}
</div>
)}
</div>
)}
{progress != null && (
<div className="updated-overlay__progress">
<span>{t('streamOverlay.progress')}</span>
<div
role="progressbar"
aria-label={t('streamOverlay.progress')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div style={{ width: `${progress}%` }} />
</div>
<strong>{Math.round(progress)}%</strong>
</div>
)}
{(stats.length > 0 || temperatures.length > 0) && (
<div className="updated-overlay__readings">
{stats.length > 0 && (
<div className="updated-overlay__stats">
{stats.map((stat) => (
<div className="updated-overlay__reading" key={stat.key}>
{stat.icon}
<div>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
</div>
</div>
))}
</div>
)}
{temperatures.length > 0 && (
<div className="updated-overlay__temperatures">
{temperatures.map((reading) => (
<div className="updated-overlay__reading" key={reading.key}>
{reading.icon}
<div>
<span>{reading.label}</span>
<strong>
{Math.round(reading.current)}°C
{reading.target != null &&
reading.target > 0 &&
Math.round(reading.target) !== Math.round(reading.current) && (
<small> / {Math.round(reading.target)}°C</small>
)}
</strong>
</div>
</div>
))}
</div>
)}
</div>
)}
</section>
)}
</div>
);
}