Add public OBS overlay modes, themes and token protection
This commit is contained in:
29
README.md
29
README.md
@@ -27,6 +27,35 @@ JMK RB RaceController is an RC timing and race-control system with support for s
|
||||
### Judging
|
||||

|
||||
|
||||
## 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
|
||||
- Event modes:
|
||||
- `Race (driver transponders)`
|
||||
|
||||
29
README.sv.md
29
README.sv.md
@@ -25,6 +25,35 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
### Domare
|
||||

|
||||
|
||||
## 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
|
||||
- Event-lägen:
|
||||
- `Race (driver transponders)`
|
||||
|
||||
10
server.js
10
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) => {
|
||||
|
||||
432
src/app.js
432
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() {
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${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 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;
|
||||
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() {
|
||||
<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="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>
|
||||
</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
|
||||
? `
|
||||
@@ -6310,7 +6490,7 @@ function renderOverlay() {
|
||||
</div>
|
||||
<div class="overlay-meta">
|
||||
<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>
|
||||
</header>
|
||||
@@ -6386,6 +6566,8 @@ function renderOverlay() {
|
||||
`
|
||||
: overlayViewMode === "team"
|
||||
? renderTeamOverlay(leaderboard, result, sessionTiming)
|
||||
: overlayViewMode === "obs"
|
||||
? renderObsOverlay(active, leaderboard, result, sessionTiming, branding)
|
||||
: overlayViewMode === "tv"
|
||||
? `
|
||||
<section class="overlay-board overlay-board-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 `
|
||||
<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) {
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
289
src/styles.css
289
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);
|
||||
|
||||
Reference in New Issue
Block a user