Add public OBS overlay modes, themes and token protection

This commit is contained in:
larssand
2026-03-22 16:02:08 +01:00
parent 94df6ece34
commit 5c96f2e421
5 changed files with 779 additions and 10 deletions

View File

@@ -27,6 +27,35 @@ JMK RB RaceController is an RC timing and race-control system with support for s
### Judging ### Judging
![Team Overlay](docs/screenshots/judging.jpg) ![Team Overlay](docs/screenshots/judging.jpg)
## Public Overlay And OBS
The app now supports a dedicated public overlay route so you do not need to expose the full admin UI externally.
- Public overlay route: `/public-overlay/<mode>`
- OBS mode: `/public-overlay/obs`
- OBS overlay can be configured from the `Overlay` menu:
- rows
- layout: `leaderboard`, `grid`, `lowerthird`
- theme: `panel`, `transparent`, `chroma`
- visible columns and blocks
- Use `Copy OBS URL` to generate a ready-to-paste browser-source link for OBS.
If you want simple protection for public overlays, set this environment variable on the server:
```bash
PUBLIC_OVERLAY_TOKEN=your-secret-token
```
Then the public overlay URL must include:
```text
/public-overlay/obs?token=your-secret-token
```
Recommended deployment:
- publish only `/public-overlay/*` through your reverse proxy
- keep the main app/admin UI internal
## Features ## Features
- Event modes: - Event modes:
- `Race (driver transponders)` - `Race (driver transponders)`

View File

@@ -25,6 +25,35 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
### Domare ### Domare
![Team Overlay](docs/screenshots/judging.jpg) ![Team Overlay](docs/screenshots/judging.jpg)
## Publik Overlay Och OBS
Appen har nu en separat publik overlay-route så att du inte behöver exponera hela admin-UI:t externt.
- Publik overlay-route: `/public-overlay/<mode>`
- OBS-läge: `/public-overlay/obs`
- OBS-overlay konfigureras från menyn `Overlay`:
- antal rader
- layout: `leaderboard`, `grid`, `lowerthird`
- tema: `panel`, `transparent`, `chroma`
- vilka kolumner och block som ska visas
- Använd `Kopiera OBS-url` för att få en färdig browser-source-länk till OBS.
Om du vill ha ett enkelt skydd för publika overlays, sätt denna miljövariabel på servern:
```bash
PUBLIC_OVERLAY_TOKEN=din-hemliga-token
```
Då måste den publika overlay-länken innehålla:
```text
/public-overlay/obs?token=din-hemliga-token
```
Rekommenderad deploy:
- publicera bara `/public-overlay/*` via reverse proxy
- håll huvudappen/admin internt
## Vad som ingår ## Vad som ingår
- Event-lägen: - Event-lägen:
- `Race (driver transponders)` - `Race (driver transponders)`

View File

@@ -268,6 +268,16 @@ app.post("/api/export/pdf", (req, res) => {
} }
}); });
const PUBLIC_OVERLAY_TOKEN = String(process.env.PUBLIC_OVERLAY_TOKEN || "").trim();
app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => {
if (PUBLIC_OVERLAY_TOKEN && String(req.query.token || "").trim() !== PUBLIC_OVERLAY_TOKEN) {
res.status(403).type("text/plain").send("Forbidden");
return;
}
res.sendFile(path.join(__dirname, "index.html"));
});
app.use( app.use(
express.static(__dirname, { express.static(__dirname, {
setHeaders: (res, filePath) => { setHeaders: (res, filePath) => {

View File

@@ -438,6 +438,7 @@ const TRANSLATIONS = {
"timing.open_results_overlay": "Result overlay", "timing.open_results_overlay": "Result overlay",
"timing.open_tv_overlay": "TV overlay", "timing.open_tv_overlay": "TV overlay",
"timing.open_team_overlay": "Team overlay", "timing.open_team_overlay": "Team overlay",
"timing.open_obs_overlay": "OBS overlay",
"timing.close_details": "Stang", "timing.close_details": "Stang",
"timing.detail_title": "Leaderboard-detaljer", "timing.detail_title": "Leaderboard-detaljer",
"timing.lap_history": "Varvhistorik", "timing.lap_history": "Varvhistorik",
@@ -732,12 +733,34 @@ const TRANSLATIONS = {
"overlay.mode_results": "Resultat", "overlay.mode_results": "Resultat",
"overlay.mode_tv": "TV", "overlay.mode_tv": "TV",
"overlay.mode_team": "Team", "overlay.mode_team": "Team",
"overlay.mode_obs": "OBS",
"overlay.fastest_lap": "Snabbaste varv", "overlay.fastest_lap": "Snabbaste varv",
"overlay.fullscreen": "Fullscreen", "overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard", "overlay.leaderboard_live": "Live leaderboard",
"overlay.rotating_panel": "Displaypanel", "overlay.rotating_panel": "Displaypanel",
"overlay.next_predicted_lap": "Nästa varv", "overlay.next_predicted_lap": "Nästa varv",
"overlay.event_markers": "Eventmarkörer", "overlay.event_markers": "Eventmarkörer",
"overlay.obs_config": "OBS-konfiguration",
"overlay.obs_public_hint": "Bygg en minimal publik overlay-länk för OBS eller extern webb.",
"overlay.obs_rows": "Antal rader",
"overlay.obs_show_clock": "Visa raceklocka",
"overlay.obs_show_fastest": "Visa snabbaste varv",
"overlay.obs_show_grid": "Visa startgrid när racet är klart att starta",
"overlay.obs_show_laps": "Visa varv",
"overlay.obs_show_result": "Visa resultat/tid",
"overlay.obs_show_best": "Visa bästa varv",
"overlay.obs_show_gap": "Visa gap",
"overlay.obs_copy_url": "Kopiera OBS-url",
"overlay.obs_layout": "OBS-layout",
"overlay.obs_theme": "OBS-tema",
"overlay.obs_public_token": "Publik token",
"overlay.obs_public_token_hint": "Valfri klienttoken som läggs i public-overlay-länken.",
"overlay.obs_layout_leaderboard": "Leaderboard",
"overlay.obs_layout_grid": "Startgrid",
"overlay.obs_layout_lowerthird": "Lower third",
"overlay.obs_theme_panel": "Panel",
"overlay.obs_theme_transparent": "Transparent",
"overlay.obs_theme_chroma": "Chroma",
"overlay.team_battle": "Lagkamp", "overlay.team_battle": "Lagkamp",
"overlay.active_member": "Aktiv förare/bil", "overlay.active_member": "Aktiv förare/bil",
"overlay.top_three": "Topp 3", "overlay.top_three": "Topp 3",
@@ -747,6 +770,9 @@ const TRANSLATIONS = {
"guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.", "guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.",
"guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.", "guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.",
"guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.", "guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.",
"guide.host_6": "6. Publicera helst bara `/public-overlay/*` externt via reverse proxy, inte hela admin-UI:t.",
"guide.host_7": "7. OBS overlay finns nu som publik URL: `/public-overlay/obs` och kan konfigureras från Overlay-menyn.",
"guide.host_8": "8. Sätt `PUBLIC_OVERLAY_TOKEN` på servern om du vill kräva `?token=...` för publika overlay-länkar.",
"guide.windows_title": "Windows + AMMC + npm", "guide.windows_title": "Windows + AMMC + npm",
"guide.windows_1": "1. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.", "guide.windows_1": "1. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.",
"guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.", "guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.",
@@ -1195,6 +1221,7 @@ const TRANSLATIONS = {
"timing.open_results_overlay": "Results overlay", "timing.open_results_overlay": "Results overlay",
"timing.open_tv_overlay": "TV overlay", "timing.open_tv_overlay": "TV overlay",
"timing.open_team_overlay": "Team overlay", "timing.open_team_overlay": "Team overlay",
"timing.open_obs_overlay": "OBS overlay",
"timing.close_details": "Close", "timing.close_details": "Close",
"timing.detail_title": "Leaderboard details", "timing.detail_title": "Leaderboard details",
"timing.lap_history": "Lap history", "timing.lap_history": "Lap history",
@@ -1489,12 +1516,34 @@ const TRANSLATIONS = {
"overlay.mode_results": "Results", "overlay.mode_results": "Results",
"overlay.mode_tv": "TV", "overlay.mode_tv": "TV",
"overlay.mode_team": "Team", "overlay.mode_team": "Team",
"overlay.mode_obs": "OBS",
"overlay.fastest_lap": "Fastest Lap", "overlay.fastest_lap": "Fastest Lap",
"overlay.fullscreen": "Fullscreen", "overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard", "overlay.leaderboard_live": "Live leaderboard",
"overlay.rotating_panel": "Display panel", "overlay.rotating_panel": "Display panel",
"overlay.next_predicted_lap": "Next lap", "overlay.next_predicted_lap": "Next lap",
"overlay.event_markers": "Event markers", "overlay.event_markers": "Event markers",
"overlay.obs_config": "OBS config",
"overlay.obs_public_hint": "Build a minimal public overlay URL for OBS or an external website.",
"overlay.obs_rows": "Rows",
"overlay.obs_show_clock": "Show race clock",
"overlay.obs_show_fastest": "Show fastest lap",
"overlay.obs_show_grid": "Show start grid when the race is ready",
"overlay.obs_show_laps": "Show laps",
"overlay.obs_show_result": "Show result/time",
"overlay.obs_show_best": "Show best lap",
"overlay.obs_show_gap": "Show gap",
"overlay.obs_copy_url": "Copy OBS URL",
"overlay.obs_layout": "OBS layout",
"overlay.obs_theme": "OBS theme",
"overlay.obs_public_token": "Public token",
"overlay.obs_public_token_hint": "Optional client token appended to the public overlay URL.",
"overlay.obs_layout_leaderboard": "Leaderboard",
"overlay.obs_layout_grid": "Start grid",
"overlay.obs_layout_lowerthird": "Lower third",
"overlay.obs_theme_panel": "Panel",
"overlay.obs_theme_transparent": "Transparent",
"overlay.obs_theme_chroma": "Chroma",
"overlay.team_battle": "Team battle", "overlay.team_battle": "Team battle",
"overlay.active_member": "Active driver/car", "overlay.active_member": "Active driver/car",
"overlay.top_three": "Top 3", "overlay.top_three": "Top 3",
@@ -1537,10 +1586,17 @@ const TRANSLATIONS = {
}; };
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const overlayMode = urlParams.get("view") === "overlay"; const allowedOverlayModes = ["leaderboard", "speaker", "results", "tv", "team", "obs"];
const overlayViewMode = ["leaderboard", "speaker", "results", "tv", "team"].includes(String(urlParams.get("overlayMode") || "").toLowerCase()) const publicOverlayMatch = window.location.pathname.match(/^\/public-overlay(?:\/([a-z0-9_-]+))?\/?$/i);
? String(urlParams.get("overlayMode")).toLowerCase() const routeOverlayMode = String(publicOverlayMatch?.[1] || "").toLowerCase();
const queryOverlayMode = String(urlParams.get("overlayMode") || "").toLowerCase();
const overlayMode = urlParams.get("view") === "overlay" || Boolean(publicOverlayMatch);
const overlayViewMode = allowedOverlayModes.includes(routeOverlayMode)
? routeOverlayMode
: allowedOverlayModes.includes(queryOverlayMode)
? queryOverlayMode
: "leaderboard"; : "leaderboard";
const publicOverlayMode = Boolean(publicOverlayMatch);
const state = loadState(); const state = loadState();
let currentView = overlayMode ? "overlay" : "dashboard"; let currentView = overlayMode ? "overlay" : "dashboard";
let wsClient = null; let wsClient = null;
@@ -1563,6 +1619,86 @@ let quickAddDraft = null;
let driverBrandFilter = ""; let driverBrandFilter = "";
let carBrandFilter = ""; let carBrandFilter = "";
let raceFormatAdvanced = false; let raceFormatAdvanced = false;
const OBS_LAYOUTS = ["leaderboard", "grid", "lowerthird"];
const OBS_THEMES = ["panel", "transparent", "chroma"];
const DEFAULT_OBS_OVERLAY_SETTINGS = Object.freeze({
rows: 10,
showClock: true,
showFastest: true,
showGrid: true,
showLaps: true,
showResult: true,
showBest: true,
showGap: true,
layout: "leaderboard",
theme: "panel",
publicToken: "",
});
function normalizeObsOverlaySettings(raw = {}) {
const rowValue = Number(raw?.rows);
const rows = Number.isFinite(rowValue) ? Math.max(3, Math.min(12, Math.round(rowValue))) : DEFAULT_OBS_OVERLAY_SETTINGS.rows;
const layout = OBS_LAYOUTS.includes(String(raw?.layout || "").toLowerCase()) ? String(raw.layout).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.layout;
const theme = OBS_THEMES.includes(String(raw?.theme || "").toLowerCase()) ? String(raw.theme).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.theme;
return {
rows,
showClock: raw?.showClock !== false,
showFastest: raw?.showFastest !== false,
showGrid: raw?.showGrid !== false,
showLaps: raw?.showLaps !== false,
showResult: raw?.showResult !== false,
showBest: raw?.showBest !== false,
showGap: raw?.showGap !== false,
layout,
theme,
publicToken: String(raw?.publicToken || "").trim(),
};
}
function parseOverlayBooleanParam(name, fallback) {
const raw = urlParams.get(name);
if (raw == null) {
return fallback;
}
return ["1", "true", "yes", "on"].includes(String(raw).toLowerCase());
}
function getObsOverlayConfig() {
const base = normalizeObsOverlaySettings(state.settings?.obsOverlay || DEFAULT_OBS_OVERLAY_SETTINGS);
return normalizeObsOverlaySettings({
...base,
rows: urlParams.get("rows") ?? base.rows,
showClock: parseOverlayBooleanParam("showClock", base.showClock),
showFastest: parseOverlayBooleanParam("showFastest", base.showFastest),
showGrid: parseOverlayBooleanParam("showGrid", base.showGrid),
showLaps: parseOverlayBooleanParam("showLaps", base.showLaps),
showResult: parseOverlayBooleanParam("showResult", base.showResult),
showBest: parseOverlayBooleanParam("showBest", base.showBest),
showGap: parseOverlayBooleanParam("showGap", base.showGap),
layout: OBS_LAYOUTS.includes(String(urlParams.get("layout") || "").toLowerCase()) ? String(urlParams.get("layout")).toLowerCase() : base.layout,
theme: OBS_THEMES.includes(String(urlParams.get("obsTheme") || "").toLowerCase()) ? String(urlParams.get("obsTheme")).toLowerCase() : base.theme,
publicToken: String(urlParams.get("token") || base.publicToken || "").trim(),
});
}
function writeObsOverlayParams(url, config) {
const normalized = normalizeObsOverlaySettings(config);
url.searchParams.set("rows", String(normalized.rows));
url.searchParams.set("showClock", normalized.showClock ? "1" : "0");
url.searchParams.set("showFastest", normalized.showFastest ? "1" : "0");
url.searchParams.set("showGrid", normalized.showGrid ? "1" : "0");
url.searchParams.set("showLaps", normalized.showLaps ? "1" : "0");
url.searchParams.set("showResult", normalized.showResult ? "1" : "0");
url.searchParams.set("showBest", normalized.showBest ? "1" : "0");
url.searchParams.set("showGap", normalized.showGap ? "1" : "0");
url.searchParams.set("layout", normalized.layout);
url.searchParams.set("obsTheme", normalized.theme);
if (normalized.publicToken) {
url.searchParams.set("token", normalized.publicToken);
}
}
let raceWizardStep = 1; let raceWizardStep = 1;
let raceWizardDraft = null; let raceWizardDraft = null;
let overlaySyncTimer = null; let overlaySyncTimer = null;
@@ -1710,6 +1846,8 @@ function seedDefaultData() {
state.settings.racePresets = []; state.settings.racePresets = [];
} }
state.settings.obsOverlay = normalizeObsOverlaySettings(state.settings.obsOverlay);
state.drivers = state.drivers.map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.drivers = state.drivers.map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name); state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name);
state.events = state.events.map((event) => normalizeEvent(event)); state.events = state.events.map((event) => normalizeEvent(event));
@@ -1754,6 +1892,7 @@ function loadState() {
racePresets: Array.isArray(parsed.settings?.racePresets) racePresets: Array.isArray(parsed.settings?.racePresets)
? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) ? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
: [], : [],
obsOverlay: normalizeObsOverlaySettings(parsed.settings?.obsOverlay),
}, },
decoder: { decoder: {
connected: false, connected: false,
@@ -1795,6 +1934,7 @@ function loadState() {
pdfTheme: "classic", pdfTheme: "classic",
logoDataUrl: "", logoDataUrl: "",
racePresets: [], racePresets: [],
obsOverlay: normalizeObsOverlaySettings(),
}, },
decoder: { decoder: {
connected: false, connected: false,
@@ -2295,6 +2435,7 @@ function applyPersistedState(persisted) {
: Array.isArray(state.settings?.racePresets) : Array.isArray(state.settings?.racePresets)
? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
: [], : [],
obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay),
}; };
applyTheme(); applyTheme();
} }
@@ -6221,7 +6362,7 @@ function renderGuide() {
<div class="guide-section-grid mt-16"> <div class="guide-section-grid mt-16">
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"])} ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"])}
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5"])} ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"])}
</div> </div>
<div class="guide-section-grid mt-16"> <div class="guide-section-grid mt-16">
@@ -6266,6 +6407,7 @@ function renderOverlay() {
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null; [...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const modeLabel = getOverlayModeLabel(overlayViewMode); const modeLabel = getOverlayModeLabel(overlayViewMode);
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : ""; const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
const obsConfig = overlayViewMode === "obs" ? getObsOverlayConfig() : null;
const rotatingPanels = buildOverlayPanels(active, recent); const rotatingPanels = buildOverlayPanels(active, recent);
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null; const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
@@ -6285,12 +6427,50 @@ function renderOverlay() {
<button id="overlayLaunchResults" class="btn" type="button">${t("timing.open_results_overlay")}</button> <button id="overlayLaunchResults" class="btn" type="button">${t("timing.open_results_overlay")}</button>
<button id="overlayLaunchTv" class="btn" type="button">${t("timing.open_tv_overlay")}</button> <button id="overlayLaunchTv" class="btn" type="button">${t("timing.open_tv_overlay")}</button>
<button id="overlayLaunchTeam" class="btn" type="button">${t("timing.open_team_overlay")}</button> <button id="overlayLaunchTeam" class="btn" type="button">${t("timing.open_team_overlay")}</button>
<button id="overlayLaunchObs" class="btn" type="button">${t("timing.open_obs_overlay")}</button>
<button id="overlayCopyObsUrl" class="btn" type="button">${t("overlay.obs_copy_url")}</button>
</div>
<div class="overlay-obs-config">
<div class="panel-header-inline">
<strong>${t("overlay.obs_config")}</strong>
<span>${t("overlay.obs_public_hint")}</span>
</div>
<div class="form-grid cols-4">
<label>
${t("overlay.obs_rows")}
<input id="obsRows" type="number" min="3" max="12" value="${normalizeObsOverlaySettings(state.settings.obsOverlay).rows}" />
</label>
<label>
${t("overlay.obs_layout")}
<select id="obsLayout">
${OBS_LAYOUTS.map((layout) => `<option value="${layout}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).layout === layout ? "selected" : ""}>${t(`overlay.obs_layout_${layout}`)}</option>`).join("")}
</select>
</label>
<label>
${t("overlay.obs_theme")}
<select id="obsTheme">
${OBS_THEMES.map((theme) => `<option value="${theme}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).theme === theme ? "selected" : ""}>${t(`overlay.obs_theme_${theme}`)}</option>`).join("")}
</select>
</label>
<label>
${t("overlay.obs_public_token")}
<input id="obsPublicToken" value="${escapeHtml(normalizeObsOverlaySettings(state.settings.obsOverlay).publicToken)}" placeholder="optional" />
<small>${t("overlay.obs_public_token_hint")}</small>
</label>
<label class="toggle"><input id="obsShowClock" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showClock ? "checked" : ""} /><span>${t("overlay.obs_show_clock")}</span></label>
<label class="toggle"><input id="obsShowFastest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showFastest ? "checked" : ""} /><span>${t("overlay.obs_show_fastest")}</span></label>
<label class="toggle"><input id="obsShowGrid" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGrid ? "checked" : ""} /><span>${t("overlay.obs_show_grid")}</span></label>
<label class="toggle"><input id="obsShowLaps" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showLaps ? "checked" : ""} /><span>${t("overlay.obs_show_laps")}</span></label>
<label class="toggle"><input id="obsShowResult" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showResult ? "checked" : ""} /><span>${t("overlay.obs_show_result")}</span></label>
<label class="toggle"><input id="obsShowBest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showBest ? "checked" : ""} /><span>${t("overlay.obs_show_best")}</span></label>
<label class="toggle"><input id="obsShowGap" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGap ? "checked" : ""} /><span>${t("overlay.obs_show_gap")}</span></label>
</div>
</div> </div>
</div> </div>
</section> </section>
` `
} }
<section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""} ${denseOverlay ? "overlay-shell-dense" : ""}"> <section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""} ${overlayViewMode === "obs" ? "overlay-shell-obs" : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-theme-${obsConfig.theme}` : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-layout-${obsConfig.layout}` : ""} ${denseOverlay ? "overlay-shell-dense" : ""} ${publicOverlayMode ? "overlay-shell-public" : ""}">
${ ${
active active
? ` ? `
@@ -6310,7 +6490,7 @@ function renderOverlay() {
</div> </div>
<div class="overlay-meta"> <div class="overlay-meta">
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button> <button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
<div class="overlay-clock">${overlayClock}</div> ${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock">${overlayClock}</div>`}
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div> <div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
</div> </div>
</header> </header>
@@ -6386,6 +6566,8 @@ function renderOverlay() {
` `
: overlayViewMode === "team" : overlayViewMode === "team"
? renderTeamOverlay(leaderboard, result, sessionTiming) ? renderTeamOverlay(leaderboard, result, sessionTiming)
: overlayViewMode === "obs"
? renderObsOverlay(active, leaderboard, result, sessionTiming, branding)
: overlayViewMode === "tv" : overlayViewMode === "tv"
? ` ? `
<section class="overlay-board overlay-board-tv"> <section class="overlay-board overlay-board-tv">
@@ -6449,6 +6631,224 @@ function renderOverlay() {
document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results")); document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results"));
document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv")); document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv"));
document.getElementById("overlayLaunchTeam")?.addEventListener("click", () => openOverlayWindow("team")); document.getElementById("overlayLaunchTeam")?.addEventListener("click", () => openOverlayWindow("team"));
document.getElementById("overlayLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
document.getElementById("overlayCopyObsUrl")?.addEventListener("click", async () => {
const url = buildOverlayUrl("obs", { public: true });
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url).catch(() => {});
return;
}
window.prompt("Copy OBS URL", url);
});
[
["obsRows", "rows", (value) => Math.max(3, Math.min(12, Number(value) || DEFAULT_OBS_OVERLAY_SETTINGS.rows))],
["obsLayout", "layout", (value) => OBS_LAYOUTS.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.layout],
["obsTheme", "theme", (value) => OBS_THEMES.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.theme],
["obsPublicToken", "publicToken", (value) => String(value || "").trim()],
["obsShowClock", "showClock", (value, el) => Boolean(el?.checked)],
["obsShowFastest", "showFastest", (value, el) => Boolean(el?.checked)],
["obsShowGrid", "showGrid", (value, el) => Boolean(el?.checked)],
["obsShowLaps", "showLaps", (value, el) => Boolean(el?.checked)],
["obsShowResult", "showResult", (value, el) => Boolean(el?.checked)],
["obsShowBest", "showBest", (value, el) => Boolean(el?.checked)],
["obsShowGap", "showGap", (value, el) => Boolean(el?.checked)],
].forEach(([id, key, mapper]) => {
const el = document.getElementById(id);
if (!el) {
return;
}
const handler = () => {
const next = normalizeObsOverlaySettings({
...state.settings.obsOverlay,
[key]: mapper(el.value, el),
});
state.settings.obsOverlay = next;
saveState();
};
el.addEventListener("change", handler);
if (el instanceof HTMLInputElement && (el.type === "number" || el.type === "text")) {
el.addEventListener("input", handler);
}
});
}
function renderObsOverlay(active, leaderboard, result, sessionTiming, branding) {
const obsConfig = getObsOverlayConfig();
const compactRows = leaderboard.slice(0, obsConfig.rows);
const readyPositionGrid = active && active.status === "ready" && normalizeStartMode(active.startMode) === "position";
const showStartGrid = obsConfig.showGrid && readyPositionGrid;
const leadRow = compactRows[0] || null;
const visiblePassings = getVisiblePassings(result);
const elapsedOrRemaining = sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: sessionTiming?.followUpActive
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
if (obsConfig.layout === "grid") {
return `
<section class="overlay-obs-layout overlay-obs-layout-grid">
<div class="overlay-obs-main">
<div class="overlay-obs-brandline">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
<div class="overlay-obs-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
<span>${t("table.laps")}: ${leadRow?.laps || 0}</span>
<span>${t("timing.total_passings")}: ${visiblePassings.length || 0}</span>
</div>
</div>
</div>
${obsConfig.showClock ? `
<div class="overlay-obs-clockblock">
<strong>${elapsedOrRemaining}</strong>
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
</div>
` : ""}
</div>
<section class="overlay-obs-feature overlay-obs-feature-gridonly">
<div class="overlay-section-head">
<h3>${showStartGrid ? t("events.start_grid") : t("overlay.leaderboard_live")}</h3>
<span class="pill">${t("overlay.mode_obs")}</span>
</div>
${showStartGrid ? renderPositionGrid(active) : renderOverlayLeaderboard(compactRows)}
</section>
</section>
`;
}
if (obsConfig.layout === "lowerthird") {
return `
<section class="overlay-obs-lowerthird">
<div class="overlay-obs-lowerthird-head">
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock">${elapsedOrRemaining}</strong>` : ""}
</div>
<div class="overlay-obs-lowerthird-rows">
${compactRows.slice(0, 6).map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-lowerthird-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
${obsConfig.showLaps ? `<span>${t("table.laps")}: ${row.laps ?? 0}</span>` : ""}
${obsConfig.showResult ? `<span>${t("table.result")}: ${escapeHtml(row.resultDisplay)}</span>` : ""}
${obsConfig.showGap ? `<span>${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</span>` : ""}
</article>
`;
}).join("")}
</div>
</section>
`;
}
return `
<section class="overlay-obs-layout">
<div class="overlay-obs-main">
<div class="overlay-obs-brandline">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
<div class="overlay-obs-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
<span>${t("table.laps")}: ${leadRow?.laps || 0}</span>
<span>${t("timing.total_passings")}: ${visiblePassings.length || 0}</span>
</div>
</div>
</div>
${obsConfig.showClock ? `
<div class="overlay-obs-clockblock">
<strong>${elapsedOrRemaining}</strong>
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
</div>
` : ""}
</div>
<div class="overlay-obs-content">
<section class="overlay-obs-feature">
${showStartGrid
? `
<div class="overlay-section-head">
<h3>${t("events.start_grid")}</h3>
<span class="pill">${escapeHtml(getStartModeLabel(active.startMode))}</span>
</div>
${renderPositionGrid(active)}
`
: obsConfig.showFastest
? `
<div class="overlay-section-head">
<h3>${t("overlay.fastest_lap")}</h3>
<span class="pill">${t("overlay.mode_obs")}</span>
</div>
<div class="overlay-fastest-banner overlay-fastest-banner-dense overlay-fastest-banner-obs">
<div class="overlay-fastest-banner-copy">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(leadRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(leadRow?.displayName || leadRow?.driverName || "-")}</div>
<div class="overlay-fastest-meta">${escapeHtml(branding.brandName || "JMK RB RaceController")}</div>
</div>
<div class="overlay-obs-gridhint">
<strong>${t("events.position_grid")}</strong>
<span>${t("overlay.leaderboard_live")}</span>
</div>
`
: `
<div class="overlay-section-head">
<h3>${escapeHtml(branding.brandName || "JMK RB RaceController")}</h3>
<span class="pill">${t("overlay.mode_obs")}</span>
</div>
<div class="overlay-obs-gridhint">
<strong>${escapeHtml(getEventName(active.eventId))}</strong>
<span>${escapeHtml(active.name)}</span>
</div>
`}
</section>
<section class="overlay-obs-standings">
${compactRows.length ? compactRows.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<div class="overlay-obs-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")}</span>
</div>
${obsConfig.showLaps ? `
<div class="overlay-obs-metric">
<label>${t("table.laps")}</label>
<strong>${row.laps ?? 0}</strong>
</div>` : ""}
${obsConfig.showResult ? `
<div class="overlay-obs-metric">
<label>${t("table.result")}</label>
<strong>${escapeHtml(row.resultDisplay)}</strong>
</div>` : ""}
${obsConfig.showBest ? `
<div class="overlay-obs-metric">
<label>${t("table.best_lap")}</label>
<strong>${formatLap(row.bestLapMs)}</strong>
</div>` : ""}
${obsConfig.showGap ? `
<div class="overlay-obs-metric">
<label>${t("table.gap")}</label>
<strong>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</strong>
</div>` : ""}
</article>
`;
}).join("") : `<p>${t("timing.no_laps")}</p>`}
</section>
</div>
</section>
`;
} }
function buildOverlayPanels(active, recent) { function buildOverlayPanels(active, recent) {
@@ -10117,18 +10517,30 @@ function exportSessionHeatSheet(session) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function buildOverlayUrl(mode = "leaderboard") { function buildOverlayUrl(mode = "leaderboard", options = {}) {
const normalizedMode = allowedOverlayModes.includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "leaderboard";
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (options.public) {
url.pathname = `/public-overlay/${normalizedMode}`;
url.search = "";
if (normalizedMode === "obs") {
writeObsOverlayParams(url, state.settings.obsOverlay);
}
return url.toString();
}
url.searchParams.set("view", "overlay"); url.searchParams.set("view", "overlay");
url.searchParams.set("overlayMode", mode); url.searchParams.set("overlayMode", normalizedMode);
if (normalizedMode === "obs") {
writeObsOverlayParams(url, state.settings.obsOverlay);
}
return url.toString(); return url.toString();
} }
function openOverlayWindow(mode = "leaderboard") { function openOverlayWindow(mode = "leaderboard", options = {}) {
const width = Math.max(1280, window.screen?.availWidth || 1600); const width = Math.max(1280, window.screen?.availWidth || 1600);
const height = Math.max(720, window.screen?.availHeight || 900); const height = Math.max(720, window.screen?.availHeight || 900);
const overlayWindow = window.open( const overlayWindow = window.open(
buildOverlayUrl(mode), buildOverlayUrl(mode, options),
"_blank", "_blank",
`noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}` `noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}`
); );

View File

@@ -1289,6 +1289,295 @@ select:focus {
opacity: 1; opacity: 1;
} }
.overlay-shell-public {
min-height: 100vh;
}
.overlay-shell-obs .overlay-header {
margin-bottom: 8px;
}
.overlay-obs-layout {
display: grid;
gap: 8px;
}
.overlay-obs-config {
display: grid;
gap: 10px;
margin-top: 12px;
}
.overlay-obs-config .panel-header-inline {
margin-bottom: 0;
}
.overlay-obs-config .panel-header-inline span {
color: var(--muted);
font-size: 0.78rem;
}
.overlay-obs-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: stretch;
}
.overlay-obs-brandline,
.overlay-obs-feature,
.overlay-obs-standings,
.overlay-obs-clockblock {
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--panel) 90%, transparent);
box-shadow: var(--shadow);
}
.overlay-obs-brandline {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
}
.overlay-obs-logo {
width: 36px;
height: 36px;
}
.overlay-obs-brandline h2 {
margin: 0;
font-family: Orbitron, sans-serif;
font-size: clamp(1rem, 1.65vw, 1.45rem);
line-height: 1.05;
}
.overlay-obs-meta {
display: flex;
flex-wrap: wrap;
gap: 5px 8px;
margin-top: 4px;
color: var(--muted);
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.overlay-obs-clockblock {
min-width: 190px;
padding: 8px 12px;
display: grid;
align-content: center;
justify-items: end;
}
.overlay-obs-clockblock strong {
font-family: Orbitron, sans-serif;
font-size: clamp(1.4rem, 2.2vw, 2.15rem);
line-height: 1;
}
.overlay-obs-clockblock span {
color: var(--muted);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.overlay-obs-content {
display: grid;
grid-template-columns: minmax(240px, 0.78fr) minmax(0, 1.42fr);
gap: 8px;
}
.overlay-obs-feature,
.overlay-obs-standings {
padding: 8px;
}
.overlay-fastest-banner-obs {
min-height: 112px;
}
.overlay-obs-gridhint {
display: grid;
gap: 2px;
margin-top: 8px;
color: var(--muted);
font-size: 0.68rem;
}
.overlay-obs-gridhint strong {
color: var(--text);
font-size: 0.72rem;
}
.overlay-obs-standings {
display: grid;
gap: 4px;
}
.overlay-obs-row {
display: grid;
grid-template-columns: 26px minmax(150px, 1.5fr) repeat(4, minmax(66px, 0.44fr));
gap: 5px;
align-items: center;
padding: 5px 6px;
border: 1px solid color-mix(in srgb, var(--line) 84%, white 16%);
border-radius: 8px;
background: var(--surface-card);
}
.overlay-obs-row-leader {
border-color: color-mix(in srgb, var(--accent) 58%, var(--line) 42%);
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, transparent), var(--surface-card));
}
.overlay-obs-driver strong,
.overlay-obs-metric strong {
display: block;
}
.overlay-obs-driver strong {
font-size: clamp(0.76rem, 0.95vw, 0.9rem);
line-height: 1.05;
}
.overlay-obs-driver span,
.overlay-obs-metric label {
color: var(--muted);
font-size: 0.54rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.overlay-shell-obs-layout-grid .overlay-header,
.overlay-shell-obs-layout-lowerthird .overlay-header {
margin-bottom: 8px;
}
.overlay-obs-feature-gridonly {
padding: 10px;
}
.overlay-obs-lowerthird {
display: grid;
gap: 8px;
align-content: end;
min-height: calc(100vh - 32px);
}
.overlay-obs-lowerthird-head,
.overlay-obs-lowerthird-rows {
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: var(--shadow);
}
.overlay-obs-lowerthird-head {
display: flex;
justify-content: space-between;
align-items: end;
gap: 12px;
padding: 10px 12px;
}
.overlay-obs-lowerthird-head h2 {
margin: 0;
font-family: Orbitron, sans-serif;
font-size: clamp(1rem, 1.6vw, 1.4rem);
}
.overlay-obs-lowerthird-clock {
font-family: Orbitron, sans-serif;
font-size: clamp(1.1rem, 1.8vw, 1.6rem);
}
.overlay-obs-lowerthird-rows {
display: grid;
gap: 4px;
padding: 8px;
}
.overlay-obs-lowerthird-row {
display: grid;
grid-template-columns: 24px minmax(180px, 1fr) repeat(3, auto);
gap: 10px;
align-items: center;
padding: 6px 8px;
border-radius: 8px;
background: var(--surface-card);
}
.overlay-obs-lowerthird-row strong {
font-size: 0.92rem;
line-height: 1.05;
}
.overlay-obs-lowerthird-row span {
color: var(--muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.overlay-shell-obs-theme-transparent {
background: transparent;
}
.overlay-shell-obs-theme-transparent .overlay-obs-brandline,
.overlay-shell-obs-theme-transparent .overlay-obs-feature,
.overlay-shell-obs-theme-transparent .overlay-obs-standings,
.overlay-shell-obs-theme-transparent .overlay-obs-clockblock,
.overlay-shell-obs-theme-transparent .overlay-obs-lowerthird-head,
.overlay-shell-obs-theme-transparent .overlay-obs-lowerthird-rows {
background: color-mix(in srgb, var(--panel) 72%, transparent);
backdrop-filter: blur(10px);
}
.overlay-shell-obs-theme-chroma {
background: #00ff00;
}
.overlay-shell-obs-theme-chroma .overlay-header,
.overlay-shell-obs-theme-chroma .overlay-kicker,
.overlay-shell-obs-theme-chroma .overlay-header-sub,
.overlay-shell-obs-theme-chroma .overlay-status {
color: #101010;
}
.overlay-shell-obs-theme-chroma .overlay-obs-brandline,
.overlay-shell-obs-theme-chroma .overlay-obs-feature,
.overlay-shell-obs-theme-chroma .overlay-obs-standings,
.overlay-shell-obs-theme-chroma .overlay-obs-clockblock,
.overlay-shell-obs-theme-chroma .overlay-obs-lowerthird-head,
.overlay-shell-obs-theme-chroma .overlay-obs-lowerthird-rows,
.overlay-shell-obs-theme-chroma .overlay-fastest-banner-obs {
background: rgba(9, 10, 18, 0.92);
border-color: rgba(255, 255, 255, 0.14);
}
.overlay-shell-obs-theme-chroma .overlay-obs-row,
.overlay-shell-obs-theme-chroma .overlay-obs-lowerthird-row {
background: rgba(20, 22, 33, 0.94);
}
.overlay-shell-obs-theme-chroma .overlay-fullscreen-btn {
background: rgba(20, 22, 33, 0.9);
}
@media (max-width: 1100px) {
.overlay-obs-content {
grid-template-columns: 1fr;
}
.overlay-obs-row {
grid-template-columns: 24px minmax(120px, 1.3fr) repeat(4, minmax(60px, 0.44fr));
}
}
.overlay-speaker { .overlay-speaker {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);