mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add opt-in portrait and landscape stream overlay artwork (#3183)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user