@@ -116,7 +141,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
)}
{progress != null && (
-
{t('streamOverlay.progress')}
+
{t('streamOverlay.progress')}
-
{Math.round(progress)}%
+
{Math.round(progress)}%
)}
{(stats.length > 0 || temperatures.length > 0) && (
diff --git a/frontend/src/hooks/useOverlayLogo.ts b/frontend/src/hooks/useOverlayLogo.ts
new file mode 100644
index 000000000..96902d607
--- /dev/null
+++ b/frontend/src/hooks/useOverlayLogo.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from 'react';
+import { api } from '../api/client';
+
+export function useOverlayLogo(enabled: boolean, token: string | null = null, revision = 0) {
+ const [logo, setLogo] = useState<{ url: string; token: string | null; revision: number } | null>(null);
+ useEffect(() => {
+ if (!enabled) return;
+ const controller = new AbortController();
+ let url: string | undefined;
+ void api.getOverlayLogo(token, controller.signal).then((blob) => {
+ if (controller.signal.aborted) return;
+ if (blob) {
+ url = URL.createObjectURL(blob);
+ setLogo({ url, token, revision });
+ } else {
+ setLogo(null);
+ }
+ }).catch(() => {
+ if (!controller.signal.aborted) setLogo(null);
+ });
+ return () => {
+ controller.abort();
+ if (url) URL.revokeObjectURL(url);
+ };
+ }, [enabled, token, revision]);
+ return enabled && logo?.token === token && logo.revision === revision ? logo.url : null;
+}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index d29786260..03e3041ae 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -3610,6 +3610,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Markenauftritt',
+ hint: 'Ein gemeinsames Logo für diese Installation. PNG oder WebP, bis zu 2 MiB und 4 Millionen Pixel. Ersetzen oder Entfernen betrifft alle Overlays, die es verwenden.',
+ upload: 'Logo hochladen',
+ logo: 'Eigenes Logo',
+ from: 'Startfarbe',
+ to: 'Endfarbe',
+ reset: 'Farben zurücksetzen',
+ failed: 'Logo konnte nicht gespeichert werden. Bildformat und Größe prüfen.',
+ },
title: 'Stream-Overlay',
invalidPrinterId: 'Ungültige Drucker-ID',
cameraStream: 'Kamera-Stream',
@@ -3620,6 +3630,8 @@ export default {
layer: 'Schicht',
remaining: 'Verbleibend',
builder: {
+ backgroundTransparency: 'Hintergrundtransparenz',
+ backgroundTransparencyHint: 'Nur die dunklen Hintergründe ausblenden. Text, Logos und Kamera bleiben sichtbar.',
artwork: 'Gestaltung',
artworkClassic: 'Klassisch',
artworkV2: 'Version 2',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 228df2e1c..19012a99c 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -3640,6 +3640,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Branding',
+ hint: 'One shared logo for this installation. PNG or WebP, up to 2 MiB and 4 million pixels. Replacing or removing it affects overlays using it.',
+ upload: 'Upload logo',
+ logo: 'Custom logo',
+ from: 'From colour',
+ to: 'To colour',
+ reset: 'Reset colours',
+ failed: 'Could not save the logo. Check the image format and size.',
+ },
title: 'Stream Overlay',
invalidPrinterId: 'Invalid printer ID',
cameraStream: 'Camera stream',
@@ -3650,6 +3660,8 @@ export default {
layer: 'Layer',
remaining: 'Remaining',
builder: {
+ backgroundTransparency: 'Background transparency',
+ backgroundTransparencyHint: 'Fade the dark backgrounds only. Text, logos and camera stay visible.',
artwork: 'Artwork',
artworkClassic: 'Classic',
artworkV2: 'Version 2',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 79b9a7d1d..c1ae32051 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -3612,6 +3612,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Identidad visual',
+ hint: 'Un logotipo compartido para esta instalación. PNG o WebP, hasta 2 MiB y 4 millones de píxeles. Reemplazarlo o eliminarlo afecta a las superposiciones que lo utilizan.',
+ upload: 'Subir logotipo',
+ logo: 'Logotipo personalizado',
+ from: 'Color inicial',
+ to: 'Color final',
+ reset: 'Restablecer colores',
+ failed: 'No se pudo guardar el logotipo. Comprueba el formato y el tamaño.',
+ },
title: 'Superposición de transmisión',
invalidPrinterId: 'ID de impresora no válido',
cameraStream: 'Transmisión de la cámara',
@@ -3622,6 +3632,8 @@ export default {
layer: 'Capa',
remaining: 'Restante',
builder: {
+ backgroundTransparency: 'Transparencia del fondo',
+ backgroundTransparencyHint: 'Atenúa solo los fondos oscuros. El texto, los logotipos y la cámara siguen visibles.',
artwork: 'Diseño',
artworkClassic: 'Clásico',
artworkV2: 'Versión 2',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index daac87383..4458cfa76 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -3598,6 +3598,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Identité visuelle',
+ hint: 'Un logo partagé pour cette installation. PNG ou WebP, jusqu’à 2 Mio et 4 millions de pixels. Son remplacement ou sa suppression affecte les incrustations qui l’utilisent.',
+ upload: 'Importer un logo',
+ logo: 'Logo personnalisé',
+ from: 'Couleur de départ',
+ to: 'Couleur de fin',
+ reset: 'Réinitialiser les couleurs',
+ failed: 'Impossible d’enregistrer le logo. Vérifiez le format et la taille.',
+ },
title: 'Superposition Flux',
invalidPrinterId: 'ID invalide',
cameraStream: 'Flux caméra',
@@ -3608,6 +3618,8 @@ export default {
layer: 'Couche',
remaining: 'Temps restant',
builder: {
+ backgroundTransparency: 'Transparence du fond',
+ backgroundTransparencyHint: 'Estompe uniquement les fonds sombres. Le texte, les logos et la caméra restent visibles.',
artwork: 'Habillage',
artworkClassic: 'Classique',
artworkV2: 'Version 2',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index 7e8acfdf9..c58a5527b 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -3597,6 +3597,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Identità visiva',
+ hint: 'Un logo condiviso per questa installazione. PNG o WebP, fino a 2 MiB e 4 milioni di pixel. Sostituirlo o rimuoverlo influisce sulle sovrapposizioni che lo usano.',
+ upload: 'Carica logo',
+ logo: 'Logo personalizzato',
+ from: 'Colore iniziale',
+ to: 'Colore finale',
+ reset: 'Ripristina colori',
+ failed: 'Impossibile salvare il logo. Controlla formato e dimensioni.',
+ },
title: 'Overlay stream',
invalidPrinterId: 'ID stampante non valido',
cameraStream: 'Stream camera',
@@ -3607,6 +3617,8 @@ export default {
layer: 'Strato',
remaining: 'Tempo rimanente',
builder: {
+ backgroundTransparency: 'Trasparenza dello sfondo',
+ backgroundTransparencyHint: 'Sfuma solo gli sfondi scuri. Testo, loghi e videocamera restano visibili.',
artwork: 'Grafica',
artworkClassic: 'Classica',
artworkV2: 'Versione 2',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index 0f2cdab63..daf7dad58 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -3610,6 +3610,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'ブランド設定',
+ hint: 'このインストールで共有するロゴです。PNG または WebP、最大 2 MiB、400 万画素。変更や削除は使用中のオーバーレイにも反映されます。',
+ upload: 'ロゴをアップロード',
+ logo: 'カスタムロゴ',
+ from: '開始色',
+ to: '終了色',
+ reset: '色をリセット',
+ failed: 'ロゴを保存できませんでした。画像の形式とサイズを確認してください。',
+ },
title: 'ストリームオーバーレイ',
invalidPrinterId: '無効なプリンターID',
cameraStream: 'カメラストリーム',
@@ -3620,6 +3630,8 @@ export default {
layer: 'レイヤー',
remaining: '残り時間',
builder: {
+ backgroundTransparency: '背景の透明度',
+ backgroundTransparencyHint: '暗い背景のみを薄くします。テキスト、ロゴ、カメラ映像はそのまま表示されます。',
artwork: 'デザイン',
artworkClassic: 'クラシック',
artworkV2: 'バージョン 2',
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index 672a00f38..b865449e4 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -3433,6 +3433,16 @@ export default {
}
},
streamOverlay: {
+ branding: {
+ title: '브랜딩 설정',
+ hint: '이 설치에서 공유하는 로고입니다. PNG 또는 WebP, 최대 2 MiB 및 400만 픽셀. 교체하거나 삭제하면 사용 중인 오버레이에 영향을 줍니다.',
+ upload: '로고 업로드',
+ logo: '사용자 지정 로고',
+ from: '시작 색상',
+ to: '끝 색상',
+ reset: '색상 초기화',
+ failed: '로고를 저장하지 못했습니다. 이미지 형식과 크기를 확인하세요.',
+ },
title: '스트림 오버레이',
invalidPrinterId: '유효하지 않은 프린터 ID',
cameraStream: '카메라 스트림',
@@ -3443,6 +3453,8 @@ export default {
layer: '레이어',
remaining: '남은 시간',
builder: {
+ backgroundTransparency: '배경 투명도',
+ backgroundTransparencyHint: '어두운 배경만 흐리게 합니다. 텍스트, 로고와 카메라 영상은 그대로 표시됩니다.',
artwork: '디자인',
artworkClassic: '클래식',
artworkV2: '버전 2',
diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts
index f6b6c6389..23aa5b76c 100644
--- a/frontend/src/i18n/locales/nl.ts
+++ b/frontend/src/i18n/locales/nl.ts
@@ -3640,6 +3640,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Huisstijl',
+ hint: 'Eén gedeeld logo voor deze installatie. PNG of WebP, maximaal 2 MiB en 4 miljoen pixels. Vervangen of verwijderen heeft invloed op overlays die het gebruiken.',
+ upload: 'Logo uploaden',
+ logo: 'Eigen logo',
+ from: 'Beginkleur',
+ to: 'Eindkleur',
+ reset: 'Kleuren herstellen',
+ failed: 'Kan het logo niet opslaan. Controleer het formaat en de grootte.',
+ },
title: 'Streamoverlay',
invalidPrinterId: 'Ongeldige printer-ID',
cameraStream: 'Camerastream',
@@ -3650,6 +3660,8 @@ export default {
layer: 'Laag',
remaining: 'Resterend',
builder: {
+ backgroundTransparency: 'Achtergrondtransparantie',
+ backgroundTransparencyHint: 'Vervaag alleen de donkere achtergronden. Tekst, logo’s en camera blijven zichtbaar.',
artwork: 'Vormgeving',
artworkClassic: 'Klassiek',
artworkV2: 'Versie 2',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 74e777485..ad876d5d5 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -3597,6 +3597,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Identidade visual',
+ hint: 'Um logotipo compartilhado nesta instalação. PNG ou WebP, até 2 MiB e 4 milhões de pixels. Substituir ou remover afeta as sobreposições que o utilizam.',
+ upload: 'Enviar logotipo',
+ logo: 'Logotipo personalizado',
+ from: 'Cor inicial',
+ to: 'Cor final',
+ reset: 'Redefinir cores',
+ failed: 'Não foi possível salvar o logotipo. Verifique o formato e o tamanho.',
+ },
title: 'Sobreposição de stream',
invalidPrinterId: 'ID da impressora inválido',
cameraStream: 'Transmissão da câmera',
@@ -3607,6 +3617,8 @@ export default {
layer: 'Camada',
remaining: 'Tempo restante',
builder: {
+ backgroundTransparency: 'Transparência do fundo',
+ backgroundTransparencyHint: 'Atenua apenas os fundos escuros. Texto, logotipos e câmera continuam visíveis.',
artwork: 'Visual',
artworkClassic: 'Clássico',
artworkV2: 'Versão 2',
diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts
index ddad7a032..c83403cc7 100644
--- a/frontend/src/i18n/locales/ru.ts
+++ b/frontend/src/i18n/locales/ru.ts
@@ -3425,6 +3425,16 @@ export default {
},
},
streamOverlay: {
+ branding: {
+ title: "Оформление",
+ hint: "Общий логотип для этой установки. PNG или WebP, до 2 МиБ и 4 миллионов пикселей. Замена или удаление влияет на использующие его оверлеи.",
+ upload: "Загрузить логотип",
+ logo: "Свой логотип",
+ from: "Начальный цвет",
+ to: "Конечный цвет",
+ reset: "Сбросить цвета",
+ failed: "Не удалось сохранить логотип. Проверьте формат и размер изображения.",
+ },
title: "Оформление трансляции",
invalidPrinterId: "Неверный идентификатор принтера",
cameraStream: "Видеопоток камеры",
@@ -3435,6 +3445,8 @@ export default {
layer: 'Слой',
remaining: 'Осталось',
builder: {
+ backgroundTransparency: "Прозрачность фона",
+ backgroundTransparencyHint: "Изменяет только тёмный фон. Текст, логотипы и изображение камеры остаются видимыми.",
artwork: 'Оформление',
artworkClassic: 'Классическое',
artworkV2: 'Версия 2',
diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts
index 372b32bbf..6d574dadc 100644
--- a/frontend/src/i18n/locales/sv.ts
+++ b/frontend/src/i18n/locales/sv.ts
@@ -3639,6 +3639,16 @@ errors: {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: 'Profilering',
+ hint: 'En delad logotyp för denna installation. PNG eller WebP, högst 2 MiB och 4 miljoner pixlar. Byte eller borttagning påverkar överlägg som använder den.',
+ upload: 'Ladda upp logotyp',
+ logo: 'Egen logotyp',
+ from: 'Startfärg',
+ to: 'Slutfärg',
+ reset: 'Återställ färger',
+ failed: 'Kunde inte spara logotypen. Kontrollera bildformat och storlek.',
+ },
title: 'Strömöverlägg',
invalidPrinterId: 'Ogiltigt skrivar-ID',
cameraStream: 'Kameraström',
@@ -3649,6 +3659,8 @@ errors: {
layer: 'Lager',
remaining: 'Återstående',
builder: {
+ backgroundTransparency: 'Bakgrundens genomskinlighet',
+ backgroundTransparencyHint: 'Tona bara ned de mörka bakgrunderna. Text, logotyper och kamerabild förblir synliga.',
artwork: 'Utseende',
artworkClassic: 'Klassisk',
artworkV2: 'Version 2',
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index 73d558d0b..834b08c69 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -3612,6 +3612,16 @@ export default {
// Akış bindirmesi
streamOverlay: {
+ branding: {
+ title: 'Marka görünümü',
+ hint: 'Bu kurulum için paylaşılan bir logo. PNG veya WebP, en fazla 2 MiB ve 4 milyon piksel. Değiştirmek veya kaldırmak, kullanan katmanları etkiler.',
+ upload: 'Logo yükle',
+ logo: 'Özel logo',
+ from: 'Başlangıç rengi',
+ to: 'Bitiş rengi',
+ reset: 'Renkleri sıfırla',
+ failed: 'Logo kaydedilemedi. Görüntü biçimini ve boyutunu kontrol edin.',
+ },
title: 'Akış Bindirmesi',
invalidPrinterId: 'Geçersiz yazıcı ID\'si',
cameraStream: 'Kamera akışı',
@@ -3622,6 +3632,8 @@ export default {
layer: 'Katman',
remaining: 'Kalan süre',
builder: {
+ backgroundTransparency: 'Arka plan saydamlığı',
+ backgroundTransparencyHint: 'Yalnızca koyu arka planları soldurur. Metin, logolar ve kamera görünür kalır.',
artwork: 'Tasarım',
artworkClassic: 'Klasik',
artworkV2: 'Sürüm 2',
diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts
index 42abc2d24..dd3993731 100644
--- a/frontend/src/i18n/locales/uk.ts
+++ b/frontend/src/i18n/locales/uk.ts
@@ -3637,6 +3637,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: "Оформлення",
+ hint: "Спільний логотип для цієї інсталяції. PNG або WebP, до 2 МіБ і 4 мільйонів пікселів. Заміна або видалення впливає на оверлеї, які його використовують.",
+ upload: "Завантажити логотип",
+ logo: "Власний логотип",
+ from: "Початковий колір",
+ to: "Кінцевий колір",
+ reset: "Скинути кольори",
+ failed: "Не вдалося зберегти логотип. Перевірте формат і розмір зображення.",
+ },
title: "Накладання потоку",
invalidPrinterId: "Недійсний ідентифікатор принтера",
cameraStream: "Потік камери",
@@ -3647,6 +3657,8 @@ export default {
layer: 'Шар',
remaining: 'Залишилось',
builder: {
+ backgroundTransparency: "Прозорість тла",
+ backgroundTransparencyHint: "Змінює лише темне тло. Текст, логотипи та зображення камери залишаються видимими.",
artwork: 'Оформлення',
artworkClassic: 'Класичне',
artworkV2: 'Версія 2',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index a20091039..b69092cbb 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -3597,6 +3597,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: '品牌设置',
+ hint: '此安装共享一个徽标。支持 PNG 或 WebP,最大 2 MiB 和 400 万像素。替换或删除会影响使用该徽标的叠加层。',
+ upload: '上传徽标',
+ logo: '自定义徽标',
+ from: '起始颜色',
+ to: '结束颜色',
+ reset: '重置颜色',
+ failed: '无法保存徽标。请检查图片格式和大小。',
+ },
title: '流叠加层',
invalidPrinterId: '无效的打印机 ID',
cameraStream: '摄像头流',
@@ -3607,6 +3617,8 @@ export default {
layer: '层数',
remaining: '剩余时间',
builder: {
+ backgroundTransparency: '背景透明度',
+ backgroundTransparencyHint: '仅淡化深色背景。文字、徽标和摄像头画面保持可见。',
artwork: '外观',
artworkClassic: '经典',
artworkV2: '第 2 版',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 6090141ec..4f426d868 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -3597,6 +3597,16 @@ export default {
// Stream overlay
streamOverlay: {
+ branding: {
+ title: '品牌設定',
+ hint: '此安裝共用一個標誌。支援 PNG 或 WebP,最大 2 MiB 和 400 萬像素。替換或刪除會影響使用該標誌的覆蓋層。',
+ upload: '上傳標誌',
+ logo: '自訂標誌',
+ from: '起始顏色',
+ to: '結束顏色',
+ reset: '重設顏色',
+ failed: '無法儲存標誌。請檢查圖片格式和大小。',
+ },
title: '流疊加層',
invalidPrinterId: '無效的印表機 ID',
cameraStream: '攝影機流',
@@ -3607,6 +3617,8 @@ export default {
layer: '層數',
remaining: '剩餘時間',
builder: {
+ backgroundTransparency: '背景透明度',
+ backgroundTransparencyHint: '僅淡化深色背景。文字、標誌和攝影機畫面保持可見。',
artwork: '外觀',
artworkClassic: '經典',
artworkV2: '第 2 版',
diff --git a/frontend/src/pages/StreamOverlayPage.tsx b/frontend/src/pages/StreamOverlayPage.tsx
index d9350fc95..ae35e220e 100644
--- a/frontend/src/pages/StreamOverlayPage.tsx
+++ b/frontend/src/pages/StreamOverlayPage.tsx
@@ -3,6 +3,8 @@ 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 { useOverlayLogo } from '../hooks/useOverlayLogo';
+import { overlayGradient, overlayProgressTextStyle } from '../utils/overlayBranding';
import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
import { api, ApiError, withStreamToken } from '../api/client';
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
@@ -16,6 +18,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
interface OverlayConfig {
size: OverlaySize;
updatedArtwork: boolean;
+ backgroundTransparency: number;
fps: number;
showCamera: boolean;
showProgress: boolean;
@@ -54,7 +57,13 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
const cameraParam = params.get('camera');
const showCamera = cameraParam !== 'false' && cameraParam !== '0';
+ const transparencyParam = Number(params.get('backgroundTransparency'));
+ const backgroundTransparency = Number.isFinite(transparencyParam)
+ ? Math.min(100, Math.max(0, transparencyParam))
+ : 0;
+
return {
+ backgroundTransparency,
size: (params.get('size') as OverlaySize) || 'medium',
fps,
updatedArtwork: params.get('artwork') === '2',
@@ -178,6 +187,16 @@ export function StreamOverlayPage() {
// (status + camera stream) is authenticated by that token instead of a JWT.
const token = searchParams.get('token');
const kiosk = token != null && token !== '';
+ const logo = useOverlayLogo(searchParams.get('logo') === '1', token);
+ const progressBackground = overlayGradient(searchParams.get('progressFrom'), searchParams.get('progressTo'));
+ const progressTextStyle = overlayProgressTextStyle(progressBackground);
+ const customLogo = logo ? (
+

+ ) : null;
// Kiosk path: one token-authenticated call for name + live status + the one
// setting the overlay reads. No JWT, so this is the only feed available.
@@ -382,6 +401,9 @@ export function StreamOverlayPage() {
const remainingTime = status.remaining_time;
const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
return
)}
- {/* Bambuddy logo - top right */}
-
-
-
+ {/* Channel branding above the Bambuddy mark. */}
+
+ {customLogo}
+
+
+
+
{/* Status overlay - bottom */}
@@ -454,13 +478,13 @@ export function StreamOverlayPage() {
{config.showProgress && isPrinting && (
- {t('streamOverlay.progress')}
- {Math.round(progress)}%
+ {t('streamOverlay.progress')}
+ {Math.round(progress)}%
diff --git a/frontend/src/utils/overlayBranding.ts b/frontend/src/utils/overlayBranding.ts
new file mode 100644
index 000000000..a8f05b283
--- /dev/null
+++ b/frontend/src/utils/overlayBranding.ts
@@ -0,0 +1,33 @@
+import type { CSSProperties } from 'react';
+
+export interface OverlayBranding {
+ logo: boolean;
+ logoRevision: number;
+ from: string;
+ to: string;
+}
+
+export const DEFAULT_BRANDING: OverlayBranding = { logo: false, logoRevision: 0, from: '', to: '' };
+
+// Only six-digit hex colours can reach CSS. Invalid or missing pairs preserve
+// the artwork's existing progress colour, including its state-specific styling.
+export function isOverlayColour(value: string): boolean {
+ return /^#[0-9a-f]{6}$/i.test(value);
+}
+
+export function overlayGradient(from: string | null, to: string | null): string | undefined {
+ return from && to && isOverlayColour(from) && isOverlayColour(to)
+ ? `linear-gradient(to right, ${from}, ${to})`
+ : undefined;
+}
+
+export function overlayProgressTextStyle(background: string | undefined): CSSProperties | undefined {
+ if (!background) return undefined;
+ return {
+ backgroundImage: background,
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ color: 'transparent',
+ WebkitTextFillColor: 'transparent',
+ };
+}