From 10c29174ef5933418befa626b8609410b47a59b7 Mon Sep 17 00:00:00 2001 From: Tyler Agostino Date: Sat, 9 May 2026 22:09:15 -0400 Subject: [PATCH 1/4] f1 quali overlays --- modules/events/__init__.py | 1 + modules/events/f1_qualifying_event.py | 71 +- modules/events/overlay_consumer_event.py | 488 +++++ modules/flet_pages/overlay.css | 2231 ++++++++++++++++++++ modules/flet_pages/overlays/overlay.html | 584 +++++ modules/flet_pages/overlays/standings.html | 459 ++++ modules/flet_pages/race_control.py | 407 ++-- tests/preview_overlay.py | 638 ++++++ 8 files changed, 4712 insertions(+), 167 deletions(-) create mode 100644 modules/events/overlay_consumer_event.py create mode 100644 modules/flet_pages/overlay.css create mode 100644 modules/flet_pages/overlays/overlay.html create mode 100644 modules/flet_pages/overlays/standings.html create mode 100644 tests/preview_overlay.py diff --git a/modules/events/__init__.py b/modules/events/__init__.py index e40151a..f90288e 100644 --- a/modules/events/__init__.py +++ b/modules/events/__init__.py @@ -28,6 +28,7 @@ ATVOTextConsumerEvent, ) from modules.events.chat_consumer_event import ChatConsumerEvent +from modules.events.overlay_consumer_event import OverlayConsumerEvent from modules.events.f1_qualifying_event import F1QualifyingEvent diff --git a/modules/events/f1_qualifying_event.py b/modules/events/f1_qualifying_event.py index 0301596..9b54e21 100644 --- a/modules/events/f1_qualifying_event.py +++ b/modules/events/f1_qualifying_event.py @@ -1,3 +1,5 @@ +import threading + from pandas import DataFrame from modules.events import BaseEvent @@ -60,6 +62,17 @@ def __init__( self.leaderboard[f"Q{n + 1}"] = {} self.leaderboard_df = DataFrame(self.leaderboard) + # Tracks car numbers knocked out in previous sessions so the overlay + # can display them distinctly from drivers still in contention. + self.knocked_out_drivers: set = set() + self.checkered_flag_out: bool = False + self.session_finishers: set = ( + set() + ) # cars that completed final lap this subsession + # Guards every read/write of leaderboard_df so the overlay thread never + # copies a partially-built DataFrame. + self.leaderboard_lock = threading.Lock() + def event_sequence(self): """ Main event sequence that runs the entire qualifying session. @@ -85,6 +98,7 @@ def event_sequence(self): ) def wait_before_next_session(self, seconds, session_number): + self.checkered_flag_out = False wait_start_time = self.sdk["SessionTime"] wait_end_time = wait_start_time + seconds # ----- SESSION PREPARATION PHASE ----- @@ -138,14 +152,14 @@ def apply_new_laptime(self, laps, carNumber, laptime): ] if new_cars_behind: sorted_laps = sorted(laps.items(), key=lambda x: x[1]) - for car in new_cars_behind: - # Give them their new position - self._chat( - f"/{car} You are now P{sorted_laps.index((car, laps[car])) + 1}" - ) - self._chat( - f"/{carNumber} You are now P{sorted_laps.index((carNumber, laps[carNumber])) + 1}" - ) + # for car in new_cars_behind: + # # Give them their new position + # self._chat( + # f"/{car} You are now P{sorted_laps.index((car, laps[car])) + 1}" + # ) + # self._chat( + # f"/{carNumber} You are now P{sorted_laps.index((carNumber, laps[carNumber])) + 1}" + # ) return laps def update_leaderboard(self, fastest_laps, session_number, send_msg=True): @@ -196,23 +210,24 @@ def update_leaderboard(self, fastest_laps, session_number, send_msg=True): # Update session leaderboard self.leaderboard[f"Q{session_number}"][car] = lap - # Update the dataframe representation of the leaderboard - self.leaderboard_df = DataFrame(self.leaderboard) - driver_names = { - c["CarNumber"]: c["UserName"] for c in self.sdk["DriverInfo"]["Drivers"] - } - self.leaderboard_df["Driver"] = self.leaderboard_df.index.map(driver_names) - # sort df columns - self.leaderboard_df = self.leaderboard_df[ - ["Driver"] + [f"Q{n + 1}" for n in range(len(self.session_minutes))] - ] - - # Sort the dataframe by lap times, prioritizing higher qualifying sessions - sessions = [f"Q{n + 1}" for n in range(len(self.session_minutes))] - sessions.reverse() - self.leaderboard_df = self.leaderboard_df.sort_values( - by=sessions, ascending=True - ) + # Rebuild the DataFrame under the lock so the overlay thread never + # copies a half-constructed frame. + with self.leaderboard_lock: + self.leaderboard_df = DataFrame(self.leaderboard) + driver_names = { + c["CarNumber"]: c["UserName"] for c in self.sdk["DriverInfo"]["Drivers"] + } + self.leaderboard_df["Driver"] = self.leaderboard_df.index.map(driver_names) + # sort df columns + self.leaderboard_df = self.leaderboard_df[ + ["Driver"] + [f"Q{n + 1}" for n in range(len(self.session_minutes))] + ] + # Sort the dataframe by lap times, prioritising higher qualifying sessions + sessions = [f"Q{n + 1}" for n in range(len(self.session_minutes))] + sessions.reverse() + self.leaderboard_df = self.leaderboard_df.sort_values( + by=sessions, ascending=True + ) def subsession( self, length, num_drivers_remain, session_number, subset_of_drivers=None @@ -232,6 +247,7 @@ def subsession( self.subsession_name = f"Q{session_number}" self._chat(f"Pit Exit is OPEN.", race_control=True) self.waiting_on = None + self.session_finishers = set() # ----- SESSION RUNNING PHASE ----- session_time_at_start = self.sdk["SessionTime"] @@ -300,6 +316,7 @@ def subsession( self._chat(f"Time Remaining: {self.subsession_time_remaining}") if out_of_time: + self.checkered_flag_out = True self._chat( f"Checkered flag is out for Q{session_number}!", race_control=True ) @@ -390,6 +407,7 @@ def subsession( > 30 ): remaining_cars.remove(car["CarNumber"]) + self.session_finishers.add(car["CarNumber"]) if first_car_to_take_checkered is None: first_car_to_take_checkered = car["CarNumber"] self._chat( @@ -408,6 +426,7 @@ def subsession( fastest_laps, session_number, send_msg=False ) remaining_cars.remove(car["CarNumber"]) + self.session_finishers.add(car["CarNumber"]) if first_car_to_take_checkered is None: first_car_to_take_checkered = car["CarNumber"] self._chat( @@ -422,6 +441,7 @@ def subsession( carIdx = driver_info_record[0]["CarIdx"] if self.sdk["CarIdxOnPitRoad"][carIdx] == 1: remaining_cars.remove(car["CarNumber"]) + self.session_finishers.add(car["CarNumber"]) self._chat(f"/{car['CarNumber']} Checkered Flag.") if lap_still_valid_reminder.__next__(): @@ -457,6 +477,7 @@ def subsession( # Notify eliminated drivers for car in eliminated_drivers: + self.knocked_out_drivers.add(car) self._chat(f"/{car} you have been eliminated from Q{session_number}!") # Notify advancing drivers diff --git a/modules/events/overlay_consumer_event.py b/modules/events/overlay_consumer_event.py new file mode 100644 index 0000000..0aca1c5 --- /dev/null +++ b/modules/events/overlay_consumer_event.py @@ -0,0 +1,488 @@ +""" +OverlayConsumerEvent +==================== +Serves browser-based OBS overlays over a local HTTP server. + +Endpoints +--------- +GET / – Index page with links to available overlays. +GET /rc-message – Race-control message banner (transparent; place at bottom). +GET /f1-timing – F1 qualifying timing tower (transparent; place at left). +GET /static/overlay.css – CSS theme file (swappable via ``css_file`` constructor arg). +GET /static/fonts/ – Font files resolved from the same directory as the CSS. +GET /sse/rc – Server-Sent Events stream for race-control messages. +GET /sse/f1 – Server-Sent Events stream for F1 timing state. + +Usage +----- +Add a **single** Browser Source in OBS pointed at:: + + http://localhost:/ + +Enable "Allow transparency" in the Browser Source settings. +The page displays whichever overlays have active data at any given moment: + +Integration with F1 Qualifying +------------------------------- +After starting the F1QualifyingEvent, set ``overlay_event.f1_event = f1_event`` so +the overlay server can poll the live leaderboard. Clear it on stop. + +Message queue +------------- +The overlay consumer reads from the shared ``broadcast_text_queue`` just like the +Discord/SDK text consumers. If you want both Discord and overlay running at the +same time, be aware that each message is consumed by one reader only; choose one +consumer per deployment or extend SubprocessManager with fan-out if needed. +""" + +import json +import queue +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from math import isnan +from pathlib import Path +from typing import TYPE_CHECKING + +from modules.events import BaseEvent + +if TYPE_CHECKING: + from modules.events.f1_qualifying_event import F1QualifyingEvent + +# --------------------------------------------------------------------------- # +# Paths +# --------------------------------------------------------------------------- # + +_THIS_DIR = Path(__file__).parent +_OVERLAY_HTML = _THIS_DIR.parent / "flet_pages" / "overlays" / "overlay.html" +_DEFAULT_CSS = _THIS_DIR.parent / "flet_pages" / "overlay.css" +_STANDINGS_HTML = _THIS_DIR.parent / "flet_pages" / "overlays" / "standings.html" +# Fonts live next to the CSS file +_FONTS_DIR = _DEFAULT_CSS.parent / "fonts" + + +# --------------------------------------------------------------------------- # +# HTTP server +# --------------------------------------------------------------------------- # + + +class _OverlayHTTPServer(ThreadingHTTPServer): + """ThreadingHTTPServer with sane defaults for a local overlay server. + + allow_reuse_address – lets the server rebind to the same port immediately + after a stop/start cycle without hitting 'Address + already in use' errors. + daemon_threads – request-handler threads (including long-lived SSE + connections) are killed automatically when the main + event thread exits, so they never block shutdown. + """ + + allow_reuse_address = True + daemon_threads = True + + +# --------------------------------------------------------------------------- # +# Event +# --------------------------------------------------------------------------- # + + +class OverlayConsumerEvent(BaseEvent): + """ + Consumer event that serves transparent HTML overlays for OBS browser sources. + + Parameters + ---------- + port : int + TCP port for the HTTP server (default 8765). + width : int + Overlay canvas width in pixels (default 1920). + height : int + Overlay canvas height in pixels (default 1080). + css_file : str + Absolute or relative path to an alternate CSS theme file. + Leave empty to use the bundled ``overlay.css``. + """ + + def __init__( + self, + port: int = 8765, + width: int = 1920, + height: int = 1080, + css_file: str = "", + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.port = int(port) + self.width = int(width) + self.height = int(height) + self.css_path = Path(css_file) if css_file else _DEFAULT_CSS + + # Per-overlay SSE client queues: list of queue.Queue, one per connected tab. + self._rc_clients: list[queue.Queue] = [] + self._f1_clients: list[queue.Queue] = [] + self._clients_lock = threading.Lock() + + # Set this to a running F1QualifyingEvent to enable the timing-tower overlay. + self.f1_event: F1QualifyingEvent | None = None + + self._server = None + + # ---------------------------------------------------------------------- # + # Public broadcast helpers (called from event_sequence loop) + # ---------------------------------------------------------------------- # + + def push_rc_message(self, title: str, text: str) -> None: + """Broadcast a race-control banner to all connected /rc-message clients.""" + payload = json.dumps({"title": title, "text": text}) + with self._clients_lock: + for q in list(self._rc_clients): + q.put(payload) + + def push_f1_state(self, state: dict) -> None: + """Broadcast an F1 timing-tower update to all connected /f1-timing clients.""" + payload = json.dumps(state) + with self._clients_lock: + for q in list(self._f1_clients): + q.put(payload) + + # ---------------------------------------------------------------------- # + # BaseEvent overrides + # ---------------------------------------------------------------------- # + + def event_sequence(self) -> None: + handler_class = self._build_handler() + self._server = _OverlayHTTPServer(("", self.port), handler_class) + + server_thread = threading.Thread( + target=self._server.serve_forever, daemon=True, name="overlay-http" + ) + server_thread.start() + self.logger.info("Overlay server listening on http://localhost:%d/", self.port) + + # ---- Main loop: drain queues and push state to SSE clients ---- # + last_f1_state: dict | None = None + try: + while True: + try: + # Race-control messages + try: + msg = self.broadcast_text_queue.get_nowait() + if isinstance(msg, dict): + self.push_rc_message( + msg.get("title", "Race Control"), + msg.get("text", ""), + ) + except queue.Empty: + pass + + # F1 timing tower state + if self.f1_event is not None: + state = self._build_f1_state() + # Only push when state actually changes to reduce traffic. + if state and state != last_f1_state: + self.push_f1_state(state) + last_f1_state = state + + except Exception as exc: + # Never let a single bad frame kill the HTTP server. + self.logger.warning( + "Overlay loop error (server kept alive): %s", exc, exc_info=True + ) + + try: + self.sleep(0.5) + except KeyboardInterrupt: + break + finally: + # Always shut down and close the socket so the port is freed + # immediately and a restart can rebind without 'Address in use'. + self._server.shutdown() + self._server.server_close() + self.logger.info("Overlay server stopped.") + + # ---------------------------------------------------------------------- # + # F1 state serialiser + # ---------------------------------------------------------------------- # + + def _build_f1_state(self) -> dict | None: + """Read the live F1 event and return a JSON-serialisable timing-tower state.""" + ev = self.f1_event + if ev is None: + return None + try: + # Acquire the leaderboard lock (if present) so we never copy a + # DataFrame that the F1 event thread is currently rebuilding. + df_lock = getattr(ev, "leaderboard_lock", None) + if df_lock is not None: + with df_lock: + df = ev.leaderboard_df.copy() + else: + df = ev.leaderboard_df.copy() + + session_name = ev.subsession_name or "" + time_remaining = ev.subsession_time_remaining or "--:--" + checkered_flag = getattr(ev, "checkered_flag_out", False) + q_cols = [c for c in df.columns if c != "Driver"] + + if df.empty: + return { + "session_name": session_name, + "time_remaining": time_remaining, + "checkered_flag": checkered_flag, + "sessions": q_cols, + "drivers": [], + } + + # Determine whether we are inside an active Qn session. + is_active_session = session_name.startswith( + "Q" + ) and not session_name.startswith("Pre-") + current_q_num = None + session_advancing = None + driver_at_risk_idx = None + + if is_active_session: + try: + current_q_num = int(session_name[1:]) + session_idx = current_q_num - 1 + if 0 <= session_idx < len(ev.session_advancing_cars): + session_advancing = ev.session_advancing_cars[session_idx] + if 0 < session_advancing <= len(df): + driver_at_risk_idx = session_advancing - 1 + except (ValueError, IndexError): + pass + + knocked_out_set = getattr(ev, "knocked_out_drivers", set()) + session_finishers = getattr(ev, "session_finishers", set()) + drivers = [] + + for pos, (car_num, row) in enumerate(df.iterrows()): + # Best lap time across all sessions (smallest positive value). + best_time = None + for col in q_cols: + val = row.get(col) + if isinstance(val, (int, float)) and not isnan(val) and val > 0: + if best_time is None or val < best_time: + best_time = val + + # Per-session individual lap times for the standings overlay. + session_times: dict[str, str] = {} + for col in q_cols: + val = row.get(col) + if isinstance(val, (int, float)) and not isnan(val) and val > 0: + session_times[col] = _fmt_time(val) + else: + session_times[col] = "" + + # Has this driver NOT yet set a lap in the CURRENT session? + # (Only meaningful during active sessions; knocked-out drivers are excluded.) + no_current_time = False + if ( + is_active_session + and current_q_num is not None + and car_num not in knocked_out_set + ): + current_q_col = f"Q{current_q_num}" + cval = row.get(current_q_col) + no_current_time = ( + cval is None + or not isinstance(cval, (int, float)) + or isnan(cval) + or cval <= 0 + ) + + # Determine status. + status = self._driver_status( + car_num=car_num, + row=row, + pos=pos, + is_active_session=is_active_session, + current_q_num=current_q_num, + session_advancing=session_advancing, + driver_at_risk_idx=driver_at_risk_idx, + knocked_out=knocked_out_set, + ) + + drivers.append( + { + "position": pos + 1, + "car_num": str(car_num), + "driver_name": str(row.get("Driver", "Unknown")), + "best_time": _fmt_time(best_time), + "status": status, + "session_times": session_times, + "no_current_time": no_current_time, + "finished": car_num in session_finishers, + } + ) + + return { + "session_name": session_name, + "time_remaining": time_remaining, + "checkered_flag": checkered_flag, + "sessions": q_cols, + "drivers": drivers, + } + + except Exception: + return None + + @staticmethod + def _driver_status( + car_num, + row, + pos: int, + is_active_session: bool, + current_q_num: int | None, + session_advancing: int | None, + driver_at_risk_idx: int | None, + knocked_out: set, + ) -> str: + """Return one of: 'safe', 'at_risk', 'elimination_zone', 'knocked_out'.""" + # Explicitly tracked knocked-out drivers always take priority. + if car_num in knocked_out: + return "knocked_out" + + if not is_active_session or session_advancing is None or current_q_num is None: + return "safe" + + # Drivers who have advanced to this session but not yet set a lap time are + # shown as 'safe' (not eliminated – they just haven't run yet). + current_q_col = f"Q{current_q_num}" + val = row.get(current_q_col) + no_current_time = ( + val is None or not isinstance(val, (int, float)) or isnan(val) or val <= 0 + ) + if no_current_time: + return "safe" + + # Classify by finishing position relative to the cutoff. + if session_advancing <= 0: + return "safe" + if driver_at_risk_idx is None: + return "safe" + if pos > driver_at_risk_idx: + return "elimination_zone" + if pos == driver_at_risk_idx: + return "at_risk" + return "safe" + + # ---------------------------------------------------------------------- # + # HTTP handler factory + # ---------------------------------------------------------------------- # + + def _build_handler(self): + """Return a BaseHTTPRequestHandler class closed over *self* (the event).""" + event = self + + class OverlayHandler(BaseHTTPRequestHandler): + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/sse/rc": + self._handle_sse(event._rc_clients) + elif path == "/sse/f1": + self._handle_sse(event._f1_clients) + elif path == "/static/overlay.css": + self._serve_file(event.css_path, "text/css") + elif path.startswith("/static/fonts/"): + font_name = path[len("/static/fonts/") :] + self._serve_file(_FONTS_DIR / font_name, "font/truetype") + elif path == "/standings": + self._serve_html(_STANDINGS_HTML) + else: + # All other paths (including "/") serve the combined overlay. + self._serve_html(_OVERLAY_HTML) + + # ---------------------------------------------------------------- # + # SSE + # ---------------------------------------------------------------- # + + def _handle_sse(self, client_list: list): + """Hold the connection open and stream JSON data events.""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + client_q: queue.Queue = queue.Queue() + with event._clients_lock: + client_list.append(client_q) + + try: + while True: + try: + data = client_q.get(timeout=15) + self.wfile.write(f"data: {data}\n\n".encode()) + self.wfile.flush() + except queue.Empty: + # Keepalive comment keeps the connection alive. + self.wfile.write(b": ping\n\n") + self.wfile.flush() + except Exception: + pass + finally: + with event._clients_lock: + if client_q in client_list: + client_list.remove(client_q) + + # ---------------------------------------------------------------- # + # Static files + # ---------------------------------------------------------------- # + + def _serve_html(self, file_path: Path): + if not file_path.exists(): + self.send_error(404, f"Overlay not found: {file_path.name}") + return + content = file_path.read_text(encoding="utf-8") + # Simple template substitution for width/height/port. + content = ( + content.replace("{{WIDTH}}", str(event.width)) + .replace("{{HEIGHT}}", str(event.height)) + .replace("{{PORT}}", str(event.port)) + ) + body = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _serve_file(self, file_path: Path, mime: str): + if not file_path.exists(): + self.send_error(404, f"Not found: {file_path.name}") + return + data = file_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + # Suppress default access-log noise. + def log_message(self, format, *args): # noqa: N802 + pass + + return OverlayHandler + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _fmt_time(seconds) -> str: + """Format a lap time given in seconds as MM:SS.mmm.""" + if ( + seconds is None + or not isinstance(seconds, (int, float)) + or isnan(seconds) + or seconds <= 0 + ): + return "" + mins = int(seconds // 60) + secs = int(seconds % 60) + millis = int((seconds % 1) * 1000) + return f"{mins:02d}:{secs:02d}.{millis:03d}" diff --git a/modules/flet_pages/overlay.css b/modules/flet_pages/overlay.css new file mode 100644 index 0000000..0199863 --- /dev/null +++ b/modules/flet_pages/overlay.css @@ -0,0 +1,2231 @@ +/* FONTS */ + +@font-face { + font-family: Saira-Black; + src: url("fonts/Saira-Black.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Bold; + src: url("fonts/Saira-Bold.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-ExtraBold; + src: url("fonts/Saira-ExtraBold.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-ExtraLight; + src: url("fonts/Saira-ExtraLight.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Light; + src: url("fonts/Saira-Light.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Medium; + src: url("fonts/Saira-Medium.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Regular; + src: url("fonts/Saira-Regular.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-SemiBold; + src: url("fonts/Saira-SemiBold.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Thin; + src: url("fonts/Saira-Thin.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-BlackItalic; + src: url("fonts/Saira-BlackItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-BoldItalic; + src: url("fonts/Saira-BoldItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-ExtraBoldItalic; + src: url("fonts/Saira-ExtraBoldItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-ExtraLightItalic; + src: url("fonts/Saira-ExtraLightItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-Italic; + src: url("fonts/Saira-Italic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-LightItalic; + src: url("fonts/Saira-LightItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-MediumItalic; + src: url("fonts/Saira-MediumItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-SemiBoldItalic; + src: url("fonts/Saira-SemiBoldItalic.ttf") format("opentype"); +} + +@font-face { + font-family: Saira-ThinItalic; + src: url("fonts/Saira-ThinItalic.ttf") format("opentype"); +} + +/* GLOBAL VARIABLES */ + +.overlay { + --sf-color: white; + --sf-text-color: white; + --sf-p1-text-color: #9a58ea; + --sf-togo-text-color: #fc5500; + --sf-gradation-background: linear-gradient( + to right, + black 40%, + var(--sf-color) 100% + ); + --replay-state-background-color: rgba(235, 235, 235, 0.9); + --header-logo: var(--series-image-url); + --left-footer-logo: url(https://www.apexracingtv.com/wp-content/uploads/vrs-logo-white.png); + --left-footer-logo-filter: none; + --right-footer-logo: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); + --right-footer-logo-filter: none; + --result-subtitle-logo: url(https://www.sdk-gaming.co.uk/wp-content/uploads/SF_result1.png); + --driver-position-background-color: #444444; + --driver-position-border-color: transparent; + --driver-position-text-color: white; + --driver-selected-color: green; + --driver-background-color: #20354d; /* Background of driver names*/ + --driver-background-color-darker: #000; /* Alternating darker background driver names*/ + --text-color: white; /* Text of driver names*/ + --text-shadow: none; + --driver-dimmed-color: #bbb; + --driver-first-name-color: white; + --driver-number-color: white; + --driver-number-outline-color: black; + --window-header-text-color: white; + --header-title-text-color: white; + --panel-background-color: #675a6bdd; + --panel-surface-background-color: #6b6b6b; + --panel-surface-background-color-light: #6b6b6bdd; + --panel-title-background-color-darker: black; + --window-header-background-color: #444444dd; + --header-title-background-color: linear-gradient( + -20deg, + black 50%, + grey 100% + ); + --header-title-background-color-lighter: linear-gradient( + to right, + black 4%, + grey 20% + ); + --header-title-background-color-alter: linear-gradient( + -20deg, + var(--sf-color) 2%, + grey 20%, + black 100% + ); + --pit-lane-background-color: #000; + --panel-background-color: #000; + --driver-close-color: white; + --driver-very-close-color: darkorange; + --driver-gain-color: lime; + --driver-loss-color: orange; + --driver-same-color: white; + --driver-personal-best-time-color: lime; + --driver-overall-best-time-color: magenta; + --driver-personal-best-sector-color: lime; + --driver-overall-best-sector-color: magenta; + --driver-completed-sector-color: darkorange; + --driver-current-sector-color: black; + --driver-out-lap-color: #00c0ff; + --driver-pit-lane-color: #7acdda; + --driver-pit-exit-color: limegreen; + --normal-font-size: 19px; + --driver-lapped-color: #87cefa; + --driver-lapping-color: red; + --black-font: Saira-Black; + --bold-font: Saira-Bold; + --extra-bold-font: Saira-ExtraBold; + --extra-light-font: Saira-ExtraLight; + --light-font: Saira-Light; + --medium-font: Saira-Medium; + --regular-font: Saira-Regular; + --semibold-font: Saira-SemiBold; + --thin-font: Saira-Thin; + --black-font-italic: Saira-BlackItalic; + --bold-font-italic: Saira-BoldItalic; + --extra-bold-font-italic: Saira-ExtraBoldItalic; + --extra-light-font-italic: Saira-ExtraLightItalic; + --light-font-italic: Saira-LightItalic; + --medium-font-italic: Saira-MediumItalic; + --regular-font-italic: Saira-Italic; + --semibold-font-italic: Saira-SemiBoldItalic; + --thin-font-italic: Saira-ThinItalic; + --car-number-font: var(--bold-font-italic); + --car-number-font-size: 23px; + --class-header-font: var(--medium-font); + --stats-color: white; + --fore-color: white; + --back-color: white; + --timer-font-color: white; + --timer-left-font-color: white; + --timer-font-size: 26px; + --timer-title-height: 30px; + --timer-time-height: 40px; +} +/* POSITION 1 HIGHLIGHT */ + +.overlay .p1 { + --driver-position-background-color: #63030c; + --driver-position-border-color: transparent; + --driver-position-text-color: white; +} + +/* LOGO */ + +.overlay > .logo > .logo-image { + background-image: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); + animation: logo-1-2 20s linear infinite; +} + +.overlay > .logo > .logo-image-2 { + background-image: var(--series-image-url); + transform: perspective(200px) tranlateY(-20px); + transform-origin: top center; + animation: logo-2-2 20s linear infinite; +} + +/* LOGO 2 */ + +.overlay.replay-mode > .logo, +.overlay.replay-mode > .logo2 { + opacity: 0; + transition: opacity 0s; +} + +.overlay > .logo2 > .logo-image { + background-image: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); +} + +/* DRIVER */ + +.driver, +.driver-wrapper > .multicar-team { + height: 27px; + background: #222222ee !important; /*linear-gradient(#ccc, #777, #222) !important;*/ + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + border: none !important; + border-radius: 0 0 0 0 !important; + color: white !important; + text-shadow: + 1.5px 1px 1px #333, + -0.3px -0.3px 0.3px #333, + -0.3px 0.3px 0.3px #333, + 0.3px -0.3px 0.3px #333, + 0.3px 0.3px 0.3px #333 !important; + overflow: visible !important; + padding-right: 1px; + border-top: solid var(--panel-surface-background-color) 2px !important; +} + +.driver > .color-stripe, +.driver > .driver-background, +.driver-wrapper > .multicar-team > .color-stripe, +.driver-wrapper > .multicar-team > .multicar-team-background { + display: none; +} + +.driver > .container > .data-wrapper, +.driver > .container > .interval, +.driver > .container > .number, +.driver > .container > .flag, +.driver > .container > .gain, +.driver > .container > .flag-wrapper-7x5, +.driver > .container > .pit-wrapper, +.driver > .container > .last-lap-time-wrapper { + line-height: 27px !important; +} + +.driver > .container > .pit-wrapper > .pit { + text-align: center; +} + +.driver > .container { + display: flow-root; + overflow: visible !important; +} + +.name-wrapper { + display: inline-block; + height: 26px !important; + border-top: solid #ccc 0px; + border-right: solid #ccc 0px; + border-left: solid #ccc 0px; + border-radius: 0 0 0 0; + padding-left: 2px !important; + margin-left: 0px !important; + margin-right: 0px !important; + color: white; + text-shadow: none; + background: var(--driver-background-color); + min-width: 30px; +} + +.name-wrapper > .name { + position: absolute; + line-height: 25px !important; + letter-spacing: 0 !important; +} + +.name-wrapper > .first-name, +.name-wrapper > .last-name, +.name-wrapper > .suffix { + display: inline-block; + line-height: 25px !important; + letter-spacing: 0 !important; + color: #ffffff !important; +} + +.name-wrapper > .first-name { + padding-left: 0 !important; +} + +.name-wrapper > .last-name { + padding-left: 8px !important; + font-family: var(--medium-font-italic); +} + +.name-wrapper > .suffix { + padding-right: 20px !important; +} + +.driver > .container > .data-wrapper.show-pit-count.allow-data { + width: 115px !important; +} + +.driver > .container > .data-wrapper.show-last-pit-lap.allow-data { + width: 115px !important; +} + +.driver > .container > .data-wrapper.show-last-pit-time.allow-data, +.driver > .container > .data-wrapper.show-last-pit-lane-time.allow-data { + width: 115px !important; +} + +.driver > .container > .data-wrapper.show-off-track-count.allow-data { + color: gold; +} + +/* POSITION BOX */ + +.position-wrapper { + position: relative; + margin-left: 8px; + z-index: 10; + clip-path: none; + width: 36px; + height: 23px; + transform: translateX(-8px); + border-radius: 0 0 0 0; + border-color: var(--driver-position-border-color) !important; + border-width: 2px !important; + border-style: solid; +} + +.position-wrapper > .position { + font-size: 24px; + line-height: 22px; + height: 24px; + font-family: var(--bold-font); + text-shadow: + 1.5px 1px 1px #202, + -0.3px -0.3px 0.3px #202, + -0.3px 0.3px 0.3px #202, + 0.3px -0.3px 0.3px #202, + 0.3px 0.3px 0.3px #202; +} + +.position-wrapper:before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: transparent; +} + +.position-displays-selected-driver.driver > .position-wrapper > .position, +.position-displays-selected-driver > .driver > .position-wrapper > .position { + color: var(--sf-text-color); + text-shadow: none; +} + +/* BATTLE BOX */ + +.overlay > .battle > .header-wrapper, +.overlay > .battle > .data-wrapper { + margin-right: 0px; + overflow: visible; +} + +.overlay > .battle > .header-wrapper { + margin-bottom: 10px; + height: 27px; +} + +.overlay > .battle > .header-wrapper > .driver { + border-radius: 0 0 0 0 !important; + border-right: solid 1px #ccc; +} + +.overlay > .battle > .header-wrapper > .driver > .driver-background { + display: block; + background: black; + color: white; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222 !important; + font-family: var(--medium-font); + letter-spacing: 0; +} + +.overlay > .battle .driver > .container > .interval { + width: 60px; +} + +/* STATE HEADER */ + +.overlay > .state-header { + top: 40px; + left: 54px; + overflow: visible; + transform: translateZ(0px); +} + +.overlay > .state-header > .container { + border-radius: 0 0 0 0; + border: 2px solid black; + border-radius: 0 10px 0 0; + color: white; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + background: var(--driver-position-background-color); + font-family: var(--semibold-font); +} + +/* DRIVER DETAILS */ + +.overlay > .driver-details { + /* Driver Details - Shows driver and team name etc*/ + left: 40px; + bottom: 20px; + transform: perspective(1000px) translateZ(0px); +} + +.overlay.pit-lane > .driver-details { + bottom: 220px; +} + +.overlay.hide > .driver-details { + opacity: 1; + background: blue; +} + +.overlay > .driver-details.hide { + animation: sf-go-hide 0.5s; + animation-timing-function: ease; + opacity: 0; +} + +.driver-details > .data-wrapper { + background: url(https://www.sdk-gaming.co.uk/wp-content/uploads/SF_driverInfo.png); + background-size: 100% 100%; + background-repeat: no-repeat; +} + +.driver-details > .data-wrapper > .upper-data-wrapper { + height: 50px !important; + margin-left: 0px; + margin-top: 0px; + background: transparent !important; + border-top: 0px solid var(--panel-surface-background-color) !important; +} + +.upper-data-wrapper > .text { + line-height: 34px !important; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .name-wrapper { + margin-left: 0px !important; + height: 46px !important; + background: transparent; + border-bottom: 2px solid var(--sf-color) !important; + min-width: 240px; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .name-wrapper + > .first-name { + line-height: 58px !important; + height: 46px !important; + font-family: var(--medium-font-italic); + font-size: 24px; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .name-wrapper + > .last-name { + line-height: 58px !important; + height: 46px !important; + font-family: var(--medium-font-italic); + font-size: 24px; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .name-wrapper + > .suffix { + line-height: 48px !important; + height: 46px !important; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .number { + margin-top: 16px; + line-height: 28px !important; + height: 24px !important; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .flag { + padding-top: 15px; + height: 28px !important; + width: 26px; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .container + > .gain { + line-height: 48px !important; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .position-wrapper { + margin-top: 8px; + margin-left: 22px !important; + height: 32px; + border-top-left-radius: 16px; +} + +.overlay + > .driver-details + > .data-wrapper + > .upper-data-wrapper + > .position-wrapper + > .position { + line-height: 34px; + padding-left: 1px; + transition: transform 0.2s ease-out 0.3s; + font-family: var(--medium-font-italic) !important; +} + +.overlay > .driver-details.photo > .data-wrapper > .upper-data-wrapper, +.overlay > .driver-details.helmet > .data-wrapper > .upper-data-wrapper { + padding-right: 130px; +} + +.overlay > .driver-details > .data-wrapper > .photo-wrapper > .helmet { + transform: scale(1); + padding-bottom: 20px; +} + +.overlay > .driver-details > .data-wrapper > .photo-wrapper > .photo { + transform: scale(1); + padding-bottom: 10px; +} + +.overlay > .driver-details.photo > .data-wrapper > .photo-wrapper > .photo, +.overlay > .driver-details.helmet > .data-wrapper > .photo-wrapper > .helmet { + right: 16px; +} + +.overlay > .driver-details > .data-wrapper > .middle-data-wrapper { + background: transparent !important; + border-left: solid #ccc 0px; + border-right: solid #ccc 0px; + height: 40px !important; + color: white; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.overlay > .driver-details > .data-wrapper > .lower-data-wrapper { + display: none; +} + +.overlay > .driver-details.hide > .data-wrapper > .middle-data-wrapper, +.overlay > .driver-details.hide > .data-wrapper > .lower-data-wrapper { + opacity: 0; +} + +.overlay > .driver-details.team > .data-wrapper > .lower-data-wrapper { + opacity: 0; +} + +.middle-data-wrapper > .text { + padding-left: 62px !important; + line-height: 34px; +} + +.middle-data-wrapper > .text, +.lower-data-wrapper > .text1, +.lower-data-wrapper > .data1, +.lower-data-wrapper > .text2, +.lower-data-wrapper > .data2 { + font-family: var(--medium-font); +} + +.driver-details.team.lap-times .middle-data-wrapper { + border-radius: 0 0 0 0; +} + +.driver-details.unknown-position + > .data-wrapper + > .upper-data-wrapper + > .position-wrapper { + border: none; +} + +/* TIMING TOWER */ + +.overlay > .standings { + top: 36px; + left: 34px; + transform: perspective(1000px) translateZ(0px); + transform-origin: top left; + font-family: var(--medium-font); +} + +.standings > .driver-wrapper { + top: 0 !important; + /*padding-bottom: 2px !important;*/ +} + +.overlay + > .standings + > .driver-wrapper.lapped:not(.dimmed) + > .driver + > .container + > .name-wrapper { + color: var(--driver-lapped-color); +} + +.overlay > .standings > .driver-wrapper.show-driver.allow-driver.out { + display: none; +} + +.overlay > .standings.grouped-header-class-color > .header-wrapper { + background: linear-gradient(90deg, #000, #222, #666); + border: none; + color: var(--class-color, var(--panel-background-color)); +} + +.overlay > .standings > .header-wrapper > .header { + min-width: 100px; + font-family: var(--medium-font); +} + +.overlay > .standings > .driver-wrapper > .driver > .container > .name-wrapper { + margin-left: -4px; + margin-right: 4px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.full-name { + width: 195px; +} + +/* LAST.F */ +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.short-name { + width: 150px; +} + +/* F.LAST */ +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.short-name-2 { + width: 150px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.last-name { + width: 150px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.team-name { + width: 280px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.initials { + width: 45px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper.three-letters { + width: 52px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .name-wrapper + > .name { + font-family: var(--bold-font); +} + +.overlay > .standings > .driver-wrapper.dimmed > .driver { + color: var(--driver-dimmed-color); +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-gap.allow-data { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver.p1 + > .container + > .data-wrapper.show-gap.allow-data + > .data { + width: 95px; + color: var(--sf-p1-text-color); +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-interval.allow-data { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-interval.allow-data + > .data { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver.p1 + > .container + > .data-wrapper.show-interval.allow-data { + width: 95px; + color: var(--sf-p1-text-color); +} + +.overlay + > .standings + > .driver-wrapper + > .driver.p1 + > .container + > .data-wrapper.show-interval.allow-data + > .data { + width: 95px; + font-size: 20px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-flag.allow-data { + width: 36px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-flag + > .flag { + width: 36px; + padding-left: 10px; + transition: width 0.2s ease-in 0.2s; +} + +.overlay + > .standings + > .driver-wrapper.out + > .driver + > .container + > .pit-wrapper + > .pit { + color: var(--driver-dimmed-color); + transition: color 0s; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-car-number.allow-data { + width: 54px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-car-number + > .data { + font-size: 18px; + min-width: 38px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-last-lap-time.allow-data { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-best-lap-time.allow-data { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .last-lap-time-wrapper + > .last-lap-time { + width: 95px; +} + +.overlay + > .standings + > .driver-wrapper.show-pit + > .driver + > .container + > .pit-wrapper { + width: 83px; +} + +.overlay + > .standings + > .driver-wrapper.show-out-lap + > .driver + > .container + > .pit-wrapper { + width: 83px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-car-logo.allow-data { + width: 45px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-car-logo + > .car-logo { + transform: perspective(500px) translateZ(-70px); + transform-origin: center; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-pit-count.allow-data { + width: 5.5em; +} + +.overlay + > .standings + > .driver-wrapper.show-lap-time + > .driver + > .container + > .last-lap-time-wrapper { + width: 6em; + padding-left: 5px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-gain.allow-data { + width: 65px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-gain + > .data { + text-align: center; + width: 45px; + font-family: var(--bold-font); +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-top-speed.allow-data { + width: 105px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-top-speed + > .data2 { + font-family: var(--bold-font); +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license.allow-data { + width: 65px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license + > .data { + width: 2.7em; + text-align: left; + padding-left: 7px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-detail.allow-data { + width: 115px; + padding-left: 18px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-detail + > .data { + width: 95px; + padding-left: 18px; + text-align: left; + font-family: var(--medium-font-italic); + font-size: 18px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-only.allow-data { + width: 1.8em; + padding-left: 16px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-only + > .data { + text-align: left; + width: 1.2em; + padding-left: 16px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-sr.allow-data { + width: 2.4em; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-license-sr + > .data { + text-align: left; + width: 2em; + padding-left: 18px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-irating.allow-data { + width: 2.6em; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-irating + > .data { + text-align: left; + width: 2em; + padding-left: 18px; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-off-track-count.allow-data { + width: 2.2em; +} + +.overlay + > .standings + > .driver-wrapper + > .driver + > .container + > .data-wrapper.show-irating-gain.allow-data { + width: 2.4em; +} + +/* RACE INFO, GRID, RESULTS, CHAMPIONSHIP COMMON ITEMS */ + +.grid > .grid-wrapper, +.results > .results-wrapper, +.championship > .results-wrapper { + margin-left: 0px; + margin-right: 0px; +} + +.header-wrapper > .header > .header-logo-wrapper { + transform: translateX(-6px); + border-radius: 0 0 0 0; + border: solid #530e37; + border-width: 3px; + background: #000 !important; + z-index: 2; +} + +.header-wrapper > .header { + grid-row-gap: 0 !important; +} + +.header-wrapper > .header > .title-wrapper { + padding-left: 28px !important; + margin-left: -30px !important; + margin-right: -1px !important; +} + +.header-wrapper > .header > .title-wrapper > .title { + font-family: var(--medium-font); + font-size: 30px; + line-height: 28px; +} + +.header-wrapper > .header > .subtitle-wrapper { + background: #222 !important; /*linear-gradient(#ccc, #777, #222) !important;*/ + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + color: white !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + margin-left: 0px; + margin-right: 0px; +} + +.header-wrapper > .header > .subtitle-wrapper > .subtitle { + color: white !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.footer-wrapper { + background: #000; /*linear-gradient(#ccc, #777, #222) !important;*/ + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + border-radius: 0 0 0 0 !important; + border-left: solid #222 1px !important; + border-right: solid #222 1px !important; + color: white !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + margin-left: 0px; + margin-right: 0px; +} + +.header-wrapper > .header > .header-logo-wrapper > .header-logo, +.header-wrapper > .header > .title-wrapper > .title, +.header-wrapper > .header > .subtitle-wrapper > .subtitle, +.subtitle-wrapper > .class-header, +.footer-wrapper > .footer { + letter-spacing: 0 !important; +} + +.header-wrapper > .header > .subtitle-wrapper { + margin-left: -20px; + padding-left: 20px; + border-radius: 0 0 0 0; +} + +.header-wrapper > .header > .subtitle-wrapper > .subtitle { + color: black; +} + +.footer-wrapper > .footer > .middle-footer { + color: white; + text-shadow: + 1.5px 1px 1px #202, + -0.3px -0.3px 0.3px #202, + -0.3px 0.3px 0.3px #202, + 0.3px -0.3px 0.3px #202, + 0.3px 0.3px 0.3px #202; +} + +.color-stripe2-wrapper, +.color-stripe3-wrapper { + display: none; +} + +/* RESULTS, CHAMPIONSHIP COMMON ITEMS */ + +.results, +.championship { + grid-template-rows: auto 15px auto 540px 15px auto !important; + top: 120px !important; +} + +.sub-header-wrapper { + border-radius: 0 0 0 0 !important; + border: solid #222 2px !important; + background: #222 !important; +} + +.sub-header-wrapper > .header { + display: block; + color: white; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222 !important; + font-family: var(--medium-font); + letter-spacing: 0; +} + +/* RESULTS */ + +.results-wrapper > .driver-wrapper { + margin-left: 0px; + margin-right: 0px; +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .name-wrapper { + background: var(--sf-gradation-background) !important; +} + +.results-wrapper > .driver-wrapper > .driver > .container > .multicar-team-name, +.results-wrapper + > .driver-wrapper + > .multicar-team + > .container + > .multicar-team-name, +.results-wrapper > .driver-wrapper > .driver > .container > .number-wrapper, +.results-wrapper > .driver-wrapper > .driver > .container > .team-name, +.results-wrapper > .driver-wrapper > .driver > .container > .gap, +.results-wrapper > .driver-wrapper > .driver > .container > .points, +.results-wrapper > .driver-wrapper > .multicar-team > .container > .points { + line-height: 25px !important; +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .flag { + display: none; +} + +.overlay > .results > .sub-header-wrapper > .header { + color: var(--sf-color); + height: 10px; + width: 600px; + padding-left: 250px; + font-family: var(--medium-font); + font-size: 18px; + line-height: 14px; + background-image: var(--result-subtitle-logo); + background-position: left center; + background-repeat: no-repeat; +} + +.results > .results-wrapper > .driver-wrapper > .driver > .container > .points, +.results + > .results-wrapper + > .driver-wrapper + > .multicar-team + > .container + > .points { + display: none; +} + +.results.session-results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .points, +.results.session-results + > .results-wrapper + > .driver-wrapper + > .multicar-team + > .container + > .points { + display: initial; +} +/* +.overlay>.results>.results-wrapper>.driver-wrapper>.driver>.container>.multicar-team-name { + display: none; +} +*/ + +.overlay > .results { + grid-template-rows: auto 15px auto 580px !important; + grid-template-columns: 0 1060px 0; +} + +.overlay > .results > .header-wrapper > .header > .title-wrapper { + line-height: 26px; +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .name-wrapper { + line-height: 24px; +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .name-wrapper + > .first-name { + font-family: var(--medium-font); +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .name-wrapper + > .last-name { + font-family: var(--medium-font); +} + +.overlay + > .results + > .results-wrapper + > .driver-wrapper + > .driver + > .container + > .number-wrapper { + background: var(--sf-color) !important; +} + +/* GRID DRIVERS */ + +.grid > .grid-wrapper > .grid-driver { + overflow: visible !important; +} + +.grid > .grid-wrapper > .grid-driver > .upper-block { + padding-right: 10px; + margin-left: 0px; + overflow: visible !important; + height: 40px; + border-radius: 0 0 0 0; +} + +.grid + > .grid-wrapper + > .grid-driver + > .upper-block + > .container + > .name-wrapper { + width: 220px; +} + +.overlay > .grid > .grid-wrapper > .grid-driver > .lower-block { + padding-right: 0px; + padding-left: 20px; + margin-left: 0px; + overflow: visible; + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + border-radius: 0 0 0 0 !important; + border-left: solid #ccc 1px !important; + border-right: solid #ccc 1px !important; + color: white !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + clip-path: polygon(0 100%, 98% 100%, 98% 0, 0 0); + margin-left: 40px; + margin-right: -9px; +} + +.overlay + > .grid + > .grid-wrapper + > .grid-driver + > .lower-block + > .team-name-wrapper { + padding-left: 8px; +} + +.overlay + > .grid + > .grid-wrapper + > .grid-driver + > .lower-block + > .qualifying-time-wrapper { + padding-right: 25px; +} + +.overlay + > .grid + > .grid-wrapper + > .grid-driver + > .lower-block + > .team-name-wrapper, +.overlay + > .grid + > .grid-wrapper + > .grid-driver + > .lower-block + > .qualifying-time-wrapper { + line-height: 25px; +} + +/* RACE INFO */ + +.race-info { + top: 70px !important; + /* grid-template-columns: 800px 400px !important;*/ + /* grid-template-rows: auto 197px 520px auto !important; + grid-template-rows: auto 0px 520px auto !important;*/ + /* transform: perspective(1000px) translateY(65px) translateZ(-115px) rotateX(4deg) rotateY(8deg);*/ + transform-origin: center; + opacity: 0.9; +} + +.overlay > .race-info > .track-wrapper > .track > .track-canvas { + max-height: 720px; + max-width: 800px; +} + +.overlay > .race-info > .track-wrapper > .track > .track { + width: 10px; +} + +.overlay > .race-info > .track-wrapper > .track > .track-shadow { + width: 15px; + color: var(--sf-color); +} + +.race-info > .track-wrapper > .track > .track-title, +.race-info > .weather-wrapper > .weather > .weather-title, +.race-info > .commentators-wrapper > .commentators > .commentators-title { + border-radius: 0 0 0 0; + padding: 0 20px 0 20px !important; + border-right: solid 1px #ccc; + line-height: 42px !important; + color: white; + font-family: var(--bold-font-italic) !important; + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; +} + +.race-info>.track-wrapper>.track, +/*.race-info>.weather-wrapper>.weather,*/ +.race-info>.commentators-wrapper>.commentators { + background: #222 !important; + color: white; + font-family: var(--regular-font); + font-size: 40px; + border-radius: 0 0 0 0; +} + +.overlay > .race-info > .weather-wrapper { + display: initial; +} + +.race-info > .weather-wrapper > .weather > .temperature, +.race-info > .weather-wrapper > .weather > .humidity, +.race-info > .weather-wrapper > .weather > .sky, +.race-info > .weather-wrapper > .weather > .wind { + background: #222 !important; + padding-top: 12px; +} + +.race-info > .track-wrapper > .track > .track-altitude, +.race-info > .track-wrapper > .track > .track-city, +.race-info > .track-wrapper > .track > .track-country, +.race-info > .track-wrapper > .track > .track-length, +.race-info > .track-wrapper > .track > .track-configuration-name { + background: #222 !important; + height: 35px; + padding-top: 10px !important; + font-family: var(--semibold-font) !important; + font-size: 30px !important; +} + +.race-info > .track-wrapper > .track > .track-temperature { + background: #222 !important; + height: 35px; + padding-top: 10px !important; + font-family: var(--semibold-font) !important; + font-size: 30px !important; + color: var(--temp-color) !important; +} + +.race-info > .track-wrapper > .track > .track-altitude-text, +.race-info > .track-wrapper > .track > .track-city-text, +.race-info > .track-wrapper > .track > .track-temperature-text, +.race-info > .track-wrapper > .track > .track-country-text, +.race-info > .track-wrapper > .track > .track-length-text, +.race-info > .track-wrapper > .track > .track-configuration-name-text { + height: 35px; + background: transparent !important; + padding-top: 10px !important; + font-size: 24px !important; +} + +.overlay + > .race-info + > .commentators-wrapper + > .commentators + > .commentators-text { + font-family: var(--medium-font-italic); + font-size: 22px; +} + +/* DASHBOARD */ + +.overlay > .dashboard { + overflow: visible; + background: transparent !important; + color: white; + font-family: var(--medium-font); + border-radius: 0 0 0 0; + text-shadow: + 1.5px 1px 1px #343, + -0.3px -0.3px 0.3px #343, + -0.3px 0.3px 0.3px #343, + 0.3px -0.3px 0.3px #343, + 0.3px 0.3px 0.3px #343; +} + +.overlay > .dashboard > .header { + border-radius: 0 0 0 0; + padding: 0 20px 0 20px !important; + line-height: 27px !important; + color: white; + background: transparent !important; /*linear-gradient(#ccc, #777, #222) !important;*/ + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.overlay > .dashboard > .gauge-wrapper, +.overlay > .dashboard > .gauge-wrapper > .upper-line, +.overlay > .dashboard > .gauge-wrapper > .lower-line { + background: transparent; +} + +.overlay > .dashboard > .gauge-wrapper { + height: 110px; +} + +.overlay > .dashboard > .gear-wrapper { + border-radius: 0 0 0 0; + padding: 0 20px 0 20px !important; + line-height: 35px !important; + color: white; + background: #333 !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.overlay > .dashboard > .gauge-wrapper > .half-ring-wrapper > .rpm-half-ring { + border: 2px solid var(--sf-color); +} + +/* WEATHER */ + +.weather > .icon-wrapper { + height: 35px !important; + width: 55px !important; + border-top: solid #ccc 1px; + border-right: solid #ccc 2px; + border-left: solid #ccc 1px; + border-radius: 0 8px 0 0; + color: black; + text-shadow: none; + background: white !important; +} + +.weather > .icon-wrapper > .icon { + filter: invert(100%); + height: 24px !important; + width: 24px !important; + margin-top: 5px !important; + margin-bottom: 5px !important; + margin-left: 15px !important; + margin-right: 10px !important; +} + +.weather > .value { + padding-left: 40px !important; + height: 35px; + margin-left: -25px; + border-top: solid 1px #ccc; + line-height: 35px !important; + color: white; + background: black !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.weather > .unit { + height: 35px; + padding-right: 40px !important; +} + +.weather > .value.date, +.weather > .value.time, +.weather > .value.sky, +.weather > .unit { + border-radius: 0 0 0 0; + border-top: solid 1px #ccc; + border-right: solid 1px #ccc; + line-height: 35px !important; + color: white; + background: black !important; + background-size: 100%, 100%, 18px !important; + background-position-x: right, right, right !important; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +/* TIME DELTA HISTORY */ + +.overlay > .time-delta-history { + right: 10px; +} + +.overlay > .time-delta-history > .header > .driver, +.overlay > .time-delta-history > .header > .driver > .name-wrapper { + background: var(--panel-background-color) !important; +} + +.overlay > .time-delta-history > .header > .driver > .name-wrapper > .name { + line-height: 27px; +} + +.overlay > .time-delta-history > .header > .time-delta { + background: #1e1e1e !important; + border-top: 2px solid var(--panel-surface-background-color); + height: 27px; + line-height: 27px; + color: white; + font-family: var(--bold-font); + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + padding-right: 20px; +} + +.overlay > .time-delta-history > .chart-wrapper { + border-radius: 0 0 0 0; + color: white; + line-height: 18px; + text-shadow: + 1.5px 1px 1px #212, + -0.3px -0.3px 0.3px #212, + -0.3px 0.3px 0.3px #212, + 0.3px -0.3px 0.3px #212, + 0.3px 0.3px 0.3px #212; +} + +.overlay + > .time-delta-history + > .chart-wrapper + > .chart-body + > .chart-left-arrow-tip, +.overlay + > .time-delta-history + > .chart-wrapper + > .chart-body + > .chart-right-arrow-tip { + border-bottom: 12px solid white; +} + +.overlay > .time-delta-history > .chart-wrapper > .chart-body > .chart-bar { + background: var(--sf-color); +} + +/* INTERVIEW */ + +.overlay > .interview { + bottom: 110px; + left: 60px; + border-radius: 0 8px 0 0; + border: transparent; + line-height: 35px !important; + color: white; + background: linear-gradient( + to right, + black 20%, + var(--sf-color) 90% + ) !important; /*linear-gradient(#ccc, #777, #222) !important;*/ + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.overlay > .interview > .interview-text { + font-family: var(--medium-font-italic); + font-size: 20px; +} + +.overlay > .interview.hide { + opacity: 0; +} + +/* CLASSES */ + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class + > .driver-class-name-wrapper { + border: 0; + min-width: 160px; + background: var(--panel-surface-background-color); + border-radius: 10px; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class.left-side + > .driver-class-name-wrapper { + border: 0 !important; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class.right-side + > .driver-class-name-wrapper { + border: 0 !important; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class + > .driver-class-name-wrapper + > .driver-class-name { + background: none; + border: 0px !important; + color: white; + font-family: var(--bold-font); + font-size: 20px; + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class + > .driver-class-data-wrapper { + transform: translateX(0px); + height: 40px; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class.left-side + > .driver-class-data-wrapper { + border-radius: 14px; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class.right-side + > .driver-class-data-wrapper { + border-radius: 14px; +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class + > .driver-class-data-wrapper + > .driver-class-data { + font-family: var(--bold-font-italic); + position: absolute; + width: 100%; + font-size: 18px; + background: black; + padding: 6px 0px 6px 0px; +} + +.overlay + > .classes.hide-data + > .driver-classes-wrapper + > .driver-class + > .driver-class-data-wrapper + > .driver-class-data { + transform: translateY(0px); +} + +.overlay + > .classes + > .driver-classes-wrapper + > .driver-class + > .driver-class-data-wrapper + > .driver-class-data.hide { + transform: translateY(0px); +} + +/* SECTORS */ + +.overlay > .sectors { + bottom: 80px; + line-height: 27px; +} + +.overlay > .sectors > .driver { + height: 27px; +} + +.overlay.pit-lane > .sectors { + bottom: 140px; +} + +.overlay > .sectors > .title { + background: var(--panel-background-color); +} + +.overlay > .sectors > .driver.line-3 { + border-bottom: 1.5px solid var(--panel-surface-background-color) !important; +} + +.overlay > .sectors > .driver > .name-wrapper { + width: 150px; +} + +.overlay > .sectors > .driver > .name-wrapper > .name { + height: 27px; +} + +.overlay > .sectors > .sector-time { + height: 27px; +} + +.overlay > .sectors > .line-1 { + border-top: 2.5px solid var(--panel-surface-background-color); + border-bottom: 0px solid var(--panel-surface-background-color); +} + +.overlay > .sectors > .line-2 { + border-top: 2.5px solid var(--panel-surface-background-color); + border-bottom: 0px solid var(--panel-surface-background-color); +} + +.overlay > .sectors > .line-3 { + border-top: 2.5px solid var(--panel-surface-background-color); + border-bottom: 1.5px solid var(--panel-surface-background-color); +} + +/* PIT STOP HISTORY */ + +.overlay > .pit-stop-history > .header-wrapper { + margin-left: 4px; + border-radius: 10px 10px 0 0; + border-top: solid 2px var(--panel-surface-background-color); + border-left: solid 2px var(--panel-surface-background-color); + border-right: solid 2px var(--panel-surface-background-color); + background: var(--driver-background-color); +} + +.overlay > .pit-stop-history > .header-wrapper > .header { + text-shadow: + 1.5px 1px 1px #222, + -0.3px -0.3px 0.3px #222, + -0.3px 0.3px 0.3px #222, + 0.3px -0.3px 0.3px #222, + 0.3px 0.3px 0.3px #222; + border: none; + font-size: 22px; + font-family: var(--bold-font-italic); + background: transparent; +} + +.overlay > .pit-stop-history > .header-wrapper > .header.hide { + text-shadow: + 1.5px 1px 1px transparent, + -0.3px -0.3px 0.3px transparent, + -0.3px 0.3px 0.3px transparent; +} + +.overlay + > .pit-stop-history + > .data-wrapper + > .driver + > .container + > .stints + > .stint.even { + background: var(--sf-color); +} + +.overlay + > .pit-stop-history + > .data-wrapper + > .driver + > .container + > .stints + > .stint.odd { + background: gold; +} + +/* RADIO CHANNEL */ + +.overlay > .radio-channel { + top: 610px; + border: 0px solid transparent; + border-radius: 0 8px 0 0; + background: linear-gradient(to right, black 20%, var(--sf-color) 100%); +} + +.overlay > .radio-channel > .equalizer { + margin-top: 18px; +} + +.overlay > .radio-channel > .equalizer > .color-bar { + background: var(--sf-color); +} + +/* WINDOW and OTHERS */ + +.overlay > .window.window-1 { + top: 14px; + right: 224px; + width: 430px; + z-index: -10; + transform: perspective(1000px) translateZ(-115px) rotateX(4deg) + rotateY(10deg) rotateZ(3deg); + transform-origin: bottom center; + transition-delay: 1.5s; + transition: opacity 0.3s ease-in-out; +} + +.overlay > .window.window-1 > .header, +.overlay > .statistics > .header { + width: 155px; + text-align: center; + height: 35px; + line-height: 35px; + color: var(--sf-color); + margin-left: 5px; + margin-right: 5px; + padding-left: 10px; + border-style: none; + font-family: var(--bold-font); + font-size: 25px; + background: var(--panel-background-color); +} + +.overlay > .window.window-1 > .data-wrapper { + margin-left: 5px; + margin-top: 2px; + margin-right: 5px; + margin-bottom: 2px; + display: none; +} + +.overlay > .window.window-1 > .data-wrapper > .data, +.overlay > .statistics > .data-wrapper > .data { + background: var(--driver-background-color); + font-family: var(--regular-font); + font-size: 25px; + color: #ffffff; +} + +.overlay.hide > .window > .header { + opacity: 0; +} + +/* OTHERS */ + +.overlay > .replay-state { + left: 860px; + top: 40px; + background: var(--header-title-background-color) !important; + border-radius: 16px; + font-family: var(--semibold-font); + font-size: 22px; + text-shadow: 1.5px 1px 1px #333; +} + +.overlay > .inputs { + bottom: 40px; + right: 480px; + background: black; +} + +.overlay.battle > .inputs { + bottom: 412px; +} + +.overlay.pit-lane > .inputs { + bottom: 412px; +} + +.overlay > .track-map { + right: 0px; + top: 100px; + transform: perspective(1000px) translateZ(-350px); +} + +.overlay > .event { + bottom: unset; + top: 80px; + left: 300px; + width: 70%; +} + +.overlay.pit-lane > .event { + bottom: unset; + top: 80px; +} + +.overlay > .event > .header-wrapper { + background: var(--header-title-background-color-lighter); + height: 22px; + border: solid black 2px; + border-radius: 15px; + transform: translateX(-8px); + transform-origin: top left; +} + +.overlay > .event > .header-wrapper > .header { + color: white; + font-family: var(--medium-font-italic); + font-size: 15px; + margin-left: 10px; + margin-right: 10px; + padding-top: 0px; + transform: translateY(-4px) skew(-10deg); + transform-origin: top; +} + +.overlay > .event > .text-wrapper { + background: var(--header-title-background-color); + border: transparent; + border-radius: 2px; +} + +.overlay > .event > .text-wrapper > .text { + margin-left: 2px; + font-family: var(--medium-font); + font-size: 18px; + color: white; +} + +/* LAP TIME HISTORY */ + +.overlay > .lap-time-history { + bottom: 40px; +} + +.overlay.pit-lane > .lap-time-history { + bottom: 240px; +} + +.overlay > .lap-time-history > .driver > .name-wrapper { + width: 150px; + margin-bottom: 20px !important; +} + +/* PIT LANE */ + +.overlay > .pit-lane > .header { + margin-top: 0px; + margin-bottom: 0px; +} + +.overlay > .pit-lane > .driver-wrapper > .driver { + border-top: 1px solid transparent !important; +} +.overlay > .pit-lane > .driver-wrapper > .driver > .container { + overflow: hidden !important; +} + +.overlay > .pit-lane > .driver-wrapper > .driver > .container > .name-wrapper { + height: 29px !important; +} + +.overlay + > .pit-lane + > .driver-wrapper + > .driver + > .container + > .name-wrapper + > .name { + height: 29px; + line-height: 27px; + padding-left: 0px; +} + +.overlay > .pit-lane > .driver-wrapper > .driver > .container > .pit-lane-time { + margin-bottom: 24px; + line-height: 27px; +} + +.overlay > .pit-lane > .driver-wrapper > .driver > .container > .pit-time { + margin-bottom: 24px; + line-height: 27px; +} + +/* RACE CONTROL*/ + +.overlay > .race-control { + font-family: var(--semibold-font-italic); +} + +.overlay > .race-control.information > .header-wrapper { + background: var(--sf-color); +} diff --git a/modules/flet_pages/overlays/overlay.html b/modules/flet_pages/overlays/overlay.html new file mode 100644 index 0000000..99a94db --- /dev/null +++ b/modules/flet_pages/overlays/overlay.html @@ -0,0 +1,584 @@ + + + + + + + + +
+ + + +
+
+ Qualifying + --:-- +
+
+
+ + + + +
+
+
+
+
Race Control
+
+
+
Race Control
+
+
+
+ + + + + diff --git a/modules/flet_pages/overlays/standings.html b/modules/flet_pages/overlays/standings.html new file mode 100644 index 0000000..8837419 --- /dev/null +++ b/modules/flet_pages/overlays/standings.html @@ -0,0 +1,459 @@ + + + + + + + + +
+
+ +
+ Qualifying Standings + --:-- +
+ + +
+ + +
+
+
+ + + + + diff --git a/modules/flet_pages/race_control.py b/modules/flet_pages/race_control.py index 6dc58d2..8371f43 100644 --- a/modules/flet_pages/race_control.py +++ b/modules/flet_pages/race_control.py @@ -95,9 +95,17 @@ def __init__(self): self.text_consumer_config = {} self.audio_consumer_config = {} self.chat_consumer_config = {} + self.overlay_consumer_config = { + "port": "8765", + "width": "1920", + "height": "1080", + "css_file": "", + } self.text_consumer_enabled = False self.audio_consumer_enabled = False self.chat_consumer_enabled = False + self.overlay_consumer_enabled = False + self.overlay_event = None # Running OverlayConsumerEvent reference self.chat_message_list = None self.chat_refresh_timer = None @@ -111,6 +119,7 @@ def __init__(self): self.clear_black_flag_enabled = False self.scheduled_black_flag_enabled = False self.gap_to_leader_enabled = False + self.f1_qualifying_enabled = False # Tab references for updating indicators self.tabs_control = None @@ -128,7 +137,6 @@ def __init__(self): self.f1_final_session = {"duration": "8", "advancing_cars": "0"} self.f1_wait_between = 120 self.f1_refresh_timer = None - self.f1_dialog = None # Beer Goggles mode state self.goggle_event: Optional[BaseEvent] = None @@ -367,6 +375,12 @@ def get_tab_definitions(self): "enabled": self.gap_to_leader_enabled, "build_func": self.build_gap_to_leader_tab, }, + { + "name": "F1 Qualifying", + "icon": ft.Icons.SPEED, + "enabled": self.f1_qualifying_enabled, + "build_func": self.build_f1_qualifying_tab, + }, ] def build_main_tabs(self): @@ -2334,6 +2348,14 @@ def build_consumer_section(self): border_radius=5, padding=10, ), + ft.Container(height=8), + ft.Container( + content=self.build_overlay_consumer(), + width=400, + border=ft.border.all(1, ft.Colors.OUTLINE), + border_radius=5, + padding=10, + ), ], ) @@ -2525,6 +2547,102 @@ def toggle_enabled(e): spacing=5, ) + def build_overlay_consumer(self): + """Build the overlay server configuration panel.""" + if not self.overlay_consumer_config: + self.overlay_consumer_config = { + "port": "8765", + "width": "1920", + "height": "1080", + "css_file": "", + } + config = self.overlay_consumer_config + + def update_config(key, value): + self.overlay_consumer_config[key] = value + # Live-update the URL hint + url_text.value = ( + f"http://localhost:{self.overlay_consumer_config.get('port', '8765')}/" + ) + url_text.update() + + def toggle_enabled(e): + self.overlay_consumer_enabled = e.control.value + disabled = not e.control.value or self.is_running + port_field.disabled = disabled + width_field.disabled = disabled + height_field.disabled = disabled + css_field.disabled = disabled + self.page.update() + + enable_check = ft.Checkbox( + label="Enable Overlay Server (OBS)", + value=self.overlay_consumer_enabled, + on_change=toggle_enabled, + disabled=self.is_running, + ) + + port_field = ft.TextField( + label="Port", + value=config.get("port", "8765"), + width=90, + disabled=not self.overlay_consumer_enabled or self.is_running, + on_change=lambda e: update_config("port", e.control.value), + ) + + width_field = ft.TextField( + label="Width (px)", + value=config.get("width", "1920"), + width=100, + disabled=not self.overlay_consumer_enabled or self.is_running, + on_change=lambda e: update_config("width", e.control.value), + ) + + height_field = ft.TextField( + label="Height (px)", + value=config.get("height", "1080"), + width=100, + disabled=not self.overlay_consumer_enabled or self.is_running, + on_change=lambda e: update_config("height", e.control.value), + ) + + css_field = ft.TextField( + label="CSS file path (blank = bundled overlay.css)", + value=config.get("css_file", ""), + width=360, + disabled=not self.overlay_consumer_enabled or self.is_running, + on_change=lambda e: update_config("css_file", e.control.value), + ) + + url_text = ft.Text( + f"http://localhost:{config.get('port', '8765')}/", + size=11, + color=ft.Colors.BLUE, + selectable=True, + ) + + return ft.Column( + [ + ft.Text( + "Overlay Server (OBS Browser Source)", + size=14, + weight=ft.FontWeight.BOLD, + ), + ft.Divider(height=5), + enable_check, + ft.Row([port_field, width_field, height_field], spacing=8), + css_field, + ft.Row( + [ + ft.Icon(ft.Icons.LINK, size=14, color=ft.Colors.BLUE), + url_text, + ], + spacing=4, + ), + ], + spacing=5, + ) + def start_race_control(self, e): """Start the race control system""" # Get logger for logging errors @@ -2805,6 +2923,26 @@ def start_race_control(self, e): } ) + # Add Overlay Consumer if enabled + if self.overlay_consumer_enabled: + event_list.append( + { + "class": events.OverlayConsumerEvent, + "args": { + "port": int(self.overlay_consumer_config.get("port", 8765)), + "width": int( + self.overlay_consumer_config.get("width", 1920) + ), + "height": int( + self.overlay_consumer_config.get("height", 1080) + ), + "css_file": self.overlay_consumer_config.get( + "css_file", "" + ), + }, + } + ) + # Create event instances with error handling event_instances = [] for i, item in enumerate(event_list): @@ -2836,11 +2974,38 @@ def start_race_control(self, e): if logger: logger.info(f"Started {len(event_instances)} events successfully") + # Keep a reference to the overlay event so we can wire in the F1 + # qualifying event later without restarting the server. + self.overlay_event = next( + ( + e + for e in event_instances + if isinstance(e, events.OverlayConsumerEvent) + ), + None, + ) + # Create and start subprocess manager event_run_methods = [event.run for event in event_instances] self.subprocess_manager = SubprocessManager(event_run_methods) self.subprocess_manager.start() + # Start F1 Qualifying if enabled + if self.f1_qualifying_enabled: + all_sessions = [*self.f1_elim_sessions, self.f1_final_session] + session_lengths = ", ".join([s["duration"] for s in all_sessions]) + advancing_cars = ", ".join([s["advancing_cars"] for s in all_sessions]) + self.f1_event = F1QualifyingEvent( + session_lengths, + advancing_cars, + wait_between_sessions=self.f1_wait_between, + ) + self.f1_subprocess_manager = SubprocessManager([self.f1_event.run]) + self.f1_subprocess_manager.start() + if self.overlay_event is not None: + self.overlay_event.f1_event = self.f1_event + self.page.run_task(self.f1_refresh_task) + # Start chat consumer refresh task if enabled if self.chat_consumer_enabled: self.page.run_task(self.chat_refresh_task) @@ -2936,6 +3101,14 @@ def stop_race_control(self, e): self.subprocess_manager.stop() self.subprocess_manager = None + # Stop F1 Qualifying if running + if self.f1_subprocess_manager: + self.f1_subprocess_manager.stop() + self.f1_subprocess_manager = None + self.f1_event = None + + self.overlay_event = None + # Clear chat messages when stopping if self.chat_message_list: self.chat_message_list.controls.clear() @@ -3155,6 +3328,10 @@ def save_preset(self, name: str): "enabled": self.chat_consumer_enabled, "config": self.chat_consumer_config, }, + "overlay_consumer": { + "enabled": self.overlay_consumer_enabled, + "config": self.overlay_consumer_config, + }, } with open(os.path.join(preset_dir, f"{name}.json"), "w") as f: @@ -3313,6 +3490,14 @@ def _load_config_data(self, config: dict): self.chat_consumer_enabled = True self.chat_consumer_config = {} + # Load Overlay Consumer + overlay_consumer = config.get("overlay_consumer", {}) + self.overlay_consumer_enabled = overlay_consumer.get("enabled", False) + self.overlay_consumer_config = overlay_consumer.get( + "config", + {"port": "8765", "width": "1920", "height": "1080", "css_file": ""}, + ) + def load_preset(self, name: str, silent: bool = False): """Load a saved preset @@ -3374,15 +3559,6 @@ def build_footer(self): content=ft.Row( [ ft.Text("Special Modes:", size=14, weight=ft.FontWeight.BOLD), - ft.ElevatedButton( - "F1 Qualifying", - icon=ft.Icons.SPEED, - on_click=self.open_f1_qualifying, - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_900, - color=ft.Colors.WHITE, - ), - ), ft.ElevatedButton( "Beer Goggles", icon=ft.Icons.REMOVE_RED_EYE, @@ -3401,24 +3577,12 @@ def build_footer(self): border_radius=5, ) - def open_f1_qualifying(self, e): - """Open F1 Qualifying mode dialog""" - self.f1_dialog = ft.AlertDialog( - modal=False, # Allow interaction with other windows - title=ft.Text("F1 Qualifying Mode"), - content=self.build_f1_qualifying_content(), - actions=[ft.TextButton("Close", on_click=self.close_f1_dialog)], - ) - self.page.overlay.append(self.f1_dialog) - self.f1_dialog.open = True - self.page.update() - - def build_f1_qualifying_content(self): - """Build the F1 qualifying configuration UI""" + def build_f1_qualifying_tab(self): + """Build the F1 Qualifying tab content""" session_list = ft.Column( scroll=ft.ScrollMode.AUTO, - height=300, - spacing=10, + height=220, + spacing=5, ) def rebuild_sessions(): @@ -3431,12 +3595,12 @@ def rebuild_sessions(): ft.Text("Session", weight=ft.FontWeight.BOLD), width=80 ), ft.Container( - ft.Text("Duration (Mins)", weight=ft.FontWeight.BOLD), width=150 + ft.Text("Duration (Mins)", weight=ft.FontWeight.BOLD), width=130 ), ft.Container( - ft.Text("Advancing Cars", weight=ft.FontWeight.BOLD), width=150 + ft.Text("Advancing Cars", weight=ft.FontWeight.BOLD), width=130 ), - ft.Container(width=100), + ft.Container(width=60), ] ) session_list.controls.append(header) @@ -3470,20 +3634,23 @@ def on_click(e): ft.Container(ft.Text(f"Q{i + 1}", size=16), width=80), ft.TextField( value=session["duration"], - width=150, + width=130, on_change=make_duration_change(i), text_align=ft.TextAlign.CENTER, + disabled=self.is_running, ), ft.TextField( value=session["advancing_cars"], - width=150, + width=130, on_change=make_advancing_change(i), text_align=ft.TextAlign.CENTER, + disabled=self.is_running, ), ft.IconButton( icon=ft.Icons.DELETE, icon_color=ft.Colors.RED, on_click=make_remove_click(i), + disabled=self.is_running, ), ] ) @@ -3500,17 +3667,18 @@ def on_final_duration_change(e): ), ft.TextField( value=self.f1_final_session["duration"], - width=150, + width=130, on_change=on_final_duration_change, text_align=ft.TextAlign.CENTER, + disabled=self.is_running, ), ft.TextField( value="0", - width=150, + width=130, disabled=True, text_align=ft.TextAlign.CENTER, ), - ft.Container(width=100), + ft.Container(width=60), ] ) session_list.controls.append(final_row) @@ -3528,64 +3696,73 @@ def on_wait_change(e): except: pass - controls = ft.Column( - [ - session_list, - ft.Row( - [ - ft.ElevatedButton( - "Add Session", - icon=ft.Icons.ADD, - on_click=add_session, - ), - ] - ), - ft.Divider(), - ft.Row( - [ - ft.Text("Wait Between Sessions (seconds):", size=14), - ft.TextField( - value=str(self.f1_wait_between), - width=100, - on_change=on_wait_change, - text_align=ft.TextAlign.CENTER, - ), - ] - ), - ft.Divider(), - ft.Row( - [ - ft.ElevatedButton( - "Start F1 Qualifying", - icon=ft.Icons.PLAY_ARROW, - on_click=self.start_f1_qualifying, - style=ft.ButtonStyle( - bgcolor=ft.Colors.GREEN, - color=ft.Colors.WHITE, + def toggle_enabled(e): + self.f1_qualifying_enabled = e.control.value + self.update_tab_indicators() + + enable_toggle = ft.Switch( + label="Enable F1 Qualifying", + value=self.f1_qualifying_enabled, + on_change=toggle_enabled, + disabled=self.is_running, + ) + + return ft.Container( + content=ft.Column( + [ + ft.Row( + [ + ft.Column( + [ + ft.Text( + "F1 Qualifying Mode", + size=16, + weight=ft.FontWeight.BOLD, + ), + ft.Text( + "F1-style knockout qualifying sessions", + size=12, + color=ft.Colors.GREY, + ), + ], + expand=True, ), - ), - ft.ElevatedButton( - "Stop F1 Qualifying", - icon=ft.Icons.STOP, - on_click=self.stop_f1_qualifying, - style=ft.ButtonStyle( - bgcolor=ft.Colors.RED, - color=ft.Colors.WHITE, + enable_toggle, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + ft.Divider(height=5), + ft.Row( + [ + ft.Text("Wait Between Sessions (seconds):", size=13), + ft.TextField( + value=str(self.f1_wait_between), + width=80, + on_change=on_wait_change, + text_align=ft.TextAlign.CENTER, + disabled=self.is_running, ), - ), - ], - spacing=10, - ), - ft.Container(height=10), - self.build_f1_leaderboard(), - ], - scroll=ft.ScrollMode.AUTO, - width=1600, - height=1000, + ft.Container(expand=True), + ft.ElevatedButton( + "Add Session", + icon=ft.Icons.ADD, + on_click=add_session, + disabled=self.is_running, + ), + ], + spacing=10, + ), + ft.Container(height=5), + session_list, + ft.Divider(height=5), + self.build_f1_leaderboard(), + ], + scroll=ft.ScrollMode.AUTO, + spacing=5, + ), + padding=8, ) - return controls - def build_f1_leaderboard(self): """Build F1 qualifying leaderboard display - returns container that will be updated""" # Create persistent Column that we'll update (not replace) @@ -3856,60 +4033,6 @@ async def f1_refresh_task(self): self.update_f1_leaderboard() await asyncio.sleep(0.5) # Update twice per second - def start_f1_qualifying(self, e): - """Start F1 Qualifying event""" - all_sessions = [*self.f1_elim_sessions, self.f1_final_session] - session_lengths = ", ".join([s["duration"] for s in all_sessions]) - advancing_cars = ", ".join([s["advancing_cars"] for s in all_sessions]) - - self.f1_event = F1QualifyingEvent( - session_lengths, advancing_cars, wait_between_sessions=self.f1_wait_between - ) - self.f1_subprocess_manager = SubprocessManager([self.f1_event.run]) - self.f1_subprocess_manager.start() - - # Start refresh task - self.page.run_task(self.f1_refresh_task) - - self.page.show_snack_bar( - ft.SnackBar( - content=ft.Text("F1 Qualifying Started!"), bgcolor=ft.Colors.GREEN - ) - ) - self.page.update() - - def stop_f1_qualifying(self, e): - """Stop F1 Qualifying event""" - if self.f1_subprocess_manager: - self.f1_subprocess_manager.stop() - self.f1_subprocess_manager = None - - self.f1_event = None - - # Clear leaderboard - if self.f1_leaderboard_column: - self.f1_leaderboard_column.controls = [ - ft.Text( - "Qualifying stopped", - size=14, - color=ft.Colors.GREY, - ) - ] - self.f1_leaderboard_column.update() - - self.page.show_snack_bar( - ft.SnackBar( - content=ft.Text("F1 Qualifying Stopped!"), bgcolor=ft.Colors.RED - ) - ) - self.page.update() - - def close_f1_dialog(self, e): - """Close F1 Qualifying dialog""" - if self.f1_dialog: - self.f1_dialog.open = False - self.page.update() - def open_beer_goggles(self, e): """Open Beer Goggles SDK viewer dialog""" self.goggles_dialog = ft.AlertDialog( diff --git a/tests/preview_overlay.py b/tests/preview_overlay.py new file mode 100644 index 0000000..79bc8f5 --- /dev/null +++ b/tests/preview_overlay.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +""" +preview_overlay.py — Overlay appearance test server +====================================================== +Serves the overlay HTML/CSS/SSE stack with **static data** so you can +tweak the overlay appearance without running the full Flet UI or +connecting to iRacing. + +Simulated scenario +------------------ + • Q2 qualifying — checkered flag out, session clock at 0:00 + • P1-P9 safe: have finished their laps, advancing to Q3 + • P10 L. Stroll at_risk: last safe spot, still on a flying lap (no finished flag) + • P11 E. Ocon elimination_zone: first to drop, also still on a flying lap + • P12-P15 elimination_zone: have pitted; sealed unless Stroll/Ocon swap with them + • P16-P20 knocked_out: eliminated in Q1, never ran in Q2 + • Race-control banner fires *rc_delay* seconds after the first client + connects, then repeats every *rc_interval* seconds + +Live reload +----------- +Whenever ``overlay.html`` or ``overlay.css`` changes on disk, every +connected browser tab reloads automatically — no manual refresh needed. + +Manual RC trigger +----------------- +Visit http://localhost:/trigger-rc in any tab (or hit it with curl) +to send the RC banner immediately to all connected overlays. + +Usage +----- + python tests/preview_overlay.py + python tests/preview_overlay.py --port 9765 --width 1920 --height 1080 + python tests/preview_overlay.py --rc-delay 2 --rc-interval 20 + python tests/preview_overlay.py --no-browser + +Then open http://localhost:9765/ in your browser or OBS browser source. +Press Ctrl+C to stop. +""" + +from __future__ import annotations + +import argparse +import json +import queue +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths (mirrors the layout expected by overlay_consumer_event.py) +# --------------------------------------------------------------------------- + +_THIS_DIR = Path(__file__).parent +_REPO_DIR = _THIS_DIR.parent +_OVERLAY_HTML = _REPO_DIR / "modules" / "flet_pages" / "overlays" / "overlay.html" +_DEFAULT_CSS = _REPO_DIR / "modules" / "flet_pages" / "overlay.css" +_FONTS_DIR = _DEFAULT_CSS.parent / "fonts" + +# --------------------------------------------------------------------------- +# Static scenario data — Q2 checkered flag, session still live +# --------------------------------------------------------------------------- +# +# Scenario: the Q2 session clock has hit zero and the checkered flag is out, +# but two drivers are still on flying laps — the results are not yet final. +# +# All four row statuses are represented so every CSS class is exercisable: +# +# safe — P1-P9 advancing to Q3, already finished their laps +# at_risk — P10 L. Stroll — last safe spot, still on a flying lap +# elimination_zone — P11 E. Ocon — first out, also still on a flying lap +# P12-P15 have pitted; their fates are sealed unless +# Stroll or Ocon swap with them +# knocked_out — P16-P20 eliminated in Q1, never ran in Q2 +# +# Toggle to ALT_F1_STATE (/trigger-alt) to see the same standings with the +# session clock still running and no checkered flag. +# --------------------------------------------------------------------------- + +STATIC_F1_STATE: dict = { + "session_name": "Q2", + "time_remaining": "0:00", + "checkered_flag": True, + "sessions": ["Q1", "Q2"], + "drivers": [ + # ── P1-P9 safe — advancing to Q3, laps completed ───────────────────── + { + "position": 1, + "car_num": "1", + "driver_name": "J. Hamilton", + "best_time": "01:23.789", + "status": "safe", + "session_times": {"Q1": "01:25.123", "Q2": "01:23.789"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 2, + "car_num": "44", + "driver_name": "C. Verstappen", + "best_time": "01:23.945", + "status": "safe", + "session_times": {"Q1": "01:25.456", "Q2": "01:23.945"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 3, + "car_num": "4", + "driver_name": "L. Norris", + "best_time": "01:24.012", + "status": "safe", + "session_times": {"Q1": "01:25.234", "Q2": "01:24.012"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 4, + "car_num": "81", + "driver_name": "O. Piastri", + "best_time": "01:24.156", + "status": "safe", + "session_times": {"Q1": "01:25.345", "Q2": "01:24.156"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 5, + "car_num": "16", + "driver_name": "C. Leclerc", + "best_time": "01:24.234", + "status": "safe", + "session_times": {"Q1": "01:25.456", "Q2": "01:24.234"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 6, + "car_num": "63", + "driver_name": "G. Russell", + "best_time": "01:24.389", + "status": "safe", + "session_times": {"Q1": "01:25.567", "Q2": "01:24.389"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 7, + "car_num": "55", + "driver_name": "C. Sainz", + "best_time": "01:24.445", + "status": "safe", + "session_times": {"Q1": "01:25.678", "Q2": "01:24.445"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 8, + "car_num": "14", + "driver_name": "F. Alonso", + "best_time": "01:24.567", + "status": "safe", + "session_times": {"Q1": "01:25.789", "Q2": "01:24.567"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 9, + "car_num": "11", + "driver_name": "S. Perez", + "best_time": "01:24.623", + "status": "safe", + "session_times": {"Q1": "01:25.891", "Q2": "01:24.623"}, + "no_current_time": False, + "finished": True, + }, + # ── P10 at_risk — last Q3 spot, still on a flying lap ───────────────── + { + "position": 10, + "car_num": "18", + "driver_name": "L. Stroll", + "best_time": "01:24.678", + "status": "at_risk", + "session_times": {"Q1": "01:26.012", "Q2": "01:24.678"}, + "no_current_time": False, + "finished": False, # still on a flying lap — could improve or be beaten + }, + # ── P11 elimination_zone — first to drop, also still on a flying lap ── + { + "position": 11, + "car_num": "31", + "driver_name": "E. Ocon", + "best_time": "01:24.712", + "status": "elimination_zone", + "session_times": {"Q1": "01:26.123", "Q2": "01:24.712"}, + "no_current_time": False, + "finished": False, # chasing Stroll for P10 + }, + # ── P12-P15 elimination_zone — pitted; eliminated unless above pair swaps + { + "position": 12, + "car_num": "10", + "driver_name": "P. Gasly", + "best_time": "01:24.789", + "status": "elimination_zone", + "session_times": {"Q1": "01:26.234", "Q2": "01:24.789"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 13, + "car_num": "27", + "driver_name": "N. Hulkenberg", + "best_time": "01:24.834", + "status": "elimination_zone", + "session_times": {"Q1": "01:26.345", "Q2": "01:24.834"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 14, + "car_num": "77", + "driver_name": "V. Bottas", + "best_time": "01:24.956", + "status": "elimination_zone", + "session_times": {"Q1": "01:26.456", "Q2": "01:24.956"}, + "no_current_time": False, + "finished": True, + }, + { + "position": 15, + "car_num": "24", + "driver_name": "G. Zhou", + "best_time": "01:25.012", + "status": "elimination_zone", + "session_times": {"Q1": "01:26.567", "Q2": "01:25.012"}, + "no_current_time": False, + "finished": True, + }, + # ── P16-P20 knocked_out — eliminated in Q1, did not run Q2 ──────────── + { + "position": 16, + "car_num": "22", + "driver_name": "Y. Tsunoda", + "best_time": "01:26.789", + "status": "knocked_out", + "session_times": {"Q1": "01:26.789", "Q2": ""}, + "no_current_time": False, + "finished": False, + }, + { + "position": 17, + "car_num": "20", + "driver_name": "K. Magnussen", + "best_time": "01:26.891", + "status": "knocked_out", + "session_times": {"Q1": "01:26.891", "Q2": ""}, + "no_current_time": False, + "finished": False, + }, + { + "position": 18, + "car_num": "23", + "driver_name": "A. Albon", + "best_time": "01:27.012", + "status": "knocked_out", + "session_times": {"Q1": "01:27.012", "Q2": ""}, + "no_current_time": False, + "finished": False, + }, + { + "position": 19, + "car_num": "2", + "driver_name": "S. Sargeant", + "best_time": "01:27.123", + "status": "knocked_out", + "session_times": {"Q1": "01:27.123", "Q2": ""}, + "no_current_time": False, + "finished": False, + }, + { + "position": 20, + "car_num": "6", + "driver_name": "N. Latifi", + "best_time": "01:27.567", + "status": "knocked_out", + "session_times": {"Q1": "01:27.567", "Q2": ""}, + "no_current_time": False, + "finished": False, + }, + ], +} + +# ALT state: same Q2 standings mid-session — clock running, no checkered flag, +# no driver has taken the flag yet. Use /trigger-alt to toggle. +ALT_F1_STATE: dict = { + **STATIC_F1_STATE, + "time_remaining": "2:15", + "checkered_flag": False, + "drivers": [{**d, "finished": False} for d in STATIC_F1_STATE["drivers"]], +} + +STATIC_RC_MESSAGE: dict = { + "title": "Q2 — Checkered Flag", + "text": "Stroll P10 / Ocon P11 on final laps — Q3 lineup not yet confirmed", +} + +# --------------------------------------------------------------------------- +# Shared state +# --------------------------------------------------------------------------- + +_rc_clients: list[queue.Queue] = [] +_f1_clients: list[queue.Queue] = [] +_reload_clients: list[queue.Queue] = [] +_clients_lock = threading.Lock() + +# Mutable current F1 state (can be swapped to ALT via /trigger-alt) +_current_f1_state: dict = STATIC_F1_STATE + + +def _broadcast_f1(state: dict) -> None: + payload = json.dumps(state) + with _clients_lock: + for q in list(_f1_clients): + q.put(payload) + + +def _broadcast_rc(msg: dict) -> None: + payload = json.dumps(msg) + with _clients_lock: + for q in list(_rc_clients): + q.put(payload) + + +def _broadcast_reload() -> None: + with _clients_lock: + for q in list(_reload_clients): + q.put("reload") + + +# --------------------------------------------------------------------------- +# Background: periodic broadcaster +# --------------------------------------------------------------------------- + + +def _broadcaster(rc_delay: float, rc_interval: float) -> None: + """ + Push the F1 state immediately, then every 2 s so the tower is always + visible when you refresh. Fire the RC banner after *rc_delay* seconds + and repeat every *rc_interval* seconds. + """ + time.sleep(0.5) # brief pause so the HTTP server is fully up + _broadcast_f1(_current_f1_state) + + rc_countdown = rc_delay + tick = 2.0 + while True: + time.sleep(tick) + _broadcast_f1(_current_f1_state) + rc_countdown -= tick + if rc_countdown <= 0: + _broadcast_rc(STATIC_RC_MESSAGE) + rc_countdown = rc_interval + + +# --------------------------------------------------------------------------- +# Background: live-reload file watcher +# --------------------------------------------------------------------------- + + +def _file_watcher(watched_paths: list[Path]) -> None: + """Reload connected browsers whenever any watched file changes on disk.""" + mtimes: dict[Path, float] = { + p: (p.stat().st_mtime if p.exists() else 0.0) for p in watched_paths + } + while True: + time.sleep(1.0) + for p in watched_paths: + if not p.exists(): + continue + mtime = p.stat().st_mtime + if mtime != mtimes[p]: + mtimes[p] = mtime + print(f" [live-reload] {p.name} changed — refreshing browsers") + _broadcast_reload() + break # one reload per tick is enough even if both files changed + + +# --------------------------------------------------------------------------- +# HTTP handler +# --------------------------------------------------------------------------- + +_LIVE_RELOAD_SNIPPET = """ + +""" + + +class _Server(ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + + +class PreviewHandler(BaseHTTPRequestHandler): + """Handles all requests for the preview server.""" + + # Injected before the server starts (avoids passing state through __init__) + port: int = 9765 + width: int = 1920 + height: int = 1080 + + # ------------------------------------------------------------------ # + # Routing # + # ------------------------------------------------------------------ # + + def do_GET(self) -> None: # noqa: N802 + global _current_f1_state + + path = self.path.split("?")[0] + + match path: + case "/sse/rc": + self._handle_sse(_rc_clients, initial_payload=None) + case "/sse/f1": + self._handle_sse( + _f1_clients, initial_payload=json.dumps(_current_f1_state) + ) + case "/sse/reload": + self._handle_sse(_reload_clients, initial_payload=None) + case "/static/overlay.css": + self._serve_file(_DEFAULT_CSS, "text/css") + case "/trigger-rc": + _broadcast_rc(STATIC_RC_MESSAGE) + self._plain_response(200, "RC banner triggered.") + case "/trigger-alt": + _current_f1_state = ( + ALT_F1_STATE + if _current_f1_state is STATIC_F1_STATE + else STATIC_F1_STATE + ) + label = ( + "ALT (mid-Q2, clock running, no checkered)" + if _current_f1_state is ALT_F1_STATE + else "MAIN (Q2 checkered, Stroll/Ocon on final laps)" + ) + _broadcast_f1(_current_f1_state) + self._plain_response(200, f"Switched to {label}.") + case _ if path.startswith("/static/fonts/"): + font_name = path[len("/static/fonts/") :] + self._serve_file(_FONTS_DIR / font_name, "font/truetype") + case _: + self._serve_html(_OVERLAY_HTML) + + # ------------------------------------------------------------------ # + # SSE # + # ------------------------------------------------------------------ # + + def _handle_sse(self, client_list: list, initial_payload: str | None) -> None: + """Hold the connection open and stream data events.""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + client_q: queue.Queue = queue.Queue() + if initial_payload is not None: + client_q.put(initial_payload) + + with _clients_lock: + client_list.append(client_q) + + try: + while True: + try: + data = client_q.get(timeout=15) + self.wfile.write(f"data: {data}\n\n".encode()) + self.wfile.flush() + except queue.Empty: + # Keepalive comment so the browser does not close the stream. + self.wfile.write(b": ping\n\n") + self.wfile.flush() + except Exception: + pass + finally: + with _clients_lock: + if client_q in client_list: + client_list.remove(client_q) + + # ------------------------------------------------------------------ # + # Static / HTML # + # ------------------------------------------------------------------ # + + def _serve_html(self, file_path: Path) -> None: + if not file_path.exists(): + self.send_error(404, f"Overlay not found: {file_path.name}") + return + content = ( + file_path.read_text(encoding="utf-8") + .replace("{{WIDTH}}", str(self.width)) + .replace("{{HEIGHT}}", str(self.height)) + .replace("{{PORT}}", str(self.port)) + ) + # Inject live-reload listener before + body = content.replace("", _LIVE_RELOAD_SNIPPET + "", 1) + encoded = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _serve_file(self, file_path: Path, mime: str) -> None: + if not file_path.exists(): + self.send_error(404, f"Not found: {file_path.name}") + return + data = file_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _plain_response(self, code: int, text: str) -> None: + body = text.encode() + self.send_response(code) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + # Suppress per-request access-log noise in the terminal. + def log_message(self, format, *args): # noqa: N802, A002 + pass + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Overlay appearance preview server — no iRacing connection needed.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--port", type=int, default=9765, help="HTTP port (default 9765)" + ) + parser.add_argument( + "--width", type=int, default=1920, help="Overlay canvas width (default 1920)" + ) + parser.add_argument( + "--height", type=int, default=1080, help="Overlay canvas height (default 1080)" + ) + parser.add_argument( + "--rc-delay", + type=float, + default=3.0, + help="Seconds before first RC banner (default 3)", + ) + parser.add_argument( + "--rc-interval", + type=float, + default=30.0, + help="Seconds between RC banners (default 30)", + ) + parser.add_argument( + "--no-browser", action="store_true", help="Don't auto-open a browser tab" + ) + args = parser.parse_args() + + # Patch class-level config into handler before the server starts. + PreviewHandler.port = args.port + PreviewHandler.width = args.width + PreviewHandler.height = args.height + + server = _Server(("", args.port), PreviewHandler) + + # ── Background broadcaster ────────────────────────────────────────── + threading.Thread( + target=_broadcaster, + args=(args.rc_delay, args.rc_interval), + daemon=True, + name="preview-broadcaster", + ).start() + + # ── Live-reload file watcher ──────────────────────────────────────── + watched = [p for p in [_OVERLAY_HTML, _DEFAULT_CSS] if p.exists()] + if watched: + threading.Thread( + target=_file_watcher, + args=(watched,), + daemon=True, + name="preview-watcher", + ).start() + + url = f"http://localhost:{args.port}/" + sep = "─" * 60 + print(f"\n {sep}") + print(f" Overlay preview server") + print(f" {sep}") + print(f" Main overlay → {url}") + print(f" Scenario → Q2 checkered flag — Stroll / Ocon still on flying laps") + print( + f" RC banner → fires in {args.rc_delay:.0f}s, then every {args.rc_interval:.0f}s" + ) + print(f" Live reload → active (edit overlay.html / overlay.css to trigger)") + print(f" {sep}") + print(f" Endpoints:") + print(f" GET / — overlay page (main + timing tower + banner)") + print(f" GET /trigger-rc — send RC banner immediately") + print(f" GET /trigger-alt — toggle mid-Q2 view (clock running, no checkered)") + print(f" {sep}") + print(f" Press Ctrl+C to stop.\n") + + if not args.no_browser: + threading.Timer(0.6, webbrowser.open, args=(url,)).start() + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n Stopping preview server.") + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() From 1598beec34d294cc18cb21092ff714ddf5758a0e Mon Sep 17 00:00:00 2001 From: Tyler Agostino Date: Sat, 9 May 2026 22:23:50 -0400 Subject: [PATCH 2/4] cleanup --- modules/events/overlay_consumer_event.py | 14 +- modules/flet_pages/overlay.css | 2231 ---------------------- modules/flet_pages/race_control.py | 15 - tests/preview_overlay.py | 12 +- 4 files changed, 7 insertions(+), 2265 deletions(-) delete mode 100644 modules/flet_pages/overlay.css diff --git a/modules/events/overlay_consumer_event.py b/modules/events/overlay_consumer_event.py index 0aca1c5..9db2c3d 100644 --- a/modules/events/overlay_consumer_event.py +++ b/modules/events/overlay_consumer_event.py @@ -8,8 +8,7 @@ GET / – Index page with links to available overlays. GET /rc-message – Race-control message banner (transparent; place at bottom). GET /f1-timing – F1 qualifying timing tower (transparent; place at left). -GET /static/overlay.css – CSS theme file (swappable via ``css_file`` constructor arg). -GET /static/fonts/ – Font files resolved from the same directory as the CSS. +GET /static/fonts/ – Font files served from the flet_pages/fonts/ directory. GET /sse/rc – Server-Sent Events stream for race-control messages. GET /sse/f1 – Server-Sent Events stream for F1 timing state. @@ -54,10 +53,8 @@ _THIS_DIR = Path(__file__).parent _OVERLAY_HTML = _THIS_DIR.parent / "flet_pages" / "overlays" / "overlay.html" -_DEFAULT_CSS = _THIS_DIR.parent / "flet_pages" / "overlay.css" _STANDINGS_HTML = _THIS_DIR.parent / "flet_pages" / "overlays" / "standings.html" -# Fonts live next to the CSS file -_FONTS_DIR = _DEFAULT_CSS.parent / "fonts" +_FONTS_DIR = _OVERLAY_HTML.parent.parent / "fonts" # --------------------------------------------------------------------------- # @@ -97,9 +94,6 @@ class OverlayConsumerEvent(BaseEvent): Overlay canvas width in pixels (default 1920). height : int Overlay canvas height in pixels (default 1080). - css_file : str - Absolute or relative path to an alternate CSS theme file. - Leave empty to use the bundled ``overlay.css``. """ def __init__( @@ -107,7 +101,6 @@ def __init__( port: int = 8765, width: int = 1920, height: int = 1080, - css_file: str = "", *args, **kwargs, ): @@ -115,7 +108,6 @@ def __init__( self.port = int(port) self.width = int(width) self.height = int(height) - self.css_path = Path(css_file) if css_file else _DEFAULT_CSS # Per-overlay SSE client queues: list of queue.Queue, one per connected tab. self._rc_clients: list[queue.Queue] = [] @@ -383,8 +375,6 @@ def do_GET(self): self._handle_sse(event._rc_clients) elif path == "/sse/f1": self._handle_sse(event._f1_clients) - elif path == "/static/overlay.css": - self._serve_file(event.css_path, "text/css") elif path.startswith("/static/fonts/"): font_name = path[len("/static/fonts/") :] self._serve_file(_FONTS_DIR / font_name, "font/truetype") diff --git a/modules/flet_pages/overlay.css b/modules/flet_pages/overlay.css deleted file mode 100644 index 0199863..0000000 --- a/modules/flet_pages/overlay.css +++ /dev/null @@ -1,2231 +0,0 @@ -/* FONTS */ - -@font-face { - font-family: Saira-Black; - src: url("fonts/Saira-Black.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Bold; - src: url("fonts/Saira-Bold.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-ExtraBold; - src: url("fonts/Saira-ExtraBold.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-ExtraLight; - src: url("fonts/Saira-ExtraLight.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Light; - src: url("fonts/Saira-Light.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Medium; - src: url("fonts/Saira-Medium.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Regular; - src: url("fonts/Saira-Regular.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-SemiBold; - src: url("fonts/Saira-SemiBold.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Thin; - src: url("fonts/Saira-Thin.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-BlackItalic; - src: url("fonts/Saira-BlackItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-BoldItalic; - src: url("fonts/Saira-BoldItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-ExtraBoldItalic; - src: url("fonts/Saira-ExtraBoldItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-ExtraLightItalic; - src: url("fonts/Saira-ExtraLightItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-Italic; - src: url("fonts/Saira-Italic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-LightItalic; - src: url("fonts/Saira-LightItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-MediumItalic; - src: url("fonts/Saira-MediumItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-SemiBoldItalic; - src: url("fonts/Saira-SemiBoldItalic.ttf") format("opentype"); -} - -@font-face { - font-family: Saira-ThinItalic; - src: url("fonts/Saira-ThinItalic.ttf") format("opentype"); -} - -/* GLOBAL VARIABLES */ - -.overlay { - --sf-color: white; - --sf-text-color: white; - --sf-p1-text-color: #9a58ea; - --sf-togo-text-color: #fc5500; - --sf-gradation-background: linear-gradient( - to right, - black 40%, - var(--sf-color) 100% - ); - --replay-state-background-color: rgba(235, 235, 235, 0.9); - --header-logo: var(--series-image-url); - --left-footer-logo: url(https://www.apexracingtv.com/wp-content/uploads/vrs-logo-white.png); - --left-footer-logo-filter: none; - --right-footer-logo: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); - --right-footer-logo-filter: none; - --result-subtitle-logo: url(https://www.sdk-gaming.co.uk/wp-content/uploads/SF_result1.png); - --driver-position-background-color: #444444; - --driver-position-border-color: transparent; - --driver-position-text-color: white; - --driver-selected-color: green; - --driver-background-color: #20354d; /* Background of driver names*/ - --driver-background-color-darker: #000; /* Alternating darker background driver names*/ - --text-color: white; /* Text of driver names*/ - --text-shadow: none; - --driver-dimmed-color: #bbb; - --driver-first-name-color: white; - --driver-number-color: white; - --driver-number-outline-color: black; - --window-header-text-color: white; - --header-title-text-color: white; - --panel-background-color: #675a6bdd; - --panel-surface-background-color: #6b6b6b; - --panel-surface-background-color-light: #6b6b6bdd; - --panel-title-background-color-darker: black; - --window-header-background-color: #444444dd; - --header-title-background-color: linear-gradient( - -20deg, - black 50%, - grey 100% - ); - --header-title-background-color-lighter: linear-gradient( - to right, - black 4%, - grey 20% - ); - --header-title-background-color-alter: linear-gradient( - -20deg, - var(--sf-color) 2%, - grey 20%, - black 100% - ); - --pit-lane-background-color: #000; - --panel-background-color: #000; - --driver-close-color: white; - --driver-very-close-color: darkorange; - --driver-gain-color: lime; - --driver-loss-color: orange; - --driver-same-color: white; - --driver-personal-best-time-color: lime; - --driver-overall-best-time-color: magenta; - --driver-personal-best-sector-color: lime; - --driver-overall-best-sector-color: magenta; - --driver-completed-sector-color: darkorange; - --driver-current-sector-color: black; - --driver-out-lap-color: #00c0ff; - --driver-pit-lane-color: #7acdda; - --driver-pit-exit-color: limegreen; - --normal-font-size: 19px; - --driver-lapped-color: #87cefa; - --driver-lapping-color: red; - --black-font: Saira-Black; - --bold-font: Saira-Bold; - --extra-bold-font: Saira-ExtraBold; - --extra-light-font: Saira-ExtraLight; - --light-font: Saira-Light; - --medium-font: Saira-Medium; - --regular-font: Saira-Regular; - --semibold-font: Saira-SemiBold; - --thin-font: Saira-Thin; - --black-font-italic: Saira-BlackItalic; - --bold-font-italic: Saira-BoldItalic; - --extra-bold-font-italic: Saira-ExtraBoldItalic; - --extra-light-font-italic: Saira-ExtraLightItalic; - --light-font-italic: Saira-LightItalic; - --medium-font-italic: Saira-MediumItalic; - --regular-font-italic: Saira-Italic; - --semibold-font-italic: Saira-SemiBoldItalic; - --thin-font-italic: Saira-ThinItalic; - --car-number-font: var(--bold-font-italic); - --car-number-font-size: 23px; - --class-header-font: var(--medium-font); - --stats-color: white; - --fore-color: white; - --back-color: white; - --timer-font-color: white; - --timer-left-font-color: white; - --timer-font-size: 26px; - --timer-title-height: 30px; - --timer-time-height: 40px; -} -/* POSITION 1 HIGHLIGHT */ - -.overlay .p1 { - --driver-position-background-color: #63030c; - --driver-position-border-color: transparent; - --driver-position-text-color: white; -} - -/* LOGO */ - -.overlay > .logo > .logo-image { - background-image: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); - animation: logo-1-2 20s linear infinite; -} - -.overlay > .logo > .logo-image-2 { - background-image: var(--series-image-url); - transform: perspective(200px) tranlateY(-20px); - transform-origin: top center; - animation: logo-2-2 20s linear infinite; -} - -/* LOGO 2 */ - -.overlay.replay-mode > .logo, -.overlay.replay-mode > .logo2 { - opacity: 0; - transition: opacity 0s; -} - -.overlay > .logo2 > .logo-image { - background-image: url(https://www.sdk-gaming.co.uk/wp-content/uploads/2017/08/SDK-Gaming-logo-White-Small.png); -} - -/* DRIVER */ - -.driver, -.driver-wrapper > .multicar-team { - height: 27px; - background: #222222ee !important; /*linear-gradient(#ccc, #777, #222) !important;*/ - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - border: none !important; - border-radius: 0 0 0 0 !important; - color: white !important; - text-shadow: - 1.5px 1px 1px #333, - -0.3px -0.3px 0.3px #333, - -0.3px 0.3px 0.3px #333, - 0.3px -0.3px 0.3px #333, - 0.3px 0.3px 0.3px #333 !important; - overflow: visible !important; - padding-right: 1px; - border-top: solid var(--panel-surface-background-color) 2px !important; -} - -.driver > .color-stripe, -.driver > .driver-background, -.driver-wrapper > .multicar-team > .color-stripe, -.driver-wrapper > .multicar-team > .multicar-team-background { - display: none; -} - -.driver > .container > .data-wrapper, -.driver > .container > .interval, -.driver > .container > .number, -.driver > .container > .flag, -.driver > .container > .gain, -.driver > .container > .flag-wrapper-7x5, -.driver > .container > .pit-wrapper, -.driver > .container > .last-lap-time-wrapper { - line-height: 27px !important; -} - -.driver > .container > .pit-wrapper > .pit { - text-align: center; -} - -.driver > .container { - display: flow-root; - overflow: visible !important; -} - -.name-wrapper { - display: inline-block; - height: 26px !important; - border-top: solid #ccc 0px; - border-right: solid #ccc 0px; - border-left: solid #ccc 0px; - border-radius: 0 0 0 0; - padding-left: 2px !important; - margin-left: 0px !important; - margin-right: 0px !important; - color: white; - text-shadow: none; - background: var(--driver-background-color); - min-width: 30px; -} - -.name-wrapper > .name { - position: absolute; - line-height: 25px !important; - letter-spacing: 0 !important; -} - -.name-wrapper > .first-name, -.name-wrapper > .last-name, -.name-wrapper > .suffix { - display: inline-block; - line-height: 25px !important; - letter-spacing: 0 !important; - color: #ffffff !important; -} - -.name-wrapper > .first-name { - padding-left: 0 !important; -} - -.name-wrapper > .last-name { - padding-left: 8px !important; - font-family: var(--medium-font-italic); -} - -.name-wrapper > .suffix { - padding-right: 20px !important; -} - -.driver > .container > .data-wrapper.show-pit-count.allow-data { - width: 115px !important; -} - -.driver > .container > .data-wrapper.show-last-pit-lap.allow-data { - width: 115px !important; -} - -.driver > .container > .data-wrapper.show-last-pit-time.allow-data, -.driver > .container > .data-wrapper.show-last-pit-lane-time.allow-data { - width: 115px !important; -} - -.driver > .container > .data-wrapper.show-off-track-count.allow-data { - color: gold; -} - -/* POSITION BOX */ - -.position-wrapper { - position: relative; - margin-left: 8px; - z-index: 10; - clip-path: none; - width: 36px; - height: 23px; - transform: translateX(-8px); - border-radius: 0 0 0 0; - border-color: var(--driver-position-border-color) !important; - border-width: 2px !important; - border-style: solid; -} - -.position-wrapper > .position { - font-size: 24px; - line-height: 22px; - height: 24px; - font-family: var(--bold-font); - text-shadow: - 1.5px 1px 1px #202, - -0.3px -0.3px 0.3px #202, - -0.3px 0.3px 0.3px #202, - 0.3px -0.3px 0.3px #202, - 0.3px 0.3px 0.3px #202; -} - -.position-wrapper:before { - content: ""; - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - background: transparent; -} - -.position-displays-selected-driver.driver > .position-wrapper > .position, -.position-displays-selected-driver > .driver > .position-wrapper > .position { - color: var(--sf-text-color); - text-shadow: none; -} - -/* BATTLE BOX */ - -.overlay > .battle > .header-wrapper, -.overlay > .battle > .data-wrapper { - margin-right: 0px; - overflow: visible; -} - -.overlay > .battle > .header-wrapper { - margin-bottom: 10px; - height: 27px; -} - -.overlay > .battle > .header-wrapper > .driver { - border-radius: 0 0 0 0 !important; - border-right: solid 1px #ccc; -} - -.overlay > .battle > .header-wrapper > .driver > .driver-background { - display: block; - background: black; - color: white; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222 !important; - font-family: var(--medium-font); - letter-spacing: 0; -} - -.overlay > .battle .driver > .container > .interval { - width: 60px; -} - -/* STATE HEADER */ - -.overlay > .state-header { - top: 40px; - left: 54px; - overflow: visible; - transform: translateZ(0px); -} - -.overlay > .state-header > .container { - border-radius: 0 0 0 0; - border: 2px solid black; - border-radius: 0 10px 0 0; - color: white; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - background: var(--driver-position-background-color); - font-family: var(--semibold-font); -} - -/* DRIVER DETAILS */ - -.overlay > .driver-details { - /* Driver Details - Shows driver and team name etc*/ - left: 40px; - bottom: 20px; - transform: perspective(1000px) translateZ(0px); -} - -.overlay.pit-lane > .driver-details { - bottom: 220px; -} - -.overlay.hide > .driver-details { - opacity: 1; - background: blue; -} - -.overlay > .driver-details.hide { - animation: sf-go-hide 0.5s; - animation-timing-function: ease; - opacity: 0; -} - -.driver-details > .data-wrapper { - background: url(https://www.sdk-gaming.co.uk/wp-content/uploads/SF_driverInfo.png); - background-size: 100% 100%; - background-repeat: no-repeat; -} - -.driver-details > .data-wrapper > .upper-data-wrapper { - height: 50px !important; - margin-left: 0px; - margin-top: 0px; - background: transparent !important; - border-top: 0px solid var(--panel-surface-background-color) !important; -} - -.upper-data-wrapper > .text { - line-height: 34px !important; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .name-wrapper { - margin-left: 0px !important; - height: 46px !important; - background: transparent; - border-bottom: 2px solid var(--sf-color) !important; - min-width: 240px; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .name-wrapper - > .first-name { - line-height: 58px !important; - height: 46px !important; - font-family: var(--medium-font-italic); - font-size: 24px; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .name-wrapper - > .last-name { - line-height: 58px !important; - height: 46px !important; - font-family: var(--medium-font-italic); - font-size: 24px; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .name-wrapper - > .suffix { - line-height: 48px !important; - height: 46px !important; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .number { - margin-top: 16px; - line-height: 28px !important; - height: 24px !important; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .flag { - padding-top: 15px; - height: 28px !important; - width: 26px; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .container - > .gain { - line-height: 48px !important; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .position-wrapper { - margin-top: 8px; - margin-left: 22px !important; - height: 32px; - border-top-left-radius: 16px; -} - -.overlay - > .driver-details - > .data-wrapper - > .upper-data-wrapper - > .position-wrapper - > .position { - line-height: 34px; - padding-left: 1px; - transition: transform 0.2s ease-out 0.3s; - font-family: var(--medium-font-italic) !important; -} - -.overlay > .driver-details.photo > .data-wrapper > .upper-data-wrapper, -.overlay > .driver-details.helmet > .data-wrapper > .upper-data-wrapper { - padding-right: 130px; -} - -.overlay > .driver-details > .data-wrapper > .photo-wrapper > .helmet { - transform: scale(1); - padding-bottom: 20px; -} - -.overlay > .driver-details > .data-wrapper > .photo-wrapper > .photo { - transform: scale(1); - padding-bottom: 10px; -} - -.overlay > .driver-details.photo > .data-wrapper > .photo-wrapper > .photo, -.overlay > .driver-details.helmet > .data-wrapper > .photo-wrapper > .helmet { - right: 16px; -} - -.overlay > .driver-details > .data-wrapper > .middle-data-wrapper { - background: transparent !important; - border-left: solid #ccc 0px; - border-right: solid #ccc 0px; - height: 40px !important; - color: white; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.overlay > .driver-details > .data-wrapper > .lower-data-wrapper { - display: none; -} - -.overlay > .driver-details.hide > .data-wrapper > .middle-data-wrapper, -.overlay > .driver-details.hide > .data-wrapper > .lower-data-wrapper { - opacity: 0; -} - -.overlay > .driver-details.team > .data-wrapper > .lower-data-wrapper { - opacity: 0; -} - -.middle-data-wrapper > .text { - padding-left: 62px !important; - line-height: 34px; -} - -.middle-data-wrapper > .text, -.lower-data-wrapper > .text1, -.lower-data-wrapper > .data1, -.lower-data-wrapper > .text2, -.lower-data-wrapper > .data2 { - font-family: var(--medium-font); -} - -.driver-details.team.lap-times .middle-data-wrapper { - border-radius: 0 0 0 0; -} - -.driver-details.unknown-position - > .data-wrapper - > .upper-data-wrapper - > .position-wrapper { - border: none; -} - -/* TIMING TOWER */ - -.overlay > .standings { - top: 36px; - left: 34px; - transform: perspective(1000px) translateZ(0px); - transform-origin: top left; - font-family: var(--medium-font); -} - -.standings > .driver-wrapper { - top: 0 !important; - /*padding-bottom: 2px !important;*/ -} - -.overlay - > .standings - > .driver-wrapper.lapped:not(.dimmed) - > .driver - > .container - > .name-wrapper { - color: var(--driver-lapped-color); -} - -.overlay > .standings > .driver-wrapper.show-driver.allow-driver.out { - display: none; -} - -.overlay > .standings.grouped-header-class-color > .header-wrapper { - background: linear-gradient(90deg, #000, #222, #666); - border: none; - color: var(--class-color, var(--panel-background-color)); -} - -.overlay > .standings > .header-wrapper > .header { - min-width: 100px; - font-family: var(--medium-font); -} - -.overlay > .standings > .driver-wrapper > .driver > .container > .name-wrapper { - margin-left: -4px; - margin-right: 4px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.full-name { - width: 195px; -} - -/* LAST.F */ -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.short-name { - width: 150px; -} - -/* F.LAST */ -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.short-name-2 { - width: 150px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.last-name { - width: 150px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.team-name { - width: 280px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.initials { - width: 45px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper.three-letters { - width: 52px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .name-wrapper - > .name { - font-family: var(--bold-font); -} - -.overlay > .standings > .driver-wrapper.dimmed > .driver { - color: var(--driver-dimmed-color); -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-gap.allow-data { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver.p1 - > .container - > .data-wrapper.show-gap.allow-data - > .data { - width: 95px; - color: var(--sf-p1-text-color); -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-interval.allow-data { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-interval.allow-data - > .data { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver.p1 - > .container - > .data-wrapper.show-interval.allow-data { - width: 95px; - color: var(--sf-p1-text-color); -} - -.overlay - > .standings - > .driver-wrapper - > .driver.p1 - > .container - > .data-wrapper.show-interval.allow-data - > .data { - width: 95px; - font-size: 20px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-flag.allow-data { - width: 36px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-flag - > .flag { - width: 36px; - padding-left: 10px; - transition: width 0.2s ease-in 0.2s; -} - -.overlay - > .standings - > .driver-wrapper.out - > .driver - > .container - > .pit-wrapper - > .pit { - color: var(--driver-dimmed-color); - transition: color 0s; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-car-number.allow-data { - width: 54px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-car-number - > .data { - font-size: 18px; - min-width: 38px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-last-lap-time.allow-data { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-best-lap-time.allow-data { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .last-lap-time-wrapper - > .last-lap-time { - width: 95px; -} - -.overlay - > .standings - > .driver-wrapper.show-pit - > .driver - > .container - > .pit-wrapper { - width: 83px; -} - -.overlay - > .standings - > .driver-wrapper.show-out-lap - > .driver - > .container - > .pit-wrapper { - width: 83px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-car-logo.allow-data { - width: 45px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-car-logo - > .car-logo { - transform: perspective(500px) translateZ(-70px); - transform-origin: center; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-pit-count.allow-data { - width: 5.5em; -} - -.overlay - > .standings - > .driver-wrapper.show-lap-time - > .driver - > .container - > .last-lap-time-wrapper { - width: 6em; - padding-left: 5px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-gain.allow-data { - width: 65px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-gain - > .data { - text-align: center; - width: 45px; - font-family: var(--bold-font); -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-top-speed.allow-data { - width: 105px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-top-speed - > .data2 { - font-family: var(--bold-font); -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license.allow-data { - width: 65px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license - > .data { - width: 2.7em; - text-align: left; - padding-left: 7px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-detail.allow-data { - width: 115px; - padding-left: 18px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-detail - > .data { - width: 95px; - padding-left: 18px; - text-align: left; - font-family: var(--medium-font-italic); - font-size: 18px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-only.allow-data { - width: 1.8em; - padding-left: 16px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-only - > .data { - text-align: left; - width: 1.2em; - padding-left: 16px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-sr.allow-data { - width: 2.4em; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-license-sr - > .data { - text-align: left; - width: 2em; - padding-left: 18px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-irating.allow-data { - width: 2.6em; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-irating - > .data { - text-align: left; - width: 2em; - padding-left: 18px; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-off-track-count.allow-data { - width: 2.2em; -} - -.overlay - > .standings - > .driver-wrapper - > .driver - > .container - > .data-wrapper.show-irating-gain.allow-data { - width: 2.4em; -} - -/* RACE INFO, GRID, RESULTS, CHAMPIONSHIP COMMON ITEMS */ - -.grid > .grid-wrapper, -.results > .results-wrapper, -.championship > .results-wrapper { - margin-left: 0px; - margin-right: 0px; -} - -.header-wrapper > .header > .header-logo-wrapper { - transform: translateX(-6px); - border-radius: 0 0 0 0; - border: solid #530e37; - border-width: 3px; - background: #000 !important; - z-index: 2; -} - -.header-wrapper > .header { - grid-row-gap: 0 !important; -} - -.header-wrapper > .header > .title-wrapper { - padding-left: 28px !important; - margin-left: -30px !important; - margin-right: -1px !important; -} - -.header-wrapper > .header > .title-wrapper > .title { - font-family: var(--medium-font); - font-size: 30px; - line-height: 28px; -} - -.header-wrapper > .header > .subtitle-wrapper { - background: #222 !important; /*linear-gradient(#ccc, #777, #222) !important;*/ - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - color: white !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - margin-left: 0px; - margin-right: 0px; -} - -.header-wrapper > .header > .subtitle-wrapper > .subtitle { - color: white !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.footer-wrapper { - background: #000; /*linear-gradient(#ccc, #777, #222) !important;*/ - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - border-radius: 0 0 0 0 !important; - border-left: solid #222 1px !important; - border-right: solid #222 1px !important; - color: white !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - margin-left: 0px; - margin-right: 0px; -} - -.header-wrapper > .header > .header-logo-wrapper > .header-logo, -.header-wrapper > .header > .title-wrapper > .title, -.header-wrapper > .header > .subtitle-wrapper > .subtitle, -.subtitle-wrapper > .class-header, -.footer-wrapper > .footer { - letter-spacing: 0 !important; -} - -.header-wrapper > .header > .subtitle-wrapper { - margin-left: -20px; - padding-left: 20px; - border-radius: 0 0 0 0; -} - -.header-wrapper > .header > .subtitle-wrapper > .subtitle { - color: black; -} - -.footer-wrapper > .footer > .middle-footer { - color: white; - text-shadow: - 1.5px 1px 1px #202, - -0.3px -0.3px 0.3px #202, - -0.3px 0.3px 0.3px #202, - 0.3px -0.3px 0.3px #202, - 0.3px 0.3px 0.3px #202; -} - -.color-stripe2-wrapper, -.color-stripe3-wrapper { - display: none; -} - -/* RESULTS, CHAMPIONSHIP COMMON ITEMS */ - -.results, -.championship { - grid-template-rows: auto 15px auto 540px 15px auto !important; - top: 120px !important; -} - -.sub-header-wrapper { - border-radius: 0 0 0 0 !important; - border: solid #222 2px !important; - background: #222 !important; -} - -.sub-header-wrapper > .header { - display: block; - color: white; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222 !important; - font-family: var(--medium-font); - letter-spacing: 0; -} - -/* RESULTS */ - -.results-wrapper > .driver-wrapper { - margin-left: 0px; - margin-right: 0px; -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .name-wrapper { - background: var(--sf-gradation-background) !important; -} - -.results-wrapper > .driver-wrapper > .driver > .container > .multicar-team-name, -.results-wrapper - > .driver-wrapper - > .multicar-team - > .container - > .multicar-team-name, -.results-wrapper > .driver-wrapper > .driver > .container > .number-wrapper, -.results-wrapper > .driver-wrapper > .driver > .container > .team-name, -.results-wrapper > .driver-wrapper > .driver > .container > .gap, -.results-wrapper > .driver-wrapper > .driver > .container > .points, -.results-wrapper > .driver-wrapper > .multicar-team > .container > .points { - line-height: 25px !important; -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .flag { - display: none; -} - -.overlay > .results > .sub-header-wrapper > .header { - color: var(--sf-color); - height: 10px; - width: 600px; - padding-left: 250px; - font-family: var(--medium-font); - font-size: 18px; - line-height: 14px; - background-image: var(--result-subtitle-logo); - background-position: left center; - background-repeat: no-repeat; -} - -.results > .results-wrapper > .driver-wrapper > .driver > .container > .points, -.results - > .results-wrapper - > .driver-wrapper - > .multicar-team - > .container - > .points { - display: none; -} - -.results.session-results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .points, -.results.session-results - > .results-wrapper - > .driver-wrapper - > .multicar-team - > .container - > .points { - display: initial; -} -/* -.overlay>.results>.results-wrapper>.driver-wrapper>.driver>.container>.multicar-team-name { - display: none; -} -*/ - -.overlay > .results { - grid-template-rows: auto 15px auto 580px !important; - grid-template-columns: 0 1060px 0; -} - -.overlay > .results > .header-wrapper > .header > .title-wrapper { - line-height: 26px; -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .name-wrapper { - line-height: 24px; -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .name-wrapper - > .first-name { - font-family: var(--medium-font); -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .name-wrapper - > .last-name { - font-family: var(--medium-font); -} - -.overlay - > .results - > .results-wrapper - > .driver-wrapper - > .driver - > .container - > .number-wrapper { - background: var(--sf-color) !important; -} - -/* GRID DRIVERS */ - -.grid > .grid-wrapper > .grid-driver { - overflow: visible !important; -} - -.grid > .grid-wrapper > .grid-driver > .upper-block { - padding-right: 10px; - margin-left: 0px; - overflow: visible !important; - height: 40px; - border-radius: 0 0 0 0; -} - -.grid - > .grid-wrapper - > .grid-driver - > .upper-block - > .container - > .name-wrapper { - width: 220px; -} - -.overlay > .grid > .grid-wrapper > .grid-driver > .lower-block { - padding-right: 0px; - padding-left: 20px; - margin-left: 0px; - overflow: visible; - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - border-radius: 0 0 0 0 !important; - border-left: solid #ccc 1px !important; - border-right: solid #ccc 1px !important; - color: white !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - clip-path: polygon(0 100%, 98% 100%, 98% 0, 0 0); - margin-left: 40px; - margin-right: -9px; -} - -.overlay - > .grid - > .grid-wrapper - > .grid-driver - > .lower-block - > .team-name-wrapper { - padding-left: 8px; -} - -.overlay - > .grid - > .grid-wrapper - > .grid-driver - > .lower-block - > .qualifying-time-wrapper { - padding-right: 25px; -} - -.overlay - > .grid - > .grid-wrapper - > .grid-driver - > .lower-block - > .team-name-wrapper, -.overlay - > .grid - > .grid-wrapper - > .grid-driver - > .lower-block - > .qualifying-time-wrapper { - line-height: 25px; -} - -/* RACE INFO */ - -.race-info { - top: 70px !important; - /* grid-template-columns: 800px 400px !important;*/ - /* grid-template-rows: auto 197px 520px auto !important; - grid-template-rows: auto 0px 520px auto !important;*/ - /* transform: perspective(1000px) translateY(65px) translateZ(-115px) rotateX(4deg) rotateY(8deg);*/ - transform-origin: center; - opacity: 0.9; -} - -.overlay > .race-info > .track-wrapper > .track > .track-canvas { - max-height: 720px; - max-width: 800px; -} - -.overlay > .race-info > .track-wrapper > .track > .track { - width: 10px; -} - -.overlay > .race-info > .track-wrapper > .track > .track-shadow { - width: 15px; - color: var(--sf-color); -} - -.race-info > .track-wrapper > .track > .track-title, -.race-info > .weather-wrapper > .weather > .weather-title, -.race-info > .commentators-wrapper > .commentators > .commentators-title { - border-radius: 0 0 0 0; - padding: 0 20px 0 20px !important; - border-right: solid 1px #ccc; - line-height: 42px !important; - color: white; - font-family: var(--bold-font-italic) !important; - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; -} - -.race-info>.track-wrapper>.track, -/*.race-info>.weather-wrapper>.weather,*/ -.race-info>.commentators-wrapper>.commentators { - background: #222 !important; - color: white; - font-family: var(--regular-font); - font-size: 40px; - border-radius: 0 0 0 0; -} - -.overlay > .race-info > .weather-wrapper { - display: initial; -} - -.race-info > .weather-wrapper > .weather > .temperature, -.race-info > .weather-wrapper > .weather > .humidity, -.race-info > .weather-wrapper > .weather > .sky, -.race-info > .weather-wrapper > .weather > .wind { - background: #222 !important; - padding-top: 12px; -} - -.race-info > .track-wrapper > .track > .track-altitude, -.race-info > .track-wrapper > .track > .track-city, -.race-info > .track-wrapper > .track > .track-country, -.race-info > .track-wrapper > .track > .track-length, -.race-info > .track-wrapper > .track > .track-configuration-name { - background: #222 !important; - height: 35px; - padding-top: 10px !important; - font-family: var(--semibold-font) !important; - font-size: 30px !important; -} - -.race-info > .track-wrapper > .track > .track-temperature { - background: #222 !important; - height: 35px; - padding-top: 10px !important; - font-family: var(--semibold-font) !important; - font-size: 30px !important; - color: var(--temp-color) !important; -} - -.race-info > .track-wrapper > .track > .track-altitude-text, -.race-info > .track-wrapper > .track > .track-city-text, -.race-info > .track-wrapper > .track > .track-temperature-text, -.race-info > .track-wrapper > .track > .track-country-text, -.race-info > .track-wrapper > .track > .track-length-text, -.race-info > .track-wrapper > .track > .track-configuration-name-text { - height: 35px; - background: transparent !important; - padding-top: 10px !important; - font-size: 24px !important; -} - -.overlay - > .race-info - > .commentators-wrapper - > .commentators - > .commentators-text { - font-family: var(--medium-font-italic); - font-size: 22px; -} - -/* DASHBOARD */ - -.overlay > .dashboard { - overflow: visible; - background: transparent !important; - color: white; - font-family: var(--medium-font); - border-radius: 0 0 0 0; - text-shadow: - 1.5px 1px 1px #343, - -0.3px -0.3px 0.3px #343, - -0.3px 0.3px 0.3px #343, - 0.3px -0.3px 0.3px #343, - 0.3px 0.3px 0.3px #343; -} - -.overlay > .dashboard > .header { - border-radius: 0 0 0 0; - padding: 0 20px 0 20px !important; - line-height: 27px !important; - color: white; - background: transparent !important; /*linear-gradient(#ccc, #777, #222) !important;*/ - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.overlay > .dashboard > .gauge-wrapper, -.overlay > .dashboard > .gauge-wrapper > .upper-line, -.overlay > .dashboard > .gauge-wrapper > .lower-line { - background: transparent; -} - -.overlay > .dashboard > .gauge-wrapper { - height: 110px; -} - -.overlay > .dashboard > .gear-wrapper { - border-radius: 0 0 0 0; - padding: 0 20px 0 20px !important; - line-height: 35px !important; - color: white; - background: #333 !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.overlay > .dashboard > .gauge-wrapper > .half-ring-wrapper > .rpm-half-ring { - border: 2px solid var(--sf-color); -} - -/* WEATHER */ - -.weather > .icon-wrapper { - height: 35px !important; - width: 55px !important; - border-top: solid #ccc 1px; - border-right: solid #ccc 2px; - border-left: solid #ccc 1px; - border-radius: 0 8px 0 0; - color: black; - text-shadow: none; - background: white !important; -} - -.weather > .icon-wrapper > .icon { - filter: invert(100%); - height: 24px !important; - width: 24px !important; - margin-top: 5px !important; - margin-bottom: 5px !important; - margin-left: 15px !important; - margin-right: 10px !important; -} - -.weather > .value { - padding-left: 40px !important; - height: 35px; - margin-left: -25px; - border-top: solid 1px #ccc; - line-height: 35px !important; - color: white; - background: black !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.weather > .unit { - height: 35px; - padding-right: 40px !important; -} - -.weather > .value.date, -.weather > .value.time, -.weather > .value.sky, -.weather > .unit { - border-radius: 0 0 0 0; - border-top: solid 1px #ccc; - border-right: solid 1px #ccc; - line-height: 35px !important; - color: white; - background: black !important; - background-size: 100%, 100%, 18px !important; - background-position-x: right, right, right !important; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -/* TIME DELTA HISTORY */ - -.overlay > .time-delta-history { - right: 10px; -} - -.overlay > .time-delta-history > .header > .driver, -.overlay > .time-delta-history > .header > .driver > .name-wrapper { - background: var(--panel-background-color) !important; -} - -.overlay > .time-delta-history > .header > .driver > .name-wrapper > .name { - line-height: 27px; -} - -.overlay > .time-delta-history > .header > .time-delta { - background: #1e1e1e !important; - border-top: 2px solid var(--panel-surface-background-color); - height: 27px; - line-height: 27px; - color: white; - font-family: var(--bold-font); - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - padding-right: 20px; -} - -.overlay > .time-delta-history > .chart-wrapper { - border-radius: 0 0 0 0; - color: white; - line-height: 18px; - text-shadow: - 1.5px 1px 1px #212, - -0.3px -0.3px 0.3px #212, - -0.3px 0.3px 0.3px #212, - 0.3px -0.3px 0.3px #212, - 0.3px 0.3px 0.3px #212; -} - -.overlay - > .time-delta-history - > .chart-wrapper - > .chart-body - > .chart-left-arrow-tip, -.overlay - > .time-delta-history - > .chart-wrapper - > .chart-body - > .chart-right-arrow-tip { - border-bottom: 12px solid white; -} - -.overlay > .time-delta-history > .chart-wrapper > .chart-body > .chart-bar { - background: var(--sf-color); -} - -/* INTERVIEW */ - -.overlay > .interview { - bottom: 110px; - left: 60px; - border-radius: 0 8px 0 0; - border: transparent; - line-height: 35px !important; - color: white; - background: linear-gradient( - to right, - black 20%, - var(--sf-color) 90% - ) !important; /*linear-gradient(#ccc, #777, #222) !important;*/ - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.overlay > .interview > .interview-text { - font-family: var(--medium-font-italic); - font-size: 20px; -} - -.overlay > .interview.hide { - opacity: 0; -} - -/* CLASSES */ - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class - > .driver-class-name-wrapper { - border: 0; - min-width: 160px; - background: var(--panel-surface-background-color); - border-radius: 10px; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class.left-side - > .driver-class-name-wrapper { - border: 0 !important; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class.right-side - > .driver-class-name-wrapper { - border: 0 !important; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class - > .driver-class-name-wrapper - > .driver-class-name { - background: none; - border: 0px !important; - color: white; - font-family: var(--bold-font); - font-size: 20px; - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class - > .driver-class-data-wrapper { - transform: translateX(0px); - height: 40px; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class.left-side - > .driver-class-data-wrapper { - border-radius: 14px; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class.right-side - > .driver-class-data-wrapper { - border-radius: 14px; -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class - > .driver-class-data-wrapper - > .driver-class-data { - font-family: var(--bold-font-italic); - position: absolute; - width: 100%; - font-size: 18px; - background: black; - padding: 6px 0px 6px 0px; -} - -.overlay - > .classes.hide-data - > .driver-classes-wrapper - > .driver-class - > .driver-class-data-wrapper - > .driver-class-data { - transform: translateY(0px); -} - -.overlay - > .classes - > .driver-classes-wrapper - > .driver-class - > .driver-class-data-wrapper - > .driver-class-data.hide { - transform: translateY(0px); -} - -/* SECTORS */ - -.overlay > .sectors { - bottom: 80px; - line-height: 27px; -} - -.overlay > .sectors > .driver { - height: 27px; -} - -.overlay.pit-lane > .sectors { - bottom: 140px; -} - -.overlay > .sectors > .title { - background: var(--panel-background-color); -} - -.overlay > .sectors > .driver.line-3 { - border-bottom: 1.5px solid var(--panel-surface-background-color) !important; -} - -.overlay > .sectors > .driver > .name-wrapper { - width: 150px; -} - -.overlay > .sectors > .driver > .name-wrapper > .name { - height: 27px; -} - -.overlay > .sectors > .sector-time { - height: 27px; -} - -.overlay > .sectors > .line-1 { - border-top: 2.5px solid var(--panel-surface-background-color); - border-bottom: 0px solid var(--panel-surface-background-color); -} - -.overlay > .sectors > .line-2 { - border-top: 2.5px solid var(--panel-surface-background-color); - border-bottom: 0px solid var(--panel-surface-background-color); -} - -.overlay > .sectors > .line-3 { - border-top: 2.5px solid var(--panel-surface-background-color); - border-bottom: 1.5px solid var(--panel-surface-background-color); -} - -/* PIT STOP HISTORY */ - -.overlay > .pit-stop-history > .header-wrapper { - margin-left: 4px; - border-radius: 10px 10px 0 0; - border-top: solid 2px var(--panel-surface-background-color); - border-left: solid 2px var(--panel-surface-background-color); - border-right: solid 2px var(--panel-surface-background-color); - background: var(--driver-background-color); -} - -.overlay > .pit-stop-history > .header-wrapper > .header { - text-shadow: - 1.5px 1px 1px #222, - -0.3px -0.3px 0.3px #222, - -0.3px 0.3px 0.3px #222, - 0.3px -0.3px 0.3px #222, - 0.3px 0.3px 0.3px #222; - border: none; - font-size: 22px; - font-family: var(--bold-font-italic); - background: transparent; -} - -.overlay > .pit-stop-history > .header-wrapper > .header.hide { - text-shadow: - 1.5px 1px 1px transparent, - -0.3px -0.3px 0.3px transparent, - -0.3px 0.3px 0.3px transparent; -} - -.overlay - > .pit-stop-history - > .data-wrapper - > .driver - > .container - > .stints - > .stint.even { - background: var(--sf-color); -} - -.overlay - > .pit-stop-history - > .data-wrapper - > .driver - > .container - > .stints - > .stint.odd { - background: gold; -} - -/* RADIO CHANNEL */ - -.overlay > .radio-channel { - top: 610px; - border: 0px solid transparent; - border-radius: 0 8px 0 0; - background: linear-gradient(to right, black 20%, var(--sf-color) 100%); -} - -.overlay > .radio-channel > .equalizer { - margin-top: 18px; -} - -.overlay > .radio-channel > .equalizer > .color-bar { - background: var(--sf-color); -} - -/* WINDOW and OTHERS */ - -.overlay > .window.window-1 { - top: 14px; - right: 224px; - width: 430px; - z-index: -10; - transform: perspective(1000px) translateZ(-115px) rotateX(4deg) - rotateY(10deg) rotateZ(3deg); - transform-origin: bottom center; - transition-delay: 1.5s; - transition: opacity 0.3s ease-in-out; -} - -.overlay > .window.window-1 > .header, -.overlay > .statistics > .header { - width: 155px; - text-align: center; - height: 35px; - line-height: 35px; - color: var(--sf-color); - margin-left: 5px; - margin-right: 5px; - padding-left: 10px; - border-style: none; - font-family: var(--bold-font); - font-size: 25px; - background: var(--panel-background-color); -} - -.overlay > .window.window-1 > .data-wrapper { - margin-left: 5px; - margin-top: 2px; - margin-right: 5px; - margin-bottom: 2px; - display: none; -} - -.overlay > .window.window-1 > .data-wrapper > .data, -.overlay > .statistics > .data-wrapper > .data { - background: var(--driver-background-color); - font-family: var(--regular-font); - font-size: 25px; - color: #ffffff; -} - -.overlay.hide > .window > .header { - opacity: 0; -} - -/* OTHERS */ - -.overlay > .replay-state { - left: 860px; - top: 40px; - background: var(--header-title-background-color) !important; - border-radius: 16px; - font-family: var(--semibold-font); - font-size: 22px; - text-shadow: 1.5px 1px 1px #333; -} - -.overlay > .inputs { - bottom: 40px; - right: 480px; - background: black; -} - -.overlay.battle > .inputs { - bottom: 412px; -} - -.overlay.pit-lane > .inputs { - bottom: 412px; -} - -.overlay > .track-map { - right: 0px; - top: 100px; - transform: perspective(1000px) translateZ(-350px); -} - -.overlay > .event { - bottom: unset; - top: 80px; - left: 300px; - width: 70%; -} - -.overlay.pit-lane > .event { - bottom: unset; - top: 80px; -} - -.overlay > .event > .header-wrapper { - background: var(--header-title-background-color-lighter); - height: 22px; - border: solid black 2px; - border-radius: 15px; - transform: translateX(-8px); - transform-origin: top left; -} - -.overlay > .event > .header-wrapper > .header { - color: white; - font-family: var(--medium-font-italic); - font-size: 15px; - margin-left: 10px; - margin-right: 10px; - padding-top: 0px; - transform: translateY(-4px) skew(-10deg); - transform-origin: top; -} - -.overlay > .event > .text-wrapper { - background: var(--header-title-background-color); - border: transparent; - border-radius: 2px; -} - -.overlay > .event > .text-wrapper > .text { - margin-left: 2px; - font-family: var(--medium-font); - font-size: 18px; - color: white; -} - -/* LAP TIME HISTORY */ - -.overlay > .lap-time-history { - bottom: 40px; -} - -.overlay.pit-lane > .lap-time-history { - bottom: 240px; -} - -.overlay > .lap-time-history > .driver > .name-wrapper { - width: 150px; - margin-bottom: 20px !important; -} - -/* PIT LANE */ - -.overlay > .pit-lane > .header { - margin-top: 0px; - margin-bottom: 0px; -} - -.overlay > .pit-lane > .driver-wrapper > .driver { - border-top: 1px solid transparent !important; -} -.overlay > .pit-lane > .driver-wrapper > .driver > .container { - overflow: hidden !important; -} - -.overlay > .pit-lane > .driver-wrapper > .driver > .container > .name-wrapper { - height: 29px !important; -} - -.overlay - > .pit-lane - > .driver-wrapper - > .driver - > .container - > .name-wrapper - > .name { - height: 29px; - line-height: 27px; - padding-left: 0px; -} - -.overlay > .pit-lane > .driver-wrapper > .driver > .container > .pit-lane-time { - margin-bottom: 24px; - line-height: 27px; -} - -.overlay > .pit-lane > .driver-wrapper > .driver > .container > .pit-time { - margin-bottom: 24px; - line-height: 27px; -} - -/* RACE CONTROL*/ - -.overlay > .race-control { - font-family: var(--semibold-font-italic); -} - -.overlay > .race-control.information > .header-wrapper { - background: var(--sf-color); -} diff --git a/modules/flet_pages/race_control.py b/modules/flet_pages/race_control.py index 8371f43..36e5197 100644 --- a/modules/flet_pages/race_control.py +++ b/modules/flet_pages/race_control.py @@ -99,7 +99,6 @@ def __init__(self): "port": "8765", "width": "1920", "height": "1080", - "css_file": "", } self.text_consumer_enabled = False self.audio_consumer_enabled = False @@ -2554,7 +2553,6 @@ def build_overlay_consumer(self): "port": "8765", "width": "1920", "height": "1080", - "css_file": "", } config = self.overlay_consumer_config @@ -2572,7 +2570,6 @@ def toggle_enabled(e): port_field.disabled = disabled width_field.disabled = disabled height_field.disabled = disabled - css_field.disabled = disabled self.page.update() enable_check = ft.Checkbox( @@ -2606,14 +2603,6 @@ def toggle_enabled(e): on_change=lambda e: update_config("height", e.control.value), ) - css_field = ft.TextField( - label="CSS file path (blank = bundled overlay.css)", - value=config.get("css_file", ""), - width=360, - disabled=not self.overlay_consumer_enabled or self.is_running, - on_change=lambda e: update_config("css_file", e.control.value), - ) - url_text = ft.Text( f"http://localhost:{config.get('port', '8765')}/", size=11, @@ -2631,7 +2620,6 @@ def toggle_enabled(e): ft.Divider(height=5), enable_check, ft.Row([port_field, width_field, height_field], spacing=8), - css_field, ft.Row( [ ft.Icon(ft.Icons.LINK, size=14, color=ft.Colors.BLUE), @@ -2936,9 +2924,6 @@ def start_race_control(self, e): "height": int( self.overlay_consumer_config.get("height", 1080) ), - "css_file": self.overlay_consumer_config.get( - "css_file", "" - ), }, } ) diff --git a/tests/preview_overlay.py b/tests/preview_overlay.py index 79bc8f5..53d5b8f 100644 --- a/tests/preview_overlay.py +++ b/tests/preview_overlay.py @@ -19,7 +19,7 @@ Live reload ----------- -Whenever ``overlay.html`` or ``overlay.css`` changes on disk, every +Whenever ``overlay.html`` changes on disk, every connected browser tab reloads automatically — no manual refresh needed. Manual RC trigger @@ -56,8 +56,7 @@ _THIS_DIR = Path(__file__).parent _REPO_DIR = _THIS_DIR.parent _OVERLAY_HTML = _REPO_DIR / "modules" / "flet_pages" / "overlays" / "overlay.html" -_DEFAULT_CSS = _REPO_DIR / "modules" / "flet_pages" / "overlay.css" -_FONTS_DIR = _DEFAULT_CSS.parent / "fonts" +_FONTS_DIR = _OVERLAY_HTML.parent.parent / "fonts" # --------------------------------------------------------------------------- # Static scenario data — Q2 checkered flag, session still live @@ -434,8 +433,7 @@ def do_GET(self) -> None: # noqa: N802 ) case "/sse/reload": self._handle_sse(_reload_clients, initial_payload=None) - case "/static/overlay.css": - self._serve_file(_DEFAULT_CSS, "text/css") + case "/trigger-rc": _broadcast_rc(STATIC_RC_MESSAGE) self._plain_response(200, "RC banner triggered.") @@ -595,7 +593,7 @@ def main() -> None: ).start() # ── Live-reload file watcher ──────────────────────────────────────── - watched = [p for p in [_OVERLAY_HTML, _DEFAULT_CSS] if p.exists()] + watched = [_OVERLAY_HTML] if _OVERLAY_HTML.exists() else [] if watched: threading.Thread( target=_file_watcher, @@ -614,7 +612,7 @@ def main() -> None: print( f" RC banner → fires in {args.rc_delay:.0f}s, then every {args.rc_interval:.0f}s" ) - print(f" Live reload → active (edit overlay.html / overlay.css to trigger)") + print(f" Live reload → active (edit overlay.html to trigger)") print(f" {sep}") print(f" Endpoints:") print(f" GET / — overlay page (main + timing tower + banner)") From a845009a81178b0e67189638b05857d5ac2e5b13 Mon Sep 17 00:00:00 2001 From: Tyler Agostino Date: Sun, 10 May 2026 12:10:48 -0400 Subject: [PATCH 3/4] font fixes --- modules/flet_pages/download_fonts.py | 60 ++++++++++++++++++++++ modules/flet_pages/overlays/overlay.html | 28 ++++++++-- modules/flet_pages/overlays/standings.html | 28 ++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 modules/flet_pages/download_fonts.py diff --git a/modules/flet_pages/download_fonts.py b/modules/flet_pages/download_fonts.py new file mode 100644 index 0000000..f78bba9 --- /dev/null +++ b/modules/flet_pages/download_fonts.py @@ -0,0 +1,60 @@ +""" +download_fonts.py +================= +Downloads the Saira font variants used by the OBS overlays into the +local ``fonts/`` directory so the overlays can serve them without +requiring an internet connection. + +Run once from the project root (or from this directory): + + python modules/flet_pages/download_fonts.py + +The overlay HTTP server already falls back to the Google Fonts CDN +automatically when the local files are absent, so this script is only +needed for fully-offline setups. +""" + +import urllib.request +from pathlib import Path + +FONTS_DIR = Path(__file__).parent / "fonts" + +# Saira v23 – sourced from Google Fonts CDN (fonts.gstatic.com). +# If these URLs ever become stale, visit: +# https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,400;0,700;0,900;1,700&display=swap +# and copy the updated src URLs. +FONT_FILES = { + "Saira-Black.ttf": ( + "https://fonts.gstatic.com/s/saira/v23/" + "memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA7_PFosg.ttf" + ), + "Saira-Bold.ttf": ( + "https://fonts.gstatic.com/s/saira/v23/" + "memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA773Fosg.ttf" + ), + "Saira-Regular.ttf": ( + "https://fonts.gstatic.com/s/saira/v23/" + "memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA71rCosg.ttf" + ), + "Saira-BoldItalic.ttf": ( + "https://fonts.gstatic.com/s/saira/v23/" + "memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBrYwxkw.ttf" + ), +} + + +def main() -> None: + FONTS_DIR.mkdir(parents=True, exist_ok=True) + for filename, url in FONT_FILES.items(): + dest = FONTS_DIR / filename + if dest.exists(): + print(f" [skip] {filename} (already present)") + continue + print(f" [fetch] {filename} ...", end=" ", flush=True) + urllib.request.urlretrieve(url, dest) + print(f"done ({dest.stat().st_size // 1024} KB)") + print("\nAll fonts ready in:", FONTS_DIR) + + +if __name__ == "__main__": + main() diff --git a/modules/flet_pages/overlays/overlay.html b/modules/flet_pages/overlays/overlay.html index 99a94db..577ee11 100644 --- a/modules/flet_pages/overlays/overlay.html +++ b/modules/flet_pages/overlays/overlay.html @@ -16,10 +16,30 @@ @@ -358,16 +662,32 @@
- Qualifying - --:-- + --:--
- + + +
+
+ Qualifying Standings + --:-- +
+
+
+
+ + +
@@ -383,6 +703,27 @@ diff --git a/modules/flet_pages/overlays/standings.html b/modules/flet_pages/overlays/standings.html deleted file mode 100644 index fb434e3..0000000 --- a/modules/flet_pages/overlays/standings.html +++ /dev/null @@ -1,479 +0,0 @@ - - - - - - - - -
-
- -
- Qualifying Standings - --:-- -
- - -
- - -
-
-
- - - - - diff --git a/modules/flet_pages/race_control.py b/modules/flet_pages/race_control.py index 36e5197..6edee8a 100644 --- a/modules/flet_pages/race_control.py +++ b/modules/flet_pages/race_control.py @@ -97,8 +97,6 @@ def __init__(self): self.chat_consumer_config = {} self.overlay_consumer_config = { "port": "8765", - "width": "1920", - "height": "1080", } self.text_consumer_enabled = False self.audio_consumer_enabled = False @@ -2551,8 +2549,6 @@ def build_overlay_consumer(self): if not self.overlay_consumer_config: self.overlay_consumer_config = { "port": "8765", - "width": "1920", - "height": "1080", } config = self.overlay_consumer_config @@ -2568,8 +2564,6 @@ def toggle_enabled(e): self.overlay_consumer_enabled = e.control.value disabled = not e.control.value or self.is_running port_field.disabled = disabled - width_field.disabled = disabled - height_field.disabled = disabled self.page.update() enable_check = ft.Checkbox( @@ -2587,22 +2581,6 @@ def toggle_enabled(e): on_change=lambda e: update_config("port", e.control.value), ) - width_field = ft.TextField( - label="Width (px)", - value=config.get("width", "1920"), - width=100, - disabled=not self.overlay_consumer_enabled or self.is_running, - on_change=lambda e: update_config("width", e.control.value), - ) - - height_field = ft.TextField( - label="Height (px)", - value=config.get("height", "1080"), - width=100, - disabled=not self.overlay_consumer_enabled or self.is_running, - on_change=lambda e: update_config("height", e.control.value), - ) - url_text = ft.Text( f"http://localhost:{config.get('port', '8765')}/", size=11, @@ -2618,8 +2596,14 @@ def toggle_enabled(e): weight=ft.FontWeight.BOLD, ), ft.Divider(height=5), + ft.Text( + "The overlay canvas automatically fills the OBS browser source size.", + size=11, + color=ft.Colors.ON_SURFACE_VARIANT, + italic=True, + ), enable_check, - ft.Row([port_field, width_field, height_field], spacing=8), + ft.Row([port_field], spacing=8), ft.Row( [ ft.Icon(ft.Icons.LINK, size=14, color=ft.Colors.BLUE), @@ -3480,7 +3464,7 @@ def _load_config_data(self, config: dict): self.overlay_consumer_enabled = overlay_consumer.get("enabled", False) self.overlay_consumer_config = overlay_consumer.get( "config", - {"port": "8765", "width": "1920", "height": "1080", "css_file": ""}, + {"port": "8765"}, ) def load_preset(self, name: str, silent: bool = False): diff --git a/tests/preview_overlay.py b/tests/preview_overlay.py index 53d5b8f..c7fb0de 100644 --- a/tests/preview_overlay.py +++ b/tests/preview_overlay.py @@ -1,37 +1,31 @@ #!/usr/bin/env python3 """ -preview_overlay.py — Overlay appearance test server -====================================================== -Serves the overlay HTML/CSS/SSE stack with **static data** so you can -tweak the overlay appearance without running the full Flet UI or -connecting to iRacing. - -Simulated scenario ------------------- - • Q2 qualifying — checkered flag out, session clock at 0:00 - • P1-P9 safe: have finished their laps, advancing to Q3 - • P10 L. Stroll at_risk: last safe spot, still on a flying lap (no finished flag) - • P11 E. Ocon elimination_zone: first to drop, also still on a flying lap - • P12-P15 elimination_zone: have pitted; sealed unless Stroll/Ocon swap with them - • P16-P20 knocked_out: eliminated in Q1, never ran in Q2 - • Race-control banner fires *rc_delay* seconds after the first client - connects, then repeats every *rc_interval* seconds +preview_overlay.py — Automated overlay simulation preview server +================================================================== + +Serves the overlay HTML/CSS/SSE stack with a **fully automated session +simulation** so you can preview every overlay state transition without +running the full Flet UI or connecting to iRacing. + +The simulation runs a complete F1-style qualifying event in a continuous loop: + + Pre-Q1 → Q1 (live) → Q1 (checkered + flying laps) → + Pre-Q2 → Q2 (live) → Q2 (checkered + flying laps) → + Pre-Q3 → Q3 (live) → Q3 (checkered + flying laps) → + final standings pause → (restart) + +Sub-session lengths (~30–60 s) and inter-session gaps (~12 s) are kept short +so the full demo loops in just a few minutes. All transitions are automatic. Live reload ----------- -Whenever ``overlay.html`` changes on disk, every -connected browser tab reloads automatically — no manual refresh needed. - -Manual RC trigger ------------------ -Visit http://localhost:/trigger-rc in any tab (or hit it with curl) -to send the RC banner immediately to all connected overlays. +Whenever ``overlay.html`` changes on disk, every connected browser tab +reloads automatically — no manual refresh needed. Usage ----- python tests/preview_overlay.py python tests/preview_overlay.py --port 9765 --width 1920 --height 1080 - python tests/preview_overlay.py --rc-delay 2 --rc-interval 20 python tests/preview_overlay.py --no-browser Then open http://localhost:9765/ in your browser or OBS browser source. @@ -42,7 +36,9 @@ import argparse import json +import math import queue +import random import threading import time import webbrowser @@ -59,255 +55,56 @@ _FONTS_DIR = _OVERLAY_HTML.parent.parent / "fonts" # --------------------------------------------------------------------------- -# Static scenario data — Q2 checkered flag, session still live +# Driver roster # --------------------------------------------------------------------------- -# -# Scenario: the Q2 session clock has hit zero and the checkered flag is out, -# but two drivers are still on flying laps — the results are not yet final. -# -# All four row statuses are represented so every CSS class is exercisable: -# -# safe — P1-P9 advancing to Q3, already finished their laps -# at_risk — P10 L. Stroll — last safe spot, still on a flying lap -# elimination_zone — P11 E. Ocon — first out, also still on a flying lap -# P12-P15 have pitted; their fates are sealed unless -# Stroll or Ocon swap with them -# knocked_out — P16-P20 eliminated in Q1, never ran in Q2 -# -# Toggle to ALT_F1_STATE (/trigger-alt) to see the same standings with the -# session clock still running and no checkered flag. +# 'speed' is each driver's offset (in seconds) above the fastest possible +# lap time. Lower speed → faster driver. + +_BASE_LAP = 83.0 # theoretical minimum lap time (seconds) + +DRIVERS: list[dict] = [ + {"car": "63", "name": "River Page", "speed": 0.000}, + {"car": "99", "name": "Giovanni Romano", "speed": 0.152}, + {"car": "16", "name": "Alex Marsh King", "speed": 0.281}, + {"car": "69", "name": "Tyler Agostino", "speed": 0.354}, + {"car": "10", "name": "Chris Bright", "speed": 0.482}, + {"car": "45", "name": "Tyler Carlton", "speed": 0.601}, + {"car": "119", "name": "Rognald Hotdognald", "speed": 0.714}, + {"car": "9", "name": "Brooks Clayton", "speed": 0.852}, + {"car": "017", "name": "Erik Ronnenberg", "speed": 0.923}, + {"car": "8", "name": "Jason L", "speed": 1.051}, + {"car": "243", "name": "Austin Tucker", "speed": 1.168}, + {"car": "42", "name": "Greg Beckman", "speed": 1.287}, + {"car": "13", "name": "Todd Madole", "speed": 1.381}, + {"car": "64", "name": "Mr. Hall", "speed": 1.474}, + {"car": "22", "name": "Boy Howdy", "speed": 1.562}, + {"car": "87", "name": "xXhalcy0n_SPNKr_94Xx", "speed": 2.103}, + {"car": "2", "name": "Ethan Conde", "speed": 2.248}, + {"car": "067", "name": "Alex Anderson", "speed": 2.401}, + {"car": "837", "name": "Sean Nelan", "speed": 2.553}, + {"car": "5", "name": "Mac Verstoopen", "speed": 2.801}, +] + +# --------------------------------------------------------------------------- +# Session configuration (short durations for a snappy demo loop) # --------------------------------------------------------------------------- -STATIC_F1_STATE: dict = { - "session_name": "Q2", - "time_remaining": "0:00", - "checkered_flag": True, - "sessions": ["Q1", "Q2"], - "drivers": [ - # ── P1-P9 safe — advancing to Q3, laps completed ───────────────────── - { - "position": 1, - "car_num": "1", - "driver_name": "J. Hamilton", - "best_time": "01:23.789", - "status": "safe", - "session_times": {"Q1": "01:25.123", "Q2": "01:23.789"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 2, - "car_num": "44", - "driver_name": "C. Verstappen", - "best_time": "01:23.945", - "status": "safe", - "session_times": {"Q1": "01:25.456", "Q2": "01:23.945"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 3, - "car_num": "4", - "driver_name": "L. Norris", - "best_time": "01:24.012", - "status": "safe", - "session_times": {"Q1": "01:25.234", "Q2": "01:24.012"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 4, - "car_num": "81", - "driver_name": "O. Piastri", - "best_time": "01:24.156", - "status": "safe", - "session_times": {"Q1": "01:25.345", "Q2": "01:24.156"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 5, - "car_num": "16", - "driver_name": "C. Leclerc", - "best_time": "01:24.234", - "status": "safe", - "session_times": {"Q1": "01:25.456", "Q2": "01:24.234"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 6, - "car_num": "63", - "driver_name": "G. Russell", - "best_time": "01:24.389", - "status": "safe", - "session_times": {"Q1": "01:25.567", "Q2": "01:24.389"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 7, - "car_num": "55", - "driver_name": "C. Sainz", - "best_time": "01:24.445", - "status": "safe", - "session_times": {"Q1": "01:25.678", "Q2": "01:24.445"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 8, - "car_num": "14", - "driver_name": "F. Alonso", - "best_time": "01:24.567", - "status": "safe", - "session_times": {"Q1": "01:25.789", "Q2": "01:24.567"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 9, - "car_num": "11", - "driver_name": "S. Perez", - "best_time": "01:24.623", - "status": "safe", - "session_times": {"Q1": "01:25.891", "Q2": "01:24.623"}, - "no_current_time": False, - "finished": True, - }, - # ── P10 at_risk — last Q3 spot, still on a flying lap ───────────────── - { - "position": 10, - "car_num": "18", - "driver_name": "L. Stroll", - "best_time": "01:24.678", - "status": "at_risk", - "session_times": {"Q1": "01:26.012", "Q2": "01:24.678"}, - "no_current_time": False, - "finished": False, # still on a flying lap — could improve or be beaten - }, - # ── P11 elimination_zone — first to drop, also still on a flying lap ── - { - "position": 11, - "car_num": "31", - "driver_name": "E. Ocon", - "best_time": "01:24.712", - "status": "elimination_zone", - "session_times": {"Q1": "01:26.123", "Q2": "01:24.712"}, - "no_current_time": False, - "finished": False, # chasing Stroll for P10 - }, - # ── P12-P15 elimination_zone — pitted; eliminated unless above pair swaps - { - "position": 12, - "car_num": "10", - "driver_name": "P. Gasly", - "best_time": "01:24.789", - "status": "elimination_zone", - "session_times": {"Q1": "01:26.234", "Q2": "01:24.789"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 13, - "car_num": "27", - "driver_name": "N. Hulkenberg", - "best_time": "01:24.834", - "status": "elimination_zone", - "session_times": {"Q1": "01:26.345", "Q2": "01:24.834"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 14, - "car_num": "77", - "driver_name": "V. Bottas", - "best_time": "01:24.956", - "status": "elimination_zone", - "session_times": {"Q1": "01:26.456", "Q2": "01:24.956"}, - "no_current_time": False, - "finished": True, - }, - { - "position": 15, - "car_num": "24", - "driver_name": "G. Zhou", - "best_time": "01:25.012", - "status": "elimination_zone", - "session_times": {"Q1": "01:26.567", "Q2": "01:25.012"}, - "no_current_time": False, - "finished": True, - }, - # ── P16-P20 knocked_out — eliminated in Q1, did not run Q2 ──────────── - { - "position": 16, - "car_num": "22", - "driver_name": "Y. Tsunoda", - "best_time": "01:26.789", - "status": "knocked_out", - "session_times": {"Q1": "01:26.789", "Q2": ""}, - "no_current_time": False, - "finished": False, - }, - { - "position": 17, - "car_num": "20", - "driver_name": "K. Magnussen", - "best_time": "01:26.891", - "status": "knocked_out", - "session_times": {"Q1": "01:26.891", "Q2": ""}, - "no_current_time": False, - "finished": False, - }, - { - "position": 18, - "car_num": "23", - "driver_name": "A. Albon", - "best_time": "01:27.012", - "status": "knocked_out", - "session_times": {"Q1": "01:27.012", "Q2": ""}, - "no_current_time": False, - "finished": False, - }, - { - "position": 19, - "car_num": "2", - "driver_name": "S. Sargeant", - "best_time": "01:27.123", - "status": "knocked_out", - "session_times": {"Q1": "01:27.123", "Q2": ""}, - "no_current_time": False, - "finished": False, - }, - { - "position": 20, - "car_num": "6", - "driver_name": "N. Latifi", - "best_time": "01:27.567", - "status": "knocked_out", - "session_times": {"Q1": "01:27.567", "Q2": ""}, - "no_current_time": False, - "finished": False, - }, - ], -} +SESSION_CONFIG: list[dict] = [ + {"name": "Q1", "duration": 60, "advancing": 15}, # 20 cars → 15 advance + {"name": "Q2", "duration": 50, "advancing": 10}, # 15 cars → 10 advance + {"name": "Q3", "duration": 40, "advancing": 0}, # 10 cars → winner +] -# ALT state: same Q2 standings mid-session — clock running, no checkered flag, -# no driver has taken the flag yet. Use /trigger-alt to toggle. -ALT_F1_STATE: dict = { - **STATIC_F1_STATE, - "time_remaining": "2:15", - "checkered_flag": False, - "drivers": [{**d, "finished": False} for d in STATIC_F1_STATE["drivers"]], -} +_ALL_SESSION_NAMES: list[str] = [s["name"] for s in SESSION_CONFIG] -STATIC_RC_MESSAGE: dict = { - "title": "Q2 — Checkered Flag", - "text": "Stroll P10 / Ocon P11 on final laps — Q3 lineup not yet confirmed", -} +# Timing constants (seconds) +_PRE_SESSION_DURATION = 12 # countdown before each session +_POST_CHECKERED_PAUSE = 4 # standings shown after all drivers finish +_LOOP_RESTART_PAUSE = 6 # final standings held before the sim loops +_SIM_TICK = 0.5 # broadcast interval # --------------------------------------------------------------------------- -# Shared state +# Shared SSE client queues # --------------------------------------------------------------------------- _rc_clients: list[queue.Queue] = [] @@ -315,19 +112,27 @@ _reload_clients: list[queue.Queue] = [] _clients_lock = threading.Lock() -# Mutable current F1 state (can be swapped to ALT via /trigger-alt) -_current_f1_state: dict = STATIC_F1_STATE +# Most-recent F1 state — sent immediately to newly connected clients. +_current_f1_state: dict = { + "session_name": "", + "time_remaining": "--:--", + "checkered_flag": False, + "sessions": [], + "drivers": [], +} def _broadcast_f1(state: dict) -> None: + global _current_f1_state + _current_f1_state = state payload = json.dumps(state) with _clients_lock: for q in list(_f1_clients): q.put(payload) -def _broadcast_rc(msg: dict) -> None: - payload = json.dumps(msg) +def _broadcast_rc(title: str, text: str) -> None: + payload = json.dumps({"title": title, "text": text}) with _clients_lock: for q in list(_rc_clients): q.put(payload) @@ -340,37 +145,425 @@ def _broadcast_reload() -> None: # --------------------------------------------------------------------------- -# Background: periodic broadcaster +# Formatting helpers +# --------------------------------------------------------------------------- + + +def _fmt_time(seconds: float | None) -> str: + """Format a lap time (seconds) as 'MM:SS.mmm', or '' for None.""" + if seconds is None: + return "" + minutes = int(seconds // 60) + secs = seconds % 60 + return f"{minutes:02d}:{secs:06.3f}" + + +def _fmt_countdown(seconds: float) -> str: + """Format a countdown value (seconds) as 'MM:SS'.""" + s = max(0.0, seconds) + return f"{int(s // 60):02d}:{int(s % 60):02d}" + + +# --------------------------------------------------------------------------- +# Simulation helpers +# --------------------------------------------------------------------------- + + +def _gen_lap_time(driver: dict, session_idx: int) -> float: + """Return a plausible lap time for *driver* in the given session (0-based).""" + # Drivers tend to improve slightly as qualifying progresses. + improvement = session_idx * random.uniform(0.02, 0.20) + noise = random.gauss(0.0, 0.13) + return _BASE_LAP + driver["speed"] - improvement + noise + + +def _compute_status(pos: int, advancing: int) -> str: + """Return 'safe', 'at_risk', or 'elimination_zone' based on position vs. cutoff.""" + if advancing <= 0: + return "safe" + if pos > advancing: + return "elimination_zone" + if pos == advancing: + return "at_risk" + return "safe" + + +# --------------------------------------------------------------------------- +# Overlay state builder # --------------------------------------------------------------------------- -def _broadcaster(rc_delay: float, rc_interval: float) -> None: +def _build_overlay_state( + *, + session_name: str, + time_remaining: str, + checkered_flag: bool, + laptimes: dict[str, dict[str, float]], # {session_name: {car: seconds}} + knocked_out: set[str], + session_finishers: set[str], + active_session: str | None, # None during pre-session countdowns + advancing: int, # cars advancing from the active session (0 = final) +) -> dict: """ - Push the F1 state immediately, then every 2 s so the tower is always - visible when you refresh. Fire the RC banner after *rc_delay* seconds - and repeat every *rc_interval* seconds. + Build the complete overlay state dict suitable for JSON-broadcasting. + + active_session + The Qn name of the session currently running, or None if we are in a + pre-session countdown phase. Controls which session column is treated + as "live" for status/no_current_time calculations. """ - time.sleep(0.5) # brief pause so the HTTP server is fully up - _broadcast_f1(_current_f1_state) - rc_countdown = rc_delay - tick = 2.0 + # ── Determine which session columns to display ───────────────────────── + # Show only sessions that already have at least one lap time recorded. + visible_sessions: list[str] = [sn for sn in _ALL_SESSION_NAMES if laptimes.get(sn)] + if not visible_sessions: + visible_sessions = [_ALL_SESSION_NAMES[0]] + + # Always include the active session column even before any laps are set, + # so that "No Time" placeholders appear from the first tick. + if active_session and active_session not in visible_sessions: + idx = _ALL_SESSION_NAMES.index(active_session) + visible_sessions = _ALL_SESSION_NAMES[: idx + 1] + + is_active_q = active_session is not None and not session_name.startswith("Pre-") + curr_times = laptimes.get(active_session, {}) if active_session else {} + + # ── Sort non-knocked-out drivers ─────────────────────────────────────── + # Primary: current-session lap time (fastest first; inf if no time yet) + # Secondary: best time in the most recent previous session (for tie-breaks) + active_cars: list[str] = [d["car"] for d in DRIVERS if d["car"] not in knocked_out] + + def _active_sort_key(car: str) -> tuple: + t_curr = curr_times.get(car, math.inf) + t_prev = math.inf + for sn in reversed(visible_sessions[:-1]): + t = laptimes.get(sn, {}).get(car) + if t is not None: + t_prev = t + break + return (t_curr, t_prev) + + active_cars.sort(key=_active_sort_key) + + # ── Build active driver rows ─────────────────────────────────────────── + drivers_out: list[dict] = [] + + for pos_0, car in enumerate(active_cars): + pos = pos_0 + 1 + info = next(x for x in DRIVERS if x["car"] == car) + + has_curr_time = car in curr_times + no_current_time = is_active_q and not has_curr_time + + # Status classification + if is_active_q and advancing > 0 and has_curr_time: + status = _compute_status(pos, advancing) + else: + status = "safe" + + # Best time shown in the timing tower + best_raw: float | None = curr_times.get(car) + if best_raw is None: + for sn in reversed(visible_sessions): + t = laptimes.get(sn, {}).get(car) + if t is not None: + best_raw = t + break + + session_times_fmt = { + sn: _fmt_time(laptimes.get(sn, {}).get(car)) for sn in visible_sessions + } + + drivers_out.append( + { + "position": pos, + "car_num": car, + "driver_name": info["name"], + "best_time": _fmt_time(best_raw), + "status": status, + "session_times": session_times_fmt, + "no_current_time": no_current_time, + "finished": car in session_finishers, + } + ) + + # ── Append knocked-out drivers at the bottom ─────────────────────────── + ko_cars: list[str] = [d["car"] for d in DRIVERS if d["car"] in knocked_out] + + def _ko_sort_key(car: str) -> float: + best = math.inf + for sn in _ALL_SESSION_NAMES: + t = laptimes.get(sn, {}).get(car) + if t is not None and t < best: + best = t + return best + + ko_cars.sort(key=_ko_sort_key) + + for ko_pos_0, car in enumerate(ko_cars): + info = next(x for x in DRIVERS if x["car"] == car) + best_raw = None + for sn in _ALL_SESSION_NAMES: + t = laptimes.get(sn, {}).get(car) + if t is not None and (best_raw is None or t < best_raw): + best_raw = t + + session_times_fmt = { + sn: _fmt_time(laptimes.get(sn, {}).get(car)) for sn in visible_sessions + } + + drivers_out.append( + { + "position": len(active_cars) + ko_pos_0 + 1, + "car_num": car, + "driver_name": info["name"], + "best_time": _fmt_time(best_raw), + "status": "knocked_out", + "session_times": session_times_fmt, + "no_current_time": False, + "finished": car in session_finishers, + } + ) + + return { + "session_name": session_name, + "time_remaining": time_remaining, + "checkered_flag": checkered_flag, + "sessions": visible_sessions, + "drivers": drivers_out, + } + + +# --------------------------------------------------------------------------- +# Main simulation loop +# --------------------------------------------------------------------------- + + +def _run_simulation() -> None: + """ + Drive the full qualifying session simulation indefinitely. + + Each iteration of the outer while-loop is one complete qualifying event: + Pre-Q1 countdown → Q1 live → Q1 checkered/flying laps → + Pre-Q2 countdown → Q2 live → Q2 checkered/flying laps → + Pre-Q3 countdown → Q3 live → Q3 checkered/flying laps → + final-standings pause → (loop) + """ + time.sleep(0.5) # brief pause so the HTTP server is fully up first + while True: - time.sleep(tick) - _broadcast_f1(_current_f1_state) - rc_countdown -= tick - if rc_countdown <= 0: - _broadcast_rc(STATIC_RC_MESSAGE) - rc_countdown = rc_interval + # ── Per-loop state reset ─────────────────────────────────────────── + laptimes: dict[str, dict[str, float]] = {sn: {} for sn in _ALL_SESSION_NAMES} + knocked_out: set[str] = set() + eligible: list[str] | None = None # None → all 20 cars + + for sess_idx, sess_cfg in enumerate(SESSION_CONFIG): + sess_name = sess_cfg["name"] + duration = float(sess_cfg["duration"]) + advancing = sess_cfg["advancing"] + pre_name = f"Pre-{sess_name}" + + # Cars eligible for this session + session_cars: list[str] = ( + list(eligible) if eligible is not None else [d["car"] for d in DRIVERS] + ) + active_cars_this_sess = [c for c in session_cars if c not in knocked_out] + + # ── Pre-session countdown ────────────────────────────────────── + countdown_end = time.monotonic() + _PRE_SESSION_DURATION + while time.monotonic() < countdown_end: + state = _build_overlay_state( + session_name=pre_name, + time_remaining=_fmt_countdown(countdown_end - time.monotonic()), + checkered_flag=False, + laptimes=laptimes, + knocked_out=knocked_out, + session_finishers=set(), + active_session=None, + advancing=advancing, + ) + _broadcast_f1(state) + time.sleep(_SIM_TICK) + + # ── Schedule when each driver sets their lap times ───────────── + # Every active car gets a first lap somewhere between 20 %–90 % + # through the session. ~65 % of cars also set a second (faster) + # attempt in the latter half. + first_reveals: dict[str, float] = {} + second_reveals: dict[str, float] = {} + + for car in active_cars_this_sess: + first_reveals[car] = random.uniform(0.20, 0.90) * duration + if random.random() < 0.65: + t2 = random.uniform(0.55, 0.98) * duration + if t2 > first_reveals[car] + 2.0: + second_reveals[car] = t2 + + # ── Active session ───────────────────────────────────────────── + _broadcast_rc( + "Race Control", + "Pit Exit OPEN!", + ) + + sess_start = time.monotonic() + session_finishers: set[str] = set() + + while True: + elapsed = time.monotonic() - sess_start + remaining = max(0.0, duration - elapsed) + out_of_time = elapsed >= duration + + # Reveal first-lap times as each driver's scheduled moment arrives. + for car in list(first_reveals): + if elapsed >= first_reveals[car]: + drv = next(d for d in DRIVERS if d["car"] == car) + laptimes[sess_name][car] = _gen_lap_time(drv, sess_idx) + del first_reveals[car] + + # Reveal second (potentially improved) lap times. + for car in list(second_reveals): + if elapsed >= second_reveals[car]: + drv = next(d for d in DRIVERS if d["car"] == car) + new_t = _gen_lap_time(drv, sess_idx) + existing = laptimes[sess_name].get(car, math.inf) + if new_t < existing: + laptimes[sess_name][car] = new_t + del second_reveals[car] + + state = _build_overlay_state( + session_name=sess_name, + time_remaining=_fmt_countdown(remaining), + checkered_flag=out_of_time, + laptimes=laptimes, + knocked_out=knocked_out, + session_finishers=session_finishers, + active_session=sess_name, + advancing=advancing, + ) + _broadcast_f1(state) + + if out_of_time: + _broadcast_rc( + "Race Control", + "Checkered Flag", + ) + break + + time.sleep(_SIM_TICK) + + # ── Checkered-flag phase: drivers complete their flying laps ─── + # Any car that never set a time gets one now (they were still out). + for car in active_cars_this_sess: + if car not in laptimes[sess_name]: + drv = next(d for d in DRIVERS if d["car"] == car) + laptimes[sess_name][car] = _gen_lap_time(drv, sess_idx) + + # Stagger when each car crosses the line over the next few seconds. + pending_finish: set[str] = set(active_cars_this_sess) + checkered_window = max(4.0, len(pending_finish) * 0.6) + finish_at: dict[str, float] = { + car: time.monotonic() + random.uniform(0.3, checkered_window) + for car in pending_finish + } + phase_deadline = time.monotonic() + checkered_window + 1.0 + + while pending_finish and time.monotonic() < phase_deadline: + now = time.monotonic() + for car in list(pending_finish): + if now >= finish_at[car]: + session_finishers.add(car) + pending_finish.discard(car) + + state = _build_overlay_state( + session_name=sess_name, + time_remaining="00:00", + checkered_flag=True, + laptimes=laptimes, + knocked_out=knocked_out, + session_finishers=session_finishers, + active_session=sess_name, + advancing=advancing, + ) + _broadcast_f1(state) + time.sleep(_SIM_TICK) + + # Ensure every active car is marked finished before moving on. + session_finishers.update(active_cars_this_sess) + + # ── Process results — determine who advances ─────────────────── + session_results: list[tuple[str, float]] = sorted( + laptimes[sess_name].items(), key=lambda x: x[1] + ) + + if advancing > 0: + advancing_cars = [car for car, _ in session_results[:advancing]] + eliminated = [car for car, _ in session_results[advancing:]] + + for car in eliminated: + knocked_out.add(car) + # Any eligible car with no recorded time is also eliminated. + for car in active_cars_this_sess: + if car not in knocked_out and car not in laptimes[sess_name]: + knocked_out.add(car) + + eligible = advancing_cars + else: + # Final session — rank everyone, nobody is eliminated. + eligible = [car for car, _ in session_results] + + # ── Post-checkered pause — show the all-done standings board ─── + pause_end = time.monotonic() + _POST_CHECKERED_PAUSE + while time.monotonic() < pause_end: + state = _build_overlay_state( + session_name=sess_name, + time_remaining="00:00", + checkered_flag=True, + laptimes=laptimes, + knocked_out=knocked_out, + session_finishers=session_finishers, + active_session=sess_name, + advancing=advancing, + ) + _broadcast_f1(state) + time.sleep(_SIM_TICK) + + # ── End of full event — hold final standings, then restart ───────── + final_sess = _ALL_SESSION_NAMES[-1] + loop_end = time.monotonic() + _LOOP_RESTART_PAUSE + while time.monotonic() < loop_end: + state = _build_overlay_state( + session_name=final_sess, + time_remaining="00:00", + checkered_flag=True, + laptimes=laptimes, + knocked_out=knocked_out, + session_finishers=session_finishers, + active_session=final_sess, + advancing=0, + ) + _broadcast_f1(state) + time.sleep(_SIM_TICK) + + # Brief blank gap before the next loop so the restart is perceptible. + _broadcast_f1( + { + "session_name": "Pre-Q1", + "time_remaining": "--:--", + "checkered_flag": False, + "sessions": [_ALL_SESSION_NAMES[0]], + "drivers": [], + } + ) + time.sleep(1.5) # --------------------------------------------------------------------------- -# Background: live-reload file watcher +# File watcher — live reload on overlay.html changes # --------------------------------------------------------------------------- def _file_watcher(watched_paths: list[Path]) -> None: - """Reload connected browsers whenever any watched file changes on disk.""" + """Reload all connected browser tabs whenever a watched file changes.""" mtimes: dict[Path, float] = { p: (p.stat().st_mtime if p.exists() else 0.0) for p in watched_paths } @@ -384,7 +577,7 @@ def _file_watcher(watched_paths: list[Path]) -> None: mtimes[p] = mtime print(f" [live-reload] {p.name} changed — refreshing browsers") _broadcast_reload() - break # one reload per tick is enough even if both files changed + break # one reload per tick is enough # --------------------------------------------------------------------------- @@ -393,7 +586,7 @@ def _file_watcher(watched_paths: list[Path]) -> None: _LIVE_RELOAD_SNIPPET = """