diff --git a/README.md b/README.md index eb830d3..f65163f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,35 @@ JMK RB RaceController is an RC timing and race-control system with support for s ### Judging ![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/` +- 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 - Event modes: - `Race (driver transponders)` diff --git a/README.sv.md b/README.sv.md index 0de5178..6b32fe1 100644 --- a/README.sv.md +++ b/README.sv.md @@ -25,6 +25,35 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he ### Domare ![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/` +- 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 - Event-lägen: - `Race (driver transponders)` diff --git a/server.js b/server.js index e08873a..75c3597 100644 --- a/server.js +++ b/server.js @@ -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( express.static(__dirname, { setHeaders: (res, filePath) => { diff --git a/src/app.js b/src/app.js index 2b24c95..5df7450 100644 --- a/src/app.js +++ b/src/app.js @@ -438,6 +438,7 @@ const TRANSLATIONS = { "timing.open_results_overlay": "Result overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", + "timing.open_obs_overlay": "OBS overlay", "timing.close_details": "Stang", "timing.detail_title": "Leaderboard-detaljer", "timing.lap_history": "Varvhistorik", @@ -732,12 +733,34 @@ const TRANSLATIONS = { "overlay.mode_results": "Resultat", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", + "overlay.mode_obs": "OBS", "overlay.fastest_lap": "Snabbaste varv", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Displaypanel", "overlay.next_predicted_lap": "Nästa varv", "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.active_member": "Aktiv förare/bil", "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_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_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_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`.", @@ -1195,6 +1221,7 @@ const TRANSLATIONS = { "timing.open_results_overlay": "Results overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", + "timing.open_obs_overlay": "OBS overlay", "timing.close_details": "Close", "timing.detail_title": "Leaderboard details", "timing.lap_history": "Lap history", @@ -1489,12 +1516,34 @@ const TRANSLATIONS = { "overlay.mode_results": "Results", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", + "overlay.mode_obs": "OBS", "overlay.fastest_lap": "Fastest Lap", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Display panel", "overlay.next_predicted_lap": "Next lap", "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.active_member": "Active driver/car", "overlay.top_three": "Top 3", @@ -1537,10 +1586,17 @@ const TRANSLATIONS = { }; const urlParams = new URLSearchParams(window.location.search); -const overlayMode = urlParams.get("view") === "overlay"; -const overlayViewMode = ["leaderboard", "speaker", "results", "tv", "team"].includes(String(urlParams.get("overlayMode") || "").toLowerCase()) - ? String(urlParams.get("overlayMode")).toLowerCase() +const allowedOverlayModes = ["leaderboard", "speaker", "results", "tv", "team", "obs"]; +const publicOverlayMatch = window.location.pathname.match(/^\/public-overlay(?:\/([a-z0-9_-]+))?\/?$/i); +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"; +const publicOverlayMode = Boolean(publicOverlayMatch); const state = loadState(); let currentView = overlayMode ? "overlay" : "dashboard"; let wsClient = null; @@ -1563,6 +1619,86 @@ let quickAddDraft = null; let driverBrandFilter = ""; let carBrandFilter = ""; 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 raceWizardDraft = null; let overlaySyncTimer = null; @@ -1710,6 +1846,8 @@ function seedDefaultData() { state.settings.racePresets = []; } + state.settings.obsOverlay = normalizeObsOverlaySettings(state.settings.obsOverlay); + 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.events = state.events.map((event) => normalizeEvent(event)); @@ -1754,6 +1892,7 @@ function loadState() { racePresets: Array.isArray(parsed.settings?.racePresets) ? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [], + obsOverlay: normalizeObsOverlaySettings(parsed.settings?.obsOverlay), }, decoder: { connected: false, @@ -1795,6 +1934,7 @@ function loadState() { pdfTheme: "classic", logoDataUrl: "", racePresets: [], + obsOverlay: normalizeObsOverlaySettings(), }, decoder: { connected: false, @@ -2295,6 +2435,7 @@ function applyPersistedState(persisted) { : Array.isArray(state.settings?.racePresets) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [], + obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay), }; applyTheme(); } @@ -6221,7 +6362,7 @@ function renderGuide() {
${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"])}
@@ -6266,6 +6407,7 @@ function renderOverlay() { [...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null; const modeLabel = getOverlayModeLabel(overlayViewMode); 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 activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null; @@ -6285,12 +6427,50 @@ function renderOverlay() { + + +
+
+
+ ${t("overlay.obs_config")} + ${t("overlay.obs_public_hint")} +
+
+ + + + + + + + + + + +
` } -
+
${ active ? ` @@ -6310,7 +6490,7 @@ function renderOverlay() {
-
${overlayClock}
+ ${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `
${overlayClock}
`}
${escapeHtml(overlayStatusLabel)}
@@ -6386,6 +6566,8 @@ function renderOverlay() { ` : overlayViewMode === "team" ? renderTeamOverlay(leaderboard, result, sessionTiming) + : overlayViewMode === "obs" + ? renderObsOverlay(active, leaderboard, result, sessionTiming, branding) : overlayViewMode === "tv" ? `
@@ -6449,6 +6631,224 @@ function renderOverlay() { document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results")); document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv")); 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 ` +
+
+
+ ${branding.logoDataUrl ? `` : ""} +
+

${escapeHtml(getEventName(active.eventId))}

+

${escapeHtml(active.name)}

+
+ ${escapeHtml(getSessionTypeLabel(active.type))} + ${escapeHtml(getStartModeLabel(active.startMode))} + ${t("table.laps")}: ${leadRow?.laps || 0} + ${t("timing.total_passings")}: ${visiblePassings.length || 0} +
+
+
+ ${obsConfig.showClock ? ` +
+ ${elapsedOrRemaining} + ${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))} +
+ ` : ""} +
+
+
+

${showStartGrid ? t("events.start_grid") : t("overlay.leaderboard_live")}

+ ${t("overlay.mode_obs")} +
+ ${showStartGrid ? renderPositionGrid(active) : renderOverlayLeaderboard(compactRows)} +
+
+ `; + } + + if (obsConfig.layout === "lowerthird") { + return ` +
+
+
+

${escapeHtml(getEventName(active.eventId))}

+

${escapeHtml(active.name)}

+
+ ${obsConfig.showClock ? `${elapsedOrRemaining}` : ""} +
+
+ ${compactRows.slice(0, 6).map((row, idx) => { + const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; + return ` +
+ ${idx + 1} + ${escapeHtml(row.displayName || row.driverName)} + ${obsConfig.showLaps ? `${t("table.laps")}: ${row.laps ?? 0}` : ""} + ${obsConfig.showResult ? `${t("table.result")}: ${escapeHtml(row.resultDisplay)}` : ""} + ${obsConfig.showGap ? `${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}` : ""} +
+ `; + }).join("")} +
+
+ `; + } + + return ` +
+
+
+ ${branding.logoDataUrl ? `` : ""} +
+

${escapeHtml(getEventName(active.eventId))}

+

${escapeHtml(active.name)}

+
+ ${escapeHtml(getSessionTypeLabel(active.type))} + ${escapeHtml(getStartModeLabel(active.startMode))} + ${t("table.laps")}: ${leadRow?.laps || 0} + ${t("timing.total_passings")}: ${visiblePassings.length || 0} +
+
+
+ ${obsConfig.showClock ? ` +
+ ${elapsedOrRemaining} + ${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))} +
+ ` : ""} +
+
+
+ ${showStartGrid + ? ` +
+

${t("events.start_grid")}

+ ${escapeHtml(getStartModeLabel(active.startMode))} +
+ ${renderPositionGrid(active)} + ` + : obsConfig.showFastest + ? ` +
+

${t("overlay.fastest_lap")}

+ ${t("overlay.mode_obs")} +
+
+
+ ${t("overlay.fastest_lap")} + ${formatLap(leadRow?.bestLapMs)} +
+
${escapeHtml(leadRow?.displayName || leadRow?.driverName || "-")}
+
${escapeHtml(branding.brandName || "JMK RB RaceController")}
+
+
+ ${t("events.position_grid")} + ${t("overlay.leaderboard_live")} +
+ ` + : ` +
+

${escapeHtml(branding.brandName || "JMK RB RaceController")}

+ ${t("overlay.mode_obs")} +
+
+ ${escapeHtml(getEventName(active.eventId))} + ${escapeHtml(active.name)} +
+ `} +
+
+ ${compactRows.length ? compactRows.map((row, idx) => { + const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; + return ` +
+ ${idx + 1} +
+ ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")} +
+ ${obsConfig.showLaps ? ` +
+ + ${row.laps ?? 0} +
` : ""} + ${obsConfig.showResult ? ` +
+ + ${escapeHtml(row.resultDisplay)} +
` : ""} + ${obsConfig.showBest ? ` +
+ + ${formatLap(row.bestLapMs)} +
` : ""} + ${obsConfig.showGap ? ` +
+ + ${escapeHtml(row.gapDisplay || row.gapAhead || "-")} +
` : ""} +
+ `; + }).join("") : `

${t("timing.no_laps")}

`} +
+
+
+ `; } function buildOverlayPanels(active, recent) { @@ -10117,18 +10517,30 @@ function exportSessionHeatSheet(session) { 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); + 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("overlayMode", mode); + url.searchParams.set("overlayMode", normalizedMode); + if (normalizedMode === "obs") { + writeObsOverlayParams(url, state.settings.obsOverlay); + } return url.toString(); } -function openOverlayWindow(mode = "leaderboard") { +function openOverlayWindow(mode = "leaderboard", options = {}) { const width = Math.max(1280, window.screen?.availWidth || 1600); const height = Math.max(720, window.screen?.availHeight || 900); const overlayWindow = window.open( - buildOverlayUrl(mode), + buildOverlayUrl(mode, options), "_blank", `noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}` ); diff --git a/src/styles.css b/src/styles.css index bbf96c3..7e6094c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1289,6 +1289,295 @@ select:focus { 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 { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);