mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
Add opt-in portrait and landscape stream overlay artwork (#3183)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -176,6 +176,7 @@ const DE_COGNATES = [
|
||||
'Diagnose', // DE: same spelling/meaning as EN — camera diagnostic button label
|
||||
'{{filament}} @ {{temp}}°C', // drying badge: filament code + universal °C
|
||||
'Score', // #1546 AI detection modal — established DE loanword (Duden)
|
||||
'Version 2', // stream overlay artwork picker (#3177) — same word in German
|
||||
];
|
||||
|
||||
// French cognates — many UI labels overlap with English exactly.
|
||||
@@ -225,6 +226,7 @@ const FR_COGNATES = [
|
||||
'Simple', 'Expert', // slicer settings visibility tiers — identical words in French
|
||||
'Support', // same word in French
|
||||
'Photos', '{{count}} photo', '{{count}} photos', // file details photo strip (#3077) — same word in French
|
||||
'Version 2', // stream overlay artwork picker (#3177) — same word in French
|
||||
];
|
||||
|
||||
// Italian cognates.
|
||||
@@ -393,6 +395,7 @@ const SV_COGNATES = [
|
||||
'Port', '(System)', 'Autologin', 'Process', 'Filament {{n}}', 'Region', 'Global', 'Normal', 'Version', 'max {{n}}', 'Expert', 'Filament {{index}} ({{type}})', 'Material:',
|
||||
'(Inv)', 'Original', 'Commit', 'Extruder', 'Gradient', 'Proxy', 'Metadata', '{{count}} filament', 'Temp', 'Min', 'ntfy, Pushover, Discord, etc.', 'Hex: #{{hex}}', 'Designer',
|
||||
'Prefix', 'Trend', 'max(global {{global}}d, SKU {{sku}}d)', 'Slicer',
|
||||
'Version 2', // stream overlay artwork picker (#3177) — same word in Swedish
|
||||
];
|
||||
|
||||
// Turkish cognates — technical UI labels that Turkish speakers use verbatim
|
||||
|
||||
@@ -68,6 +68,22 @@ describe('StreamOverlayBuilder', () => {
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl);
|
||||
});
|
||||
|
||||
it('opts into artwork in the URL and preview and restores the original URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StreamOverlayBuilder />);
|
||||
const artwork = await screen.findByLabelText('Artwork');
|
||||
expect(artwork).toHaveValue('1');
|
||||
const original = shownUrl();
|
||||
expect(new URL(original).searchParams.has('artwork')).toBe(false);
|
||||
await user.selectOptions(artwork, 'Version 2');
|
||||
expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
|
||||
await user.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
|
||||
await user.selectOptions(artwork, 'Classic');
|
||||
expect(shownUrl()).toBe(original);
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original);
|
||||
});
|
||||
|
||||
it('switches printer', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StreamOverlayBuilder />);
|
||||
|
||||
@@ -106,6 +106,95 @@ describe('StreamOverlayPage', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('updated artwork', () => {
|
||||
it('maps legacy model codes in version 2', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model: 'BL-P001' })));
|
||||
renderOverlayPage(1, '?artwork=2&show=model');
|
||||
expect(await screen.findByText('X1C')).toBeInTheDocument();
|
||||
expect(screen.queryByText('BL-P001')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['', '?artwork=other', '?artwork=updated'])('retains the original renderer for %s', async (query) => {
|
||||
const { container } = renderOverlayPage(1, query);
|
||||
await screen.findByAltText('Bambuddy');
|
||||
expect(container.querySelector('.updated-overlay')).not.toBeInTheDocument();
|
||||
expect(screen.getByAltText('Camera stream')).toHaveClass('object-contain');
|
||||
});
|
||||
it('uses token data, rotation and both nozzles without inventing missing temperatures', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
|
||||
...mockStatusPrinting, name: 'Token printer', model: 'O1D', camera_rotation: 90,
|
||||
time_format: '24h', temperatures: { nozzle: 210, nozzle_target: 220, nozzle_2: 215, bed: 45 },
|
||||
})));
|
||||
renderOverlayPage(1, '?artwork=2&token=bblt_test&show=model,nozzle,bed,chamber&size=large');
|
||||
expect(await screen.findByText('H2D')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Token printer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
|
||||
const camera = screen.getByAltText('Camera stream');
|
||||
expect(camera).toHaveAttribute('src', expect.stringContaining('token=bblt_test'));
|
||||
expect(camera).toHaveStyle({ transform: 'translate(-50%, -50%) rotate(90deg)' });
|
||||
expect(camera.closest('.updated-overlay')).toHaveAttribute('data-size', 'large');
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[90, { width: '640px', height: '360px' }],
|
||||
[0, null],
|
||||
])('sizes a camera turned %i degrees from its slot, not the viewport', async (rotation, expected) => {
|
||||
// A portrait source: the camera fills a 360 x 640 middle row, so the
|
||||
// viewport (the old vw/vh fallback) would be the wrong box to swap.
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('updated-overlay__camera')
|
||||
? ({ width: 360, height: 640, top: 0, left: 0, right: 360, bottom: 640, x: 0, y: 0, toJSON: () => ({}) } as DOMRect)
|
||||
: ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect);
|
||||
});
|
||||
try {
|
||||
server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, camera_rotation: rotation })));
|
||||
renderOverlayPage(1, '?artwork=2');
|
||||
const camera = await screen.findByAltText('Camera stream');
|
||||
await waitFor(() => expect(camera).toHaveStyle({ transform: `translate(-50%, -50%) rotate(${rotation}deg)` }));
|
||||
if (expected) {
|
||||
await waitFor(() => expect(camera).toHaveStyle(expected));
|
||||
} else {
|
||||
expect(camera.style.width).toBe('');
|
||||
expect(camera.style.height).toBe('');
|
||||
}
|
||||
} finally {
|
||||
rect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders selected fields and accessible progress', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
|
||||
renderOverlayPage(1, '?artwork=2&show=printer,model,filename,status,progress,layers,eta');
|
||||
expect(await screen.findByRole('progressbar')).toHaveAttribute('aria-valuenow', '45');
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
expect(screen.getByText('X1C')).toBeInTheDocument();
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
expect(screen.getByText('150 / 300')).toBeInTheDocument();
|
||||
});
|
||||
it('hides all unselected information, including idle status', async () => {
|
||||
const { container } = renderOverlayPage(1, '?artwork=2&show=&camera=false');
|
||||
await screen.findByAltText('Bambuddy');
|
||||
expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
|
||||
expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.updated-overlay__panel')).not.toBeInTheDocument();
|
||||
});
|
||||
it.each(['FINISH', 'FAILED'])('shows %s without contradictory idle text', async (state) => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, state })));
|
||||
renderOverlayPage(1, '?artwork=2&show=status,progress');
|
||||
expect(await screen.findByText(state === 'FINISH' ? 'Finished' : 'Failed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Printer is idle')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
it('hides stale progress when disconnected', async () => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...mockStatusPrinting, connected: false })));
|
||||
renderOverlayPage(1, '?artwork=2&show=status,progress,layers,eta');
|
||||
expect(await screen.findByText('Printer offline')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders overlay page for printer', async () => {
|
||||
renderOverlayPage(1);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3596,7 +3596,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Drucker ist inaktiv',
|
||||
printerOffline: 'Drucker offline',
|
||||
layer: 'Schicht',
|
||||
remaining: 'Verbleibend',
|
||||
builder: {
|
||||
artwork: 'Gestaltung',
|
||||
artworkClassic: 'Klassisch',
|
||||
artworkV2: 'Version 2',
|
||||
title: 'Stream-Overlay',
|
||||
description: 'Erstellt die URL für ein Stream-Overlay — eine bildschirmfüllende Kameraansicht mit eingeblendeten Live-Druckdaten, für OBS, ein Wanddisplay oder jede andere Browserquelle. Felder auswählen und URL kopieren.',
|
||||
printer: 'Drucker',
|
||||
|
||||
@@ -3626,7 +3626,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Printer is idle',
|
||||
printerOffline: 'Printer offline',
|
||||
layer: 'Layer',
|
||||
remaining: 'Remaining',
|
||||
builder: {
|
||||
artwork: 'Artwork',
|
||||
artworkClassic: 'Classic',
|
||||
artworkV2: 'Version 2',
|
||||
title: 'Streaming Overlay',
|
||||
description: 'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
|
||||
printer: 'Printer',
|
||||
|
||||
@@ -3598,7 +3598,12 @@ export default {
|
||||
eta: 'Tiempo estimado',
|
||||
printerIdle: 'La impresora está inactiva',
|
||||
printerOffline: 'Impresora desconectada',
|
||||
layer: 'Capa',
|
||||
remaining: 'Restante',
|
||||
builder: {
|
||||
artwork: 'Diseño',
|
||||
artworkClassic: 'Clásico',
|
||||
artworkV2: 'Versión 2',
|
||||
title: 'Superposición de emisión',
|
||||
description: 'Crea la URL de una superposición de emisión: una vista de cámara a pantalla completa con los datos de impresión en directo encima, para OBS, una pantalla de pared o cualquier fuente de navegador. Elige los campos y copia la URL.',
|
||||
printer: 'Impresora',
|
||||
|
||||
@@ -3584,7 +3584,12 @@ export default {
|
||||
eta: 'Fin estimée',
|
||||
printerIdle: 'Imprimante inactive',
|
||||
printerOffline: 'Imprimante hors ligne',
|
||||
layer: 'Couche',
|
||||
remaining: 'Temps restant',
|
||||
builder: {
|
||||
artwork: 'Habillage',
|
||||
artworkClassic: 'Classique',
|
||||
artworkV2: 'Version 2',
|
||||
title: 'Incrustation de diffusion',
|
||||
description: 'Compose l\'URL d\'une incrustation de diffusion — une vue caméra plein écran avec les données d\'impression en direct par-dessus, pour OBS, un écran mural ou toute source navigateur. Choisissez les champs voulus et copiez l\'URL.',
|
||||
printer: 'Imprimante',
|
||||
|
||||
@@ -3583,7 +3583,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Stampante inattiva',
|
||||
printerOffline: 'Stampante offline',
|
||||
layer: 'Strato',
|
||||
remaining: 'Tempo rimanente',
|
||||
builder: {
|
||||
artwork: 'Grafica',
|
||||
artworkClassic: 'Classica',
|
||||
artworkV2: 'Versione 2',
|
||||
title: 'Overlay per streaming',
|
||||
description: 'Compone l\'URL di un overlay per streaming: una vista telecamera a schermo intero con i dati di stampa in tempo reale sovrapposti, per OBS, un display a parete o qualsiasi sorgente browser. Scegli i campi e copia l\'URL.',
|
||||
printer: 'Stampante',
|
||||
|
||||
@@ -3596,7 +3596,12 @@ export default {
|
||||
eta: '残り時間',
|
||||
printerIdle: 'プリンター待機中',
|
||||
printerOffline: 'プリンターオフライン',
|
||||
layer: 'レイヤー',
|
||||
remaining: '残り時間',
|
||||
builder: {
|
||||
artwork: 'デザイン',
|
||||
artworkClassic: 'クラシック',
|
||||
artworkV2: 'バージョン 2',
|
||||
title: 'ストリームオーバーレイ',
|
||||
description: 'ストリームオーバーレイのURLを作成します。全画面のカメラ映像に印刷中の情報を重ねて表示するもので、OBSや壁掛けディスプレイなどのブラウザソースで使えます。表示する項目を選んでURLをコピーしてください。',
|
||||
printer: 'プリンター',
|
||||
|
||||
@@ -3419,7 +3419,12 @@ export default {
|
||||
eta: '예상 완료',
|
||||
printerIdle: '프린터 대기 중',
|
||||
printerOffline: '프린터 오프라인',
|
||||
layer: '레이어',
|
||||
remaining: '남은 시간',
|
||||
builder: {
|
||||
artwork: '디자인',
|
||||
artworkClassic: '클래식',
|
||||
artworkV2: '버전 2',
|
||||
title: '스트림 오버레이',
|
||||
description: '스트림 오버레이 URL을 만듭니다. 전체 화면 카메라 영상 위에 실시간 출력 정보를 겹쳐 보여주며 OBS, 벽걸이 디스플레이 등 모든 브라우저 소스에서 쓸 수 있습니다. 원하는 항목을 고르고 URL을 복사하세요.',
|
||||
printer: '프린터',
|
||||
|
||||
@@ -3626,7 +3626,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Printer is inactief',
|
||||
printerOffline: 'Printer offline',
|
||||
layer: 'Laag',
|
||||
remaining: 'Resterend',
|
||||
builder: {
|
||||
artwork: 'Vormgeving',
|
||||
artworkClassic: 'Klassiek',
|
||||
artworkV2: 'Versie 2',
|
||||
title: 'Streamingoverlay',
|
||||
description: 'Bouw de URL voor een streamingoverlay — een camerabeeld op volledig scherm met live afdrukgegevens eroverheen, voor OBS, een wanddisplay of elke browserbron. Kies de velden die je wilt en kopieer de URL.',
|
||||
printer: 'Printer',
|
||||
|
||||
@@ -3583,7 +3583,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Impressora ociosa',
|
||||
printerOffline: 'Impressora offline',
|
||||
layer: 'Camada',
|
||||
remaining: 'Tempo restante',
|
||||
builder: {
|
||||
artwork: 'Visual',
|
||||
artworkClassic: 'Clássico',
|
||||
artworkV2: 'Versão 2',
|
||||
title: 'Sobreposição de transmissão',
|
||||
description: 'Monta a URL de uma sobreposição de transmissão: a câmera em tela cheia com os dados da impressão sobrepostos, para OBS, um painel de parede ou qualquer fonte de navegador. Escolha os campos e copie a URL.',
|
||||
printer: 'Impressora',
|
||||
|
||||
@@ -3411,7 +3411,12 @@ export default {
|
||||
eta: "Осталось",
|
||||
printerIdle: "Принтер простаивает",
|
||||
printerOffline: "Принтер не в сети",
|
||||
layer: 'Слой',
|
||||
remaining: 'Осталось',
|
||||
builder: {
|
||||
artwork: 'Оформление',
|
||||
artworkClassic: 'Классическое',
|
||||
artworkV2: 'Версия 2',
|
||||
title: "Оформление трансляции",
|
||||
description: "Собирает адрес оформления трансляции: полноэкранное изображение камеры с наложенными данными о печати — для OBS, настенного экрана или любого источника-браузера. Выберите нужные поля и скопируйте адрес.",
|
||||
printer: "Принтер",
|
||||
|
||||
@@ -3625,7 +3625,12 @@ errors: {
|
||||
eta: 'Beräknad tid',
|
||||
printerIdle: 'Skrivaren är inaktiv',
|
||||
printerOffline: 'Skrivare offline',
|
||||
layer: 'Lager',
|
||||
remaining: 'Återstående',
|
||||
builder: {
|
||||
artwork: 'Utseende',
|
||||
artworkClassic: 'Klassisk',
|
||||
artworkV2: 'Version 2',
|
||||
title: 'Strömningsöverlägg',
|
||||
description: 'Bygg URL:en för ett strömningsöverlägg — en helskärmskameravy med live utskriftsdata som visas ovanpå. Välj de fält du vill ha och kopiera URL:en.',
|
||||
printer: 'Skrivare',
|
||||
|
||||
@@ -3598,7 +3598,12 @@ export default {
|
||||
eta: 'ETA',
|
||||
printerIdle: 'Yazıcı boşta',
|
||||
printerOffline: 'Yazıcı çevrimdışı',
|
||||
layer: 'Katman',
|
||||
remaining: 'Kalan süre',
|
||||
builder: {
|
||||
artwork: 'Tasarım',
|
||||
artworkClassic: 'Klasik',
|
||||
artworkV2: 'Sürüm 2',
|
||||
title: 'Yayın Kaplaması',
|
||||
description: 'Yayın kaplaması için URL oluşturur: tam ekran kamera görüntüsünün üzerine canlı baskı bilgileri bindirilir; OBS, duvar ekranı veya herhangi bir tarayıcı kaynağı için. İstediğiniz alanları seçip URL\'yi kopyalayın.',
|
||||
printer: 'Yazıcı',
|
||||
|
||||
@@ -3623,7 +3623,12 @@ export default {
|
||||
eta: "ETA",
|
||||
printerIdle: "Принтер неактивний",
|
||||
printerOffline: "Принтер не в мережі",
|
||||
layer: 'Шар',
|
||||
remaining: 'Залишилось',
|
||||
builder: {
|
||||
artwork: 'Оформлення',
|
||||
artworkClassic: 'Класичне',
|
||||
artworkV2: 'Версія 2',
|
||||
title: "Оформлення трансляції",
|
||||
description: "Складає адресу оформлення трансляції: повноекранне зображення камери з накладеними даними друку — для OBS, настінного екрана або будь-якого джерела-браузера. Виберіть потрібні поля та скопіюйте адресу.",
|
||||
printer: "Принтер",
|
||||
|
||||
@@ -3583,7 +3583,12 @@ export default {
|
||||
eta: '预计完成时间',
|
||||
printerIdle: '打印机空闲',
|
||||
printerOffline: '打印机离线',
|
||||
layer: '层数',
|
||||
remaining: '剩余时间',
|
||||
builder: {
|
||||
artwork: '外观',
|
||||
artworkClassic: '经典',
|
||||
artworkV2: '第 2 版',
|
||||
title: '直播叠加层',
|
||||
description: '生成直播叠加层的网址:全屏摄像头画面上叠加实时打印信息,可用于 OBS、墙面显示屏或任何浏览器源。选择需要的字段并复制网址。',
|
||||
printer: '打印机',
|
||||
|
||||
@@ -3583,7 +3583,12 @@ export default {
|
||||
eta: '預計完成時間',
|
||||
printerIdle: '印表機空閒',
|
||||
printerOffline: '印表機離線',
|
||||
layer: '層數',
|
||||
remaining: '剩餘時間',
|
||||
builder: {
|
||||
artwork: '外觀',
|
||||
artworkClassic: '經典',
|
||||
artworkV2: '第 2 版',
|
||||
title: '直播疊加層',
|
||||
description: '產生直播疊加層的網址:全螢幕攝影機畫面上疊加即時列印資訊,可用於 OBS、牆面顯示器或任何瀏覽器來源。選擇需要的欄位並複製網址。',
|
||||
printer: '印表機',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
|
||||
import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
|
||||
import { api, ApiError, withStreamToken } from '../api/client';
|
||||
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
|
||||
import { mapModelCode } from '../utils/printerModel';
|
||||
@@ -13,6 +14,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface OverlayConfig {
|
||||
size: OverlaySize;
|
||||
updatedArtwork: boolean;
|
||||
fps: number;
|
||||
showCamera: boolean;
|
||||
showProgress: boolean;
|
||||
@@ -54,6 +56,7 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
|
||||
return {
|
||||
size: (params.get('size') as OverlaySize) || 'medium',
|
||||
fps,
|
||||
updatedArtwork: params.get('artwork') === '2',
|
||||
showCamera,
|
||||
showProgress: show.includes('progress'),
|
||||
showLayers: show.includes('layers'),
|
||||
@@ -380,6 +383,26 @@ export function StreamOverlayPage() {
|
||||
? `${camPath}&token=${encodeURIComponent(token)}`
|
||||
: withStreamToken(camPath);
|
||||
|
||||
if (config.updatedArtwork) {
|
||||
const active = status.connected && isPrinting;
|
||||
const remainingTime = status.remaining_time;
|
||||
const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
|
||||
return <UpdatedStreamOverlay
|
||||
size={config.size}
|
||||
camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
|
||||
name={config.showPrinter ? printer?.name ?? null : null}
|
||||
model={config.showModel ? mapModelCode(printer?.model ?? null) || null : null}
|
||||
filename={config.showFilename && status.current_print ? formatPrintName(status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, ''), status.gcode_file, t) : null}
|
||||
status={config.showStatus ? (status.connected ? getStatusText(status, t) : t('streamOverlay.printerOffline')) : null}
|
||||
state={status.connected ? status.state : null}
|
||||
progress={active && config.showProgress ? Math.min(100, Math.max(0, Number.isFinite(progress) ? progress : 0)) : null}
|
||||
layers={active && config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 ? `${status.layer_num} / ${status.total_layers}` : null}
|
||||
remaining={hasRemaining ? formatDuration(remainingTime * 60) : null}
|
||||
eta={hasRemaining ? formatETA(remainingTime, timeFormat, t) : null}
|
||||
temperatures={status.connected ? tempReadings : []}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black relative overflow-hidden">
|
||||
{/* Camera feed - fullscreen background (optional) */}
|
||||
|
||||
Reference in New Issue
Block a user