Records Lap Times
// ==UserScript==
// @name MoDuL's Lap Recorder
// @namespace modul.torn.racing
// @version 1.6.5
// @description Records Lap Times
// @author MoDuL
// @license MIT
// @match https://www.torn.com/page.php?sid=racing*
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const RT_LAP_REC_VERSION = "1.6.5";
var TAG = "[MoDuL's Lap Recorder v" + RT_LAP_REC_VERSION + "]";
try { console.log(TAG, "Loaded ✅"); } catch (e) {}
/* ============================
* STORAGE
* ============================ */
const STORE_LAPS_KEY = "RT_TORN_LAPS_V2";
const STORE_REC_KEY = "RT_TORN_REC_ENABLED"; // 1 = ON, 0 = OFF
const STORE_UI_KEY = "RT_TORN_LAP_UI_POS"; // {btn:{left,top}, win:{left,top,width,height}}
const STORE_META_KEY = "RT_TORN_RACE_META_V3"; // {track,car,detectedAtIso,detectedAtLocal,metaKey,countdown}
const STORE_THEME_KEY = "RT_TORN_LAP_THEME"; // theme key
const STORE_RECORDS_KEY = "RT_TORN_TRACK_RECORDS_V1";
const STORE_WIN_OPEN_KEY = "RT_TORN_LAP_WIN_OPEN";
const STORE_LAST_RACE_ID_KEY = "RT_TORN_LAST_RACE_ID";
const STORE_CLEAR_ON_RACE_CHANGE_KEY = "RT_TORN_CLEAR_ON_RACE_CHANGE";
const STORE_RECORDS_MAX = 2500;
const STORE_MAX = 5000;
/* ============================
* SELECTORS / FORMATS
* ============================ */
const SEL_LAP_UI = "#racingdetails .pd-val.pd-lap"; // "3/15"
const SEL_LAST_UI = "#racingdetails .pd-val.pd-laptime"; // "01:04"
const SEL_COMP_UI = "#racingdetails .pd-val.pd-completion"; // "17.33%" (can be hidden)
const SEL_POS_UI = "#racingdetails .pd-val.pd-position"; // "8/8"
const SEL_PILOT_NAME = "#racingdetails .pd-val.pd-pilotname, li.pd-val.pd-pilotname, .pd-val.pd-pilotname";
const SEL_INFOSPOT = "#infoSpot"; // status: "Race paused" / "Race started"
const SEL_SPEED_VALUE = "#speed-value"; // replay speed indicator like "x4"
const SEL_PLAY_PAUSE = "#play-pause-btn"; // replay play/pause button (class play/pause)
const SEL_LEADERBOARD = "#leaderBoard"; // leaderboard list (spectate)
const TIME_ANY_RE = /^(\d{1,3}:\d{2}:\d{2}(?:\.\d{1,3})?|\d{1,3}:\d{2}(?:\.\d{1,3})?|\d{1,3}\.\d{1,3})$/;
/* ============================
* THEMES
* ============================ */
const THEMES = [
{ key: "classic", name: "Classic" },
{ key: "dark", name: "Dark" },
{ key: "ice", name: "Ice" },
{ key: "neon", name: "Neon" },
{ key: "class_a", name: "Class A" },
{ key: "class_b", name: "Class B" },
{ key: "class_c", name: "Class C" },
{ key: "class_d", name: "Class D" },
{ key: "class_e", name: "Class E" },
];
/* ============================
* STATE
* ============================ */
let recordingEnabled = true;
let lastLapTimeSeen = "";
let lastLapTimeSeenToken = 0;
let lastLapTimeUsedToken = 0;
let lastLapCompleted = 0;
let lastRecordedKey = "";
let clearOnRaceChange = false;
let theme = "classic";
// Track records UI
let recordsOpen = true;
let recordsMode = "lap"; // "lap" or "race"
let recordsTrack = "";
let spectateName = "";
let spectateCar = "";
let spectateCarImg = "";
let playerName = "";
let spectateDriverId_ = "";
let playerId = "";
let lastReplaySelectAttemptAtMs = 0;
let lastReplaySelectAttemptName = "";
let completionPct = null; // number or null
let completionFinishTime = ""; // when completion switches from % to total time
let finishCapture = null; // {metaKey, atIso, speed, pause}
let observersReady = false;
let uiDirty = true;
let finishRecordedForMetaKey = ""; // guard
let startRecordedForMetaKey = ""; // guard (lap 0 baseline)
let pendingCompletedTarget = 0; // Lap counter can move before Last lap text; wait for a fresh time.
let pendingFinalLap = 0; // if finish detected but time not valid yet
// Replay pause accounting (for replays)
let replayIsPlaying = false;
let replayPauseStartMs = null;
let replayPauseAccumMs = 0;
/** @type {{text:string, at:string}[]} */
let replayEvents = [];
/** @type {{id:string,lap:number,position:string,time:string,at:string,ms:number|null,ui?:string,track?:string,car?:string,raceDetectedAtIso?:string}[]} */
let laps = [];
/** @type {{id:string,track:string,mode:"lap"|"race",car:string,carImg?:string,carClass?:string,driverName?:string,driverId?:string,timeText:string,ms:number,atIso:string}[]} */
let records = [];
/** @type {{track:string,car:string,detectedAtIso:string,detectedAtLocal:string,metaKey:string,countdown?:string} | null} */
let raceMeta = null;
/* ============================
* STORAGE HELPERS
* ============================ */
function loadWinOpen_() {
const v = GM_getValue(STORE_WIN_OPEN_KEY, 0);
return v === 1 || v === "1" || v === true || v === "true";
}
function saveWinOpen_(isOpen) {
GM_setValue(STORE_WIN_OPEN_KEY, isOpen ? 1 : 0);
}
function loadLastRaceId_() {
return String(GM_getValue(STORE_LAST_RACE_ID_KEY, "") || "").trim();
}
function loadClearOnRaceChange_() {
const v = GM_getValue(STORE_CLEAR_ON_RACE_CHANGE_KEY, 1);
return v === 1 || v === "1" || v === true || v === "true";
}
function saveClearOnRaceChange_(enabled) {
GM_setValue(STORE_CLEAR_ON_RACE_CHANGE_KEY, enabled ? 1 : 0);
}
function saveLastRaceId_(raceId) {
GM_setValue(STORE_LAST_RACE_ID_KEY, String(raceId || "").trim());
}
function loadJson_(key, fallback) {
try {
const raw = GM_getValue(key, "");
if (!raw) return fallback;
const obj = JSON.parse(raw);
return obj ?? fallback;
} catch {
return fallback;
}
}
function saveJson_(key, obj) { GM_setValue(key, JSON.stringify(obj)); }
function loadLaps_() {
const data = loadJson_(STORE_LAPS_KEY, []);
return Array.isArray(data) ? data : [];
}
function saveLaps_() {
if (laps.length > STORE_MAX) laps = laps.slice(laps.length - STORE_MAX);
saveJson_(STORE_LAPS_KEY, laps);
}
function loadRecords_() {
const data = loadJson_(STORE_RECORDS_KEY, []);
return Array.isArray(data) ? data : [];
}
function saveRecords_() {
if (records.length > STORE_RECORDS_MAX) records = records.slice(records.length - STORE_RECORDS_MAX);
saveJson_(STORE_RECORDS_KEY, records);
}
function loadRecEnabled_() {
const v = GM_getValue(STORE_REC_KEY, 1);
if (v === 1 || v === "1" || v === true || v === "true") return true;
if (v === 0 || v === "0" || v === false || v === "false") return false;
return true;
}
function saveRecEnabled_() { GM_setValue(STORE_REC_KEY, recordingEnabled ? 1 : 0); }
function loadUiState_() { return loadJson_(STORE_UI_KEY, { btn: null, win: null }); }
function saveUiState_(state) { saveJson_(STORE_UI_KEY, state); }
function loadRaceMeta_() {
const m = loadJson_(STORE_META_KEY, null);
if (!m || typeof m !== "object") return null;
if (!m.track || !m.car || !m.detectedAtIso || !m.metaKey) return null;
return m;
}
function saveRaceMeta_(m) { saveJson_(STORE_META_KEY, m); }
function loadTheme_() {
const t = GM_getValue(STORE_THEME_KEY, "classic");
return THEMES.some(x => x.key === t) ? t : "classic";
}
function readText_(sel) {
try {
const el = document.querySelector(sel);
return (el?.textContent || "").trim();
} catch {
return "";
}
}
function saveTheme_() { GM_setValue(STORE_THEME_KEY, theme); }
/* ============================
* TIME / FORMAT HELPERS
* ============================ */
const nowIso_ = () => new Date().toISOString();
function makeRaceMetaKey_(track, car, raceId, detectedAtIso) {
const rid = String(raceId || "").trim();
const seen = String(detectedAtIso || "").trim();
const racePart = rid ? `race:${rid}` : `seen:${seen || nowIso_()}`;
return `${racePart}__${track || "UnknownTrack"}__${car || "Replay"}`;
}
function lapBelongsToMeta_(r, metaKey = raceMeta?.metaKey || "") {
if (!r) return false;
if (!metaKey) return true;
const id = String(r.id || "");
if (id.startsWith(`${metaKey}|`)) return true;
const scopePrefix = String(metaKey).match(/^(?:race|seen):.+?__/);
if (scopePrefix && id.startsWith(scopePrefix[0])) return true;
// Migration path for rows saved before race id / detected-at were part of the key.
return !!(
raceMeta &&
String(r.raceDetectedAtIso || "") &&
String(r.raceDetectedAtIso || "") === String(raceMeta.detectedAtIso || "")
);
}
function currentRaceLaps_(includeLap0 = false) {
const metaKey = raceMeta?.metaKey || "";
const rows = metaKey ? laps.filter(r => lapBelongsToMeta_(r, metaKey)) : laps.slice();
return includeLap0 ? rows : rows.filter(r => (r.lap || 0) > 0);
}
function refreshLastLapCompleted_() {
lastLapCompleted = currentRaceLaps_(true).reduce((m, r) => Math.max(m, Number(r.lap) || 0), 0);
}
function resetSeenLapTime_() {
lastLapTimeSeen = "";
lastLapTimeSeenToken = 0;
lastLapTimeUsedToken = 0;
}
function noteLastLapTime_(timeText) {
const s = String(timeText || "").trim();
if (!TIME_ANY_RE.test(s)) return false;
const pendingNeedsTime = (pendingCompletedTarget > lastLapCompleted) || !!pendingFinalLap;
if (s !== lastLapTimeSeen || lastLapTimeSeenToken === 0 || (pendingNeedsTime && lastLapTimeSeenToken <= lastLapTimeUsedToken)) {
lastLapTimeSeenToken++;
}
lastLapTimeSeen = s;
return true;
}
function hasFreshLastLapTime_() {
return TIME_ANY_RE.test(lastLapTimeSeen) && lastLapTimeSeenToken > lastLapTimeUsedToken;
}
function markLastLapTimeUsed_() {
lastLapTimeUsedToken = lastLapTimeSeenToken;
}
function readPlayerFromSidebar_() {
try {
const a = document.querySelector('#sidebarroot a[href^="/profiles.php?XID="], #sidebar a[href^="/profiles.php?XID="], a[href^="/profiles.php?XID="]');
if (!a) return null;
const name = (a.textContent || '').trim();
const href = a.getAttribute('href') || '';
const idm = href.match(/XID=(\d+)/i);
const xid = idm ? idm[1] : '';
if (!name) return null;
return { name, xid };
} catch (_) { return null; }
}
function readPilotName_() {
try {
const el = document.querySelector(SEL_PILOT_NAME);
const name = (el?.textContent || "").trim();
return name || "";
} catch (_) {
return "";
}
}
function normalizeDriverName_(name) {
return String(name || "").trim().replace(/\s+/g, " ").toLowerCase();
}
function currentDriverName_() {
return String(spectateName || playerName || raceMeta?.driver || "").trim();
}
function currentDriverId_() {
return String(spectateDriverId_ || playerId || raceMeta?.driverId || "").trim();
}
function ensurePlayer_() {
const sidebar = readPlayerFromSidebar_();
if (sidebar?.xid) playerId = String(sidebar.xid).trim();
const detectedName = readPilotName_() || String(sidebar?.name || "").trim();
if (!detectedName) return !!playerName;
if (detectedName !== playerName) {
playerName = detectedName;
uiDirty = true;
scheduleRender_();
}
return true;
}
function resetForRaceChange_(newRaceId) {
laps = [];
resetSeenLapTime_();
lastLapCompleted = 0;
lastRecordedKey = "";
completionPct = null;
completionFinishTime = "";
finishRecordedForMetaKey = "";
startRecordedForMetaKey = "";
pendingCompletedTarget = 0;
pendingFinalLap = 0;
replayIsPlaying = false;
replayPauseStartMs = null;
replayPauseAccumMs = 0;
replayEvents = [];
clearRaceMeta_();
saveLaps_();
saveLastRaceId_(newRaceId || "");
uiDirty = true;
scheduleRender_();
}
function bestRaceAtIso_() {
// Best-effort: for replays prefer "Time started" from the Race info panel.
try {
const wrap = document.querySelector('.race-info-wrap');
if (wrap) {
const rows = Array.from(wrap.querySelectorAll('.info-row'));
for (const r of rows) {
const t = (r.querySelector('.info-title')?.textContent || '').trim();
const v = (r.querySelector('.info-value')?.textContent || '').trim();
if (/^time started/i.test(t) && v) {
const iso = toIsoFromAt_(v);
if (iso) return iso;
}
}
for (const r of rows) {
const t = (r.querySelector('.info-title')?.textContent || '').trim();
const v = (r.querySelector('.info-value')?.textContent || '').trim();
if ((/^time created/i.test(t) || /^time ended/i.test(t)) && v) {
const iso = toIsoFromAt_(v);
if (iso) return iso;
}
}
}
} catch { }
// Fall back to stored meta if available.
if (raceMeta) {
const cand =
raceMeta.startAtIso ||
raceMeta.raceAtIso ||
raceMeta.detectedAtIso ||
raceMeta.createdAtIso ||
"";
if (cand) return String(cand);
}
return nowIso_();
}
function pad2_(n) { return String(n).padStart(2, "0"); }
function formatLocalStamp_(d) {
const yy = String(d.getFullYear()).slice(-2);
const mm = pad2_(d.getMonth() + 1);
const dd = pad2_(d.getDate());
const HH = pad2_(d.getHours());
const MM = pad2_(d.getMinutes());
return `${yy}-${mm}-${dd}.${HH}${MM}`;
}
function formatLocalDateTime_(d) {
const dd = pad2_(d.getDate());
const mm = pad2_(d.getMonth() + 1);
const yyyy = d.getFullYear();
const HH = pad2_(d.getHours());
const MM = pad2_(d.getMinutes());
const SS = pad2_(d.getSeconds());
return `${dd}/${mm}/${yyyy}, ${HH}:${MM}:${SS}`;
}
function parseCountdownToSeconds_(s) {
const txt = String(s || "").toLowerCase();
const h = (txt.match(/(\d+)\s*hour/) || [])[1];
const m = (txt.match(/(\d+)\s*minute/) || [])[1];
const sec = (txt.match(/(\d+)\s*second/) || [])[1];
const hours = h ? parseInt(h, 10) : 0;
const mins = m ? parseInt(m, 10) : 0;
const secs = sec ? parseInt(sec, 10) : 0;
const total = hours * 3600 + mins * 60 + secs;
return Number.isFinite(total) ? total : 0;
}
function formatShortCountdown_(totalSeconds) {
totalSeconds = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
// ============================
// TAB TITLE TIMER (countdown / elapsed)
// ============================
const RT_TAB_SUFFIX = " | Torn";
let rtOrigTitle_ = null;
let rtStartEpochMs_ = null;
const RT_ORIG_TITLE = document.title || "Torn";
function isReplay_() {
// Strict: only treat as replay when Torn replay controls exist.
// (Prevents pause/speed logic from running in live races.)
return !!(document.querySelector(SEL_SPEED_VALUE) || document.querySelector(SEL_PLAY_PAUSE) || isReplayPage_());
}
function getReplaySpeedFactor_() {
// Reads replay speed from the in-game control: <span id="speed-value">x4</span>
// Returns 1 when not available.
try {
const el = document.querySelector(SEL_SPEED_VALUE);
const raw = (el?.textContent || "").trim();
const m = raw.match(/x\s*(\d+(?:\.\d+)?)/i);
const v = m ? parseFloat(m[1]) : NaN;
return (Number.isFinite(v) && v > 0) ? v : 1;
} catch {
return 1;
}
}
function getReplayPauseAccumMs_() {
// Total paused milliseconds so far (replay only). While currently paused, includes the ongoing pause duration.
if (!isReplay_()) return 0;
const now = Date.now();
if (replayPauseStartMs != null) return replayPauseAccumMs + Math.max(0, now - replayPauseStartMs);
return replayPauseAccumMs;
}
function getReplayPauseCaptureMs_() {
// For lap capture snapshots we want only completed pause time.
// If the replay has just auto-paused at the finish, including the ongoing pause
// can collapse the last lap to 0ms.
if (!isReplay_()) return 0;
return replayPauseAccumMs;
}
function fmtLocalMs_(d) {
const pad = (n, w = 2) => String(n).padStart(w, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
function logReplayEvent_(text) {
const at = fmtLocalMs_(new Date());
replayEvents.push({ text, at });
if (replayEvents.length > 50) replayEvents.shift();
uiDirty = true;
scheduleRender_();
}
function isReplayPlaying_() {
const btn = document.querySelector(SEL_PLAY_PAUSE);
if (!btn) return false;
// Torn uses class "play" when paused/stopped, and "pause" when actually playing.
if (btn.classList && btn.classList.contains("pause")) return true;
const cls = String(btn.className || "").toLowerCase();
return cls.includes("pause");
}
function hookReplayPlayObserver_() {
try {
const btn = document.querySelector(SEL_PLAY_PAUSE);
if (!btn) return false;
// Ensure we only hook once per page load
if (btn.__tlrHooked) return true;
btn.__tlrHooked = true;
const onChange = () => {
const nowPlaying = isReplay_() && isReplayPlaying_();
// Track pause/resume and exclude paused time from chrono.
if (isReplay_()) {
if (nowPlaying && !replayIsPlaying) {
// resumed
if (replayPauseStartMs != null) {
const delta = Math.max(0, Date.now() - replayPauseStartMs);
replayPauseAccumMs += delta;
replayPauseStartMs = null;
logReplayEvent_(`Replay resumed`);
} else {
// first time play, just note
logReplayEvent_(`Replay started`);
}
} else if (!nowPlaying && replayIsPlaying) {
// paused
replayPauseStartMs = Date.now();
logReplayEvent_(`Replay paused`);
}
replayIsPlaying = nowPlaying;
} else {
// not in replay context
replayIsPlaying = false;
replayPauseStartMs = null;
replayPauseAccumMs = 0;
}
// When user presses Play on a replay, Torn swaps class from "play" -> "pause".
if (isReplay_() && nowPlaying) {
// If init disabled recording earlier (older builds), force it ON now.
if (!recordingEnabled) {
recordingEnabled = true;
saveRecEnabled_();
}
// Establish Lap 0 immediately so Lap 1 chrono can be computed.
maybeRecordLap0Baseline_();
maybeMarkRaceStarted_();
}
};
new MutationObserver(onChange).observe(btn, { attributes: true, attributeFilter: ["class"] });
// Run once in case we're already playing when the script attaches.
onChange();
return true;
} catch (e) {
console.warn("[Lap Recorder] hookReplayPlayObserver_ failed", e);
return false;
}
}
function hookReplaySpeedObserver_() {
try {
const el = document.querySelector(SEL_SPEED_VALUE);
if (!el) return false;
if (el.__tlrSpeedHooked) return true;
el.__tlrSpeedHooked = true;
const onChange = () => {
// The render reads the current speed label.
uiDirty = true;
scheduleRender_();
};
new MutationObserver(onChange).observe(el, { characterData: true, childList: true, subtree: true });
onChange();
return true;
} catch (e) {
console.warn("[MoDuL's Lap Recorder] hookReplaySpeedObserver_ failed", e);
return false;
}
}
function getLeaderboardEntries_() {
try {
const root = document.querySelector(SEL_LEADERBOARD);
if (!root) return [];
const rows = Array.from(root.querySelectorAll("li"))
.filter(row => row.querySelector("li.name span, li.name, .name span, .name") && row.querySelector("li.car img, .car img"));
return rows.map(row => {
const name = (row.querySelector("li.name span, li.name, .name span, .name")?.textContent || "").trim();
const imgEl = row.querySelector("li.car img, .car img");
const car = (imgEl?.getAttribute("title") || "").trim();
const img = (imgEl?.getAttribute("src") || "").trim();
const href = row.querySelector('a[href*="XID="]')?.getAttribute("href") || "";
const idMatch = href.match(/XID=(\d+)/i);
const driverId = idMatch ? idMatch[1] : "";
const selected = /\bselected\b/i.test(String(row.className || ""));
return { row, name, car, img, driverId, selected };
}).filter(entry => entry.name);
} catch {
return [];
}
}
function readSelectedDriver_() {
const entry = getLeaderboardEntries_().find(item => item.selected);
if (!entry) return { name: "", car: "", img: "", driverId: "" };
return entry;
}
function clickReplayDriverEntry_(entry) {
const row = entry?.row;
if (!row) return false;
const targets = [
row.querySelector("li.name a, .name a"),
row.querySelector("li.name, .name"),
row.querySelector("a"),
row
].filter(Boolean);
for (const target of targets) {
try {
if (typeof target.click === "function") target.click();
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
return true;
} catch { }
}
return false;
}
function trySelectReplayDriver_() {
if (!isReplay_()) return false;
const wanted = normalizeDriverName_(playerName);
if (!wanted) return false;
const selected = readSelectedDriver_();
if (selected.name) return false;
const now = Date.now();
if (lastReplaySelectAttemptName === wanted && (now - lastReplaySelectAttemptAtMs) < 1000) return false;
const entry = getLeaderboardEntries_().find(item => normalizeDriverName_(item.name) === wanted);
if (!entry) return false;
lastReplaySelectAttemptName = wanted;
lastReplaySelectAttemptAtMs = now;
return clickReplayDriverEntry_(entry);
}
let playerProbeTimer_ = null;
function startPlayerProbe_() {
if (playerProbeTimer_) return;
let tries = 0;
playerProbeTimer_ = setInterval(() => {
tries++;
ensurePlayer_();
if ((playerName && playerName !== '—') || tries > 30) {
clearInterval(playerProbeTimer_);
playerProbeTimer_ = null;
}
}, 500);
}
function refreshSpectate_() {
ensurePlayer_();
trySelectReplayDriver_();
const { name, car, img, driverId } = readSelectedDriver_();
const changed =
(name && name !== spectateName) ||
(car && car !== spectateCar) ||
(img && img !== spectateCarImg) ||
(driverId && driverId !== spectateDriverId_);
if (name) spectateName = name;
if (car) spectateCar = car;
if (img) spectateCarImg = img;
if (driverId) spectateDriverId_ = driverId;
// If meta car is unknown (e.g., "Replay"), use the selected driver's car.
if (raceMeta && spectateCar && (!raceMeta.car || /^replay(?:\s+driver)?$/i.test(String(raceMeta.car || "")))) {
raceMeta.car = spectateCar;
if (spectateCarImg && !raceMeta.carImg) raceMeta.carImg = spectateCarImg;
raceMeta.driver = currentDriverName_();
raceMeta.driverId = currentDriverId_();
saveRaceMeta_(raceMeta);
}
// Keep car image fresh if we have it.
if (raceMeta && spectateCarImg && raceMeta.car === spectateCar && raceMeta.carImg !== spectateCarImg) {
raceMeta.carImg = spectateCarImg;
raceMeta.driver = currentDriverName_();
raceMeta.driverId = currentDriverId_();
saveRaceMeta_(raceMeta);
}
if (raceMeta) {
const nextDriver = currentDriverName_();
const nextDriverId = currentDriverId_();
if ((nextDriver && raceMeta.driver !== nextDriver) || (nextDriverId && raceMeta.driverId !== nextDriverId)) {
if (nextDriver) raceMeta.driver = nextDriver;
if (nextDriverId) raceMeta.driverId = nextDriverId;
saveRaceMeta_(raceMeta);
}
}
if (changed) { uiDirty = true; scheduleRender_(); }
}
function raceIsFinished_() {
const info = document.querySelector(SEL_INFOSPOT);
const t = (info?.textContent || "").toLowerCase();
if (t.includes("finished")) return true;
// Some layouts show it in the video bar / title.
const bodyTxt = (document.body?.textContent || "").toLowerCase();
return bodyTxt.includes("race finished");
}
// ============================
// TRACK RECORDS (per track, unique cars)
// ============================
function makeRecId_(mode, track, car, atIso) {
return `${mode}|${track}|${car}|${atIso}`;
}
function msToTimeText_(ms, mode) {
// lap/chrono/last: m:ss.mmm
// race: h:mm:ss.mmm (or m:ss.mmm when < 1h)
ms = Math.max(0, Math.floor(ms || 0));
if (!ms) return '';
if (mode === "race") {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const mm = ms % 1000;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(mm).padStart(3, "0")}`;
return `${m}:${String(s).padStart(2, "0")}.${String(mm).padStart(3, "0")}`;
}
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
const mm = ms % 1000;
return `${m}:${String(s).padStart(2, "0")}.${String(mm).padStart(3, "0")}`;
}
function finishRaceRecording_(metaKey) {
const key = metaKey || raceMeta?.metaKey || "";
if (!key || finishRecordedForMetaKey === key) return;
finishRecordedForMetaKey = key;
maybeSaveTrackRecordsForRace_();
uiDirty = true;
scheduleRender_();
}
function clearFinishCapture_() {
finishCapture = null;
}
function maybeCaptureFinishMoment_(parsed) {
try {
ensureRaceMeta_();
const metaKey = raceMeta?.metaKey || "";
if (!metaKey) return;
const comp = parsed || ((completionPct == null && !completionFinishTime)
? parseCompletionOrTime_()
: { pct: completionPct, time: completionFinishTime });
const finished = !!(comp && comp.time) || (typeof comp?.pct === "number" && isFinite(comp.pct) && comp.pct >= 99.9);
if (!finished) return;
if (finishCapture && finishCapture.metaKey === metaKey) return;
finishCapture = {
metaKey,
atIso: nowIso_(),
speed: isReplay_() ? getReplaySpeedFactor_() : 1,
pause: isReplay_() ? getReplayPauseCaptureMs_() : 0
};
} catch { }
}
function deriveFinalLapCapture_(lapNum, timeText) {
try {
const lapMs = timeTextToMs_(timeText);
if (!(typeof lapMs === "number" && isFinite(lapMs) && lapMs > 0)) return null;
const prevLapNum = Math.max(0, (Number(lapNum) || 0) - 1);
const prevRows = currentRaceLaps_(true).filter(r => (r.lap || 0) === prevLapNum);
if (!prevRows.length) return null;
const prev = prevRows
.slice()
.sort((a, b) => tsMs_(b) - tsMs_(a))[0];
const prevTs = tsMs_(prev);
if (!isFinite(prevTs)) return null;
if (isReplay_()) {
const prevPause = (typeof prev.pause === "number" && isFinite(prev.pause)) ? prev.pause : 0;
const speedNow = getReplaySpeedFactor_();
const pauseNow = getReplayPauseCaptureMs_();
const speed = (Number.isFinite(speedNow) && speedNow > 0)
? speedNow
: ((typeof prev.speed === "number" && isFinite(prev.speed) && prev.speed > 0)
? prev.speed
: ((typeof raceMeta?.replaySpeed === "number" && isFinite(raceMeta.replaySpeed) && raceMeta.replaySpeed > 0) ? raceMeta.replaySpeed : 1));
if (finishCapture && finishCapture.metaKey === (raceMeta?.metaKey || "")) {
const finishTs = Date.parse(finishCapture.atIso || "");
const finishPause = (typeof finishCapture.pause === "number" && isFinite(finishCapture.pause))
? finishCapture.pause
: pauseNow;
const finishSpeed = (typeof finishCapture.speed === "number" && isFinite(finishCapture.speed) && finishCapture.speed > 0)
? finishCapture.speed
: speed;
const finishCalcMs = isFinite(finishTs)
? Math.max(0, (finishTs - prevTs - (finishPause - prevPause)) * finishSpeed)
: NaN;
const finishLooksSane = isFinite(finishCalcMs)
&& finishCalcMs > 0
&& Math.abs(finishCalcMs - lapMs) <= 1500;
if (finishLooksSane) {
return {
atIso: finishCapture.atIso,
speed: finishSpeed,
pause: finishPause
};
}
}
return {
atIso: new Date(prevTs + Math.max(0, pauseNow - prevPause) + (lapMs / speed)).toISOString(),
speed,
pause: pauseNow
};
}
return {
atIso: new Date(prevTs + lapMs).toISOString()
};
} catch {
return null;
}
}
function fallbackLapChronoMs_(row) {
const ms = timeTextToMs_(row && row.time);
return (typeof ms === "number" && isFinite(ms) && ms > 0) ? ms : null;
}
function computeLapChronoMs_(cur, prev) {
if (!cur || (cur.lap || 0) <= 0) return null;
const tCur = Date.parse(lapAtIso_(cur));
const tPrev = prev ? Date.parse(lapAtIso_(prev)) : Date.parse((raceMeta && raceMeta.startAtIso) ? raceMeta.startAtIso : "");
if (isFinite(tCur) && isFinite(tPrev)) {
const spd = (typeof cur.speed === "number" && isFinite(cur.speed) && cur.speed > 0)
? cur.speed
: (typeof raceMeta?.replaySpeed === "number" && isFinite(raceMeta.replaySpeed) && raceMeta.replaySpeed > 0 ? raceMeta.replaySpeed : 1);
const pCur = (cur.pause || 0);
const pPrev = prev ? (prev.pause || 0) : 0;
const calcMs = Math.max(0, (tCur - tPrev - (pCur - pPrev)) * spd);
if (calcMs > 0) return calcMs;
}
return fallbackLapChronoMs_(cur);
}
function maybeRecordFinalLapOnFinish_() {
if (!recordingEnabled) return;
ensureRaceMeta_();
const metaKey = raceMeta?.metaKey || "";
if (!metaKey) return;
if (finishRecordedForMetaKey === metaKey) return;
const { cur, tot, raw } = parseLapUi_();
if (!cur || !tot) return;
if (cur !== tot) return;
const parsed = (completionPct == null && !completionFinishTime)
? parseCompletionOrTime_()
: { pct: completionPct, time: completionFinishTime };
maybeCaptureFinishMoment_(parsed);
const pct = parsed.pct;
const finishTime = parsed.time;
if (!finishTime && (pct == null || pct < 99.9)) return;
if (finishTime) {
if (raceMeta && (raceMeta.totalTime || "") !== finishTime) {
raceMeta.totalTime = finishTime;
saveRaceMeta_(raceMeta);
}
}
if (lastLapCompleted === tot) {
finishRaceRecording_(metaKey);
return;
}
if (lastLapCompleted !== tot - 1) return;
const lastNow = (document.querySelector(SEL_LAST_UI)?.textContent || "").trim();
// Do not reuse the previous lap's time for the finish.
if (!noteLastLapTime_(lastNow) || !hasFreshLastLapTime_()) {
pendingFinalLap = tot;
return;
}
const posNow = getPositionText_();
const finalCapture = deriveFinalLapCapture_(tot, lastLapTimeSeen) || {};
recordLap_(tot, lastLapTimeSeen, raw, posNow, finalCapture);
markLastLapTimeUsed_();
lastLapCompleted = tot;
finishRaceRecording_(metaKey);
}
function computeChronoByLap_(metaKey) {
try {
const asc = (laps || [])
.filter(r => r && (r.lap || 0) > 0 && lapBelongsToMeta_(r, metaKey))
.slice()
.sort((a, b) => (a.lap || 0) - (b.lap || 0));
const out = new Map();
if (!asc.length) return out;
for (let i = 0; i < asc.length; i++) {
const cur = asc[i];
const prev = i > 0 ? asc[i - 1] : null;
out.set(cur.lap, computeLapChronoMs_(cur, prev));
}
return out;
} catch {
return new Map();
}
}
function maybeSaveTrackRecordsForRace_() {
try {
ensureRaceMeta_();
if (!raceMeta?.metaKey) return;
const track = raceMeta.track || "UnknownTrack";
const car = raceMeta.car || "Replay";
const carImg = raceMeta.carImg || findCarImageUrl_(car) || spectateCarImg || "";
const driverName = currentDriverName_();
const driverId = currentDriverId_();
const carClass = String(raceMeta.carClass || "").trim();
const atIso = raceMeta.detectedAtIso || nowIso_();
// 1) Best lap chrono (calculated)
const chrono = computeChronoByLap_(raceMeta.metaKey);
const msBestLap = Math.min(...Array.from(chrono.values()).filter(v => typeof v === "number" && v > 0));
if (isFinite(msBestLap) && msBestLap > 0) {
const id = makeRecId_("lap", track, car, atIso);
records = records.filter(r => r.id !== id);
records.push({
id,
track,
mode: "lap",
car,
carImg,
carClass,
driverName,
driverId,
raceId: raceMeta.raceId || "",
timeText: msToTimeText_(msBestLap, "lap"),
ms: msBestLap,
atIso
});
}
// 2) Race total time
// Torn completion total can be rounded to centiseconds, while our Chrono sum can be more precise.
// Use the FASTEST positive value so records update correctly.
const tornTxt = String(raceMeta.totalTime || "").trim();
const msTorn = timeTextToMs_(tornTxt);
const chronoAll = computeChronoByLap_(raceMeta.metaKey);
const vals = Array.from(chronoAll.values()).filter(v => typeof v === "number" && isFinite(v) && v > 0);
const msChronoSum = vals.length ? vals.reduce((a, b) => a + b, 0) : 0;
let msRace = 0;
if (msTorn > 0 && msChronoSum > 0) msRace = Math.min(msTorn, msChronoSum);
else msRace = msTorn > 0 ? msTorn : msChronoSum;
if (msRace && msRace > 0) {
const id = makeRecId_("race", track, car, atIso);
records = records.filter(r => r.id !== id);
records.push({
id,
track,
mode: "race",
car,
carImg,
carClass,
driverName,
driverId,
raceId: raceMeta.raceId || "",
timeText: msToTimeText_(msRace, "race"),
ms: msRace,
atIso
});
}
saveRecords_();
} catch (e) {
console.warn("[MoDuL's Lap Recorder] maybeSaveTrackRecordsForRace_ failed", e);
}
}
function formatHMS_(ms) {
ms = Math.max(0, Math.floor(ms));
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function setTabTitle_(ms) {
if (!rtOrigTitle_) rtOrigTitle_ = document.title || "Torn";
document.title = `R${formatHMS_(ms)}${RT_TAB_SUFFIX}`;
}
function updateTabTitle_() {
// Freeze at finish time if known
const finishTxt = (completionFinishTime || raceMeta?.totalTime || "");
if (finishTxt) {
const ms = timeTextToMs_(finishTxt);
if (ms) { setTabTitle_(ms); return; }
}
// Countdown (pre-race)
if (!raceHasStarted_() && (raceMeta?.countdown || "")) {
const secs = parseCountdownToSeconds_(raceMeta.countdown);
setTabTitle_(secs * 1000);
// Use planned start as baseline so elapsed is stable when race begins
rtStartEpochMs_ = Date.now() + secs * 1000;
return;
}
// Elapsed (race running)
if (raceHasStarted_()) {
if (!rtStartEpochMs_) rtStartEpochMs_ = Date.now();
setTabTitle_(Date.now() - rtStartEpochMs_);
return;
}
// No race context; restore original title
if (rtOrigTitle_) document.title = rtOrigTitle_;
}
function timeTextToMs_(t) {
const s = String(t || "").trim().toLowerCase();
if (!s) return null;
// h:mm:ss(.ms)
let m1 = s.match(/^(\d{1,3}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
if (m1) {
return ((parseInt(m1[1], 10) * 3600) + (parseInt(m1[2], 10) * 60) + parseInt(m1[3], 10)) * 1000 +
(m1[4] ? parseInt(m1[4].padEnd(3, "0"), 10) : 0);
}
// mm:ss(.ms)
m1 = s.match(/^(\d{1,3}):(\d{2})(?:\.(\d{1,3}))?$/);
if (m1) {
return (parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10)) * 1000 +
(m1[3] ? parseInt(m1[3].padEnd(3, "0"), 10) : 0);
}
// ss.ms (e.g. 50.825)
m1 = s.match(/^(\d{1,3})\.(\d{1,3})$/);
if (m1) return parseInt(m1[1], 10) * 1000 + parseInt(m1[2].padEnd(3, "0"), 10);
// "14 minutes, 27 seconds" / "1 hour, 2 minutes, 3 seconds" / "14m 27s"
const h = (s.match(/(\d+)\s*(?:h|hour|hours)\b/) || [])[1];
const min = (s.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/) || [])[1];
const sec = (s.match(/(\d+)\s*(?:s|sec|secs|second|seconds)\b/) || [])[1];
if (h || min || sec) {
const hh = h ? parseInt(h, 10) : 0;
const mm = min ? parseInt(min, 10) : 0;
const ss = sec ? parseInt(sec, 10) : 0;
if ([hh, mm, ss].some(n => !Number.isFinite(n))) return null;
return ((hh * 3600) + (mm * 60) + ss) * 1000;
}
return null;
}
function msToDeltaText_(ms) {
if (ms == null || !isFinite(ms)) return "";
const s = ms / 1000;
const abs = Math.abs(s).toFixed(3);
const sign = s > 0 ? "+" : (s < 0 ? "-" : "+");
return `${sign}${abs}`;
}
function msToGapText_(ms) {
if (ms == null) return "";
const rounded = Math.round(ms / 1000);
if (rounded === 0) return "0";
const sign = rounded > 0 ? "+" : "-";
const abs = Math.abs(rounded);
const m = Math.floor(abs / 60);
const s = abs % 60;
return `${sign}${m}:${String(s).padStart(2, "0")}`;
}
function msToCalcText_(ms) {
if (ms == null || !isFinite(ms) || ms <= 0) return "";
const totalMs = Math.round(ms);
const msPart = totalMs % 1000;
const totalSec = Math.floor(totalMs / 1000);
const s = totalSec % 60;
const m = Math.floor(totalSec / 60);
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(msPart).padStart(3, "0")}`;
}
function tsMs_(r) {
// Prefer ISO timestamp with milliseconds for stable precision across replay multipliers.
if (!r) return NaN;
if (r.atIso) {
const v = Date.parse(r.atIso);
if (isFinite(v)) return v;
}
const s = String(r.at || "");
if (!s) return NaN;
const isoLike = s.replace(" ", "T"); // "YYYY-MM-DD HH:MM:SS.mmm" -> ISO-like
const v2 = Date.parse(isoLike);
return v2;
}
function gapClass_(ms) {
if (ms == null) return "gap-zero";
if (ms < 0) return "gap-neg";
if (ms > 0) return "gap-pos";
return "gap-zero";
}
function parseLapUi_() {
const el = document.querySelector(SEL_LAP_UI);
const m = (el?.textContent || "").match(/^(\d+)\s*\/\s*(\d+)$/);
return m ? { cur: +m[1], tot: +m[2], raw: `${m[1]}/${m[2]}` } : { cur: null, tot: null, raw: "" };
}
function getPositionText_() {
const el = document.querySelector(SEL_POS_UI);
return (el?.textContent || "").trim(); // e.g. "3/6" or "8/8"
}
function parseCompletionOrTime_() {
const el = document.querySelector(SEL_COMP_UI);
const t = (el?.textContent || "").trim();
const m = t.match(/(\d+(?:\.\d+)?)\s*%/);
if (m) {
const v = parseFloat(m[1]);
return { pct: Number.isFinite(v) ? v : null, time: "" };
}
// On some pages, completion switches from % to total time at finish.
if (TIME_ANY_RE.test(t)) return { pct: null, time: t };
return { pct: null, time: "" };
}
function esc_(s) {
return String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function escAttr_(s) { return esc_(s); }
function fmtWhen_(iso) {
if (!iso) return "—";
const s = String(iso);
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return s.replace("T", " ").replace("Z", "").slice(0, 16);
return s.slice(0, 32);
}
// Alias used by some render/export paths
function escapeHtml_(s) { return esc_(s); }
function toIsoFromAt_(atStr) {
// Convert "YYYY-MM-DD HH:MM:SS.mmm" -> "YYYY-MM-DDTHH:MM:SS.mmmZ"
const s = String(atStr || "").trim();
if (!s) return "";
if (s.includes("T")) return s;
const m = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?$/);
if (!m) return "";
const ms = m[3] ? "." + m[3].padEnd(3, "0") : "";
return `${m[1]}T${m[2]}${ms}Z`;
}
function lapAtIso_(r) {
return (r && r.atIso) ? String(r.atIso) : toIsoFromAt_(r && r.at);
}
function sanitizeFilePart_(s) {
return String(s || "")
.trim()
.replace(/[\/\\:*?"<>|]/g, "")
.replace(/\s+/g, " ")
.replace(/\s/g, "_")
.replace(/_+/g, "_")
.slice(0, 60) || "Unknown";
}
function buildBaseFilename_() {
const m = raceMeta || { track: "UnknownTrack", car: "Replay", detectedAtIso: nowIso_(), startAtIso: "" };
// Prefer true race start time:
// 1) replay info "Time started" (race log)
// 2) our recorded startAtIso (live/replay playback baseline)
// 3) detectedAtIso
let whenIso = "";
const tStarted = (m.replayInfo && (m.replayInfo.time_started || m.replayInfo.timeStarted)) ? String(m.replayInfo.time_started || m.replayInfo.timeStarted) : "";
if (tStarted) {
// "YYYY-MM-DD HH:MM:SS" -> ISO-ish
const mm = tStarted.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})$/);
whenIso = mm ? `${mm[1]}T${mm[2]}Z` : "";
}
if (!whenIso && m.startAtIso) whenIso = String(m.startAtIso);
if (!whenIso) whenIso = String(m.detectedAtIso || nowIso_());
const d = new Date(whenIso);
const stamp = formatLocalStamp_(d);
const track = sanitizeFilePart_(m.track);
const car = sanitizeFilePart_(m.car);
// Position tag (P1, P6, etc.) — use latest known position if available.
let pTag = "";
try {
const asc = currentRaceLaps_().slice().sort((a, b) => (a.lap || 0) - (b.lap || 0));
const last = asc.length ? asc[asc.length - 1] : null;
const posTxt = String((last && last.position) || getPositionText_() || "").trim();
const pm = posTxt.match(/^(\d+)\s*\/\s*\d+$/);
if (pm) pTag = `_P${pm[1]}`;
} catch { }
return `${stamp}.${track}.${car}${pTag}`;
}
function raceHasStarted_() { return !!(raceMeta && raceMeta.startAtIso); }
function clearRaceMeta_() {
raceMeta = null;
spectateName = "";
spectateCar = "";
spectateCarImg = "";
spectateDriverId_ = "";
lastReplaySelectAttemptAtMs = 0;
lastReplaySelectAttemptName = "";
GM_setValue(STORE_META_KEY, "");
startRecordedForMetaKey = "";
finishRecordedForMetaKey = "";
clearFinishCapture_();
rtOrigTitle_ = RT_ORIG_TITLE;
document.title = RT_ORIG_TITLE;
rtStartEpochMs_ = null;
}
function ensureLap0_(whenIso) {
ensureRaceMeta_();
if (!raceMeta || !raceMeta.metaKey) return;
if (raceHasStarted_()) return;
raceMeta.startAtIso = whenIso || new Date().toISOString();
startRecordedForMetaKey = raceMeta.metaKey;
saveRaceMeta_(raceMeta);
const at0Iso = raceMeta.startAtIso;
const at0 = at0Iso.replace("T", " ").replace("Z", "");
const exists = laps.some(r => r && r.lap === 0 && lapBelongsToMeta_(r));
if (!exists) {
laps.push({
id: `${raceMeta.metaKey}|0|${at0}`,
lap: 0,
position: "",
time: "",
at: at0,
atIso: at0Iso,
ms: null,
speed: getReplaySpeedFactor_(),
pause: (isReplay_() ? getReplayPauseAccumMs_() : 0),
ui: "0",
track: raceMeta.track || "",
car: raceMeta.car || "",
raceDetectedAtIso: raceMeta.detectedAtIso || ""
});
saveLaps_();
}
}
function maybeMarkRaceStarted_() {
try {
if (!recordingEnabled) return;
ensureRaceMeta_();
if (!raceMeta || !raceMeta.metaKey) return;
if (raceHasStarted_()) return;
if (raceIsFinished_() && !laps.length) return; // don't fabricate lap0 on finished screen
// Extra guard: on a finished replay screen (before pressing play), never mark started.
if (raceIsFinished_() && isReplay_() && !isReplayPlaying_()) return;
const info = document.querySelector(SEL_INFOSPOT);
const txt = info ? String(info.textContent || "").trim().toLowerCase() : "";
// Primary trigger: completion starts moving (replay/live)
const comp = (completionPct == null && !completionFinishTime) ? parseCompletionOrTime_() : { pct: completionPct, time: completionFinishTime };
const pct = comp.pct;
const moving = (typeof pct === "number" && isFinite(pct) && pct > 0);
// Secondary trigger: Torn explicitly says started
const saysReplay = (txt.includes("race replaying") || txt.includes("replaying"));
const saysStarted = txt.includes("race started") || (txt.includes("started") && !txt.includes("paused")) || (saysReplay && isReplayPlaying_());
if (!moving && !saysStarted) return;
// Save replay speed at start (if any) so calc can be scaled correctly.
const spd = getReplaySpeedFactor_();
raceMeta.replaySpeed = spd;
ensureLap0_(new Date().toISOString());
// If the replay is already on lap 2+ by the time we mark start, catch up
// pending lap(s) immediately using the last known "Last lap" time.
const { cur, raw } = parseLapUi_();
if (cur && cur > 1) {
pendingCompletedTarget = Math.max(pendingCompletedTarget || 0, cur - 1);
flushPending_(raw, getPositionText_());
}
uiDirty = true;
scheduleRender_();
} catch (e) {
console.warn("[MoDuL's Lap Recorder] maybeMarkRaceStarted_ failed", e);
}
}
/* ============================
* META DETECTION
* ============================ */
function parseRaceTitle_(raw) {
if (!raw) return { title: "", countdown: "" };
const cleanPart = (text) => String(text || "")
.replace(/\s+/g, " ")
.replace(/\s*[-—]+\s*$/, "")
.trim();
const parts = String(raw)
.replace(/\s+/g, " ")
.split(" - ")
.map(cleanPart)
.filter(Boolean);
return {
title: cleanPart(parts.slice(0, 2).join(" - ")),
countdown: cleanPart(parts.length > 2 ? parts.slice(2).join(" - ") : "")
};
}
function getRaceTitleParts_() {
const el =
document.querySelector(".drivers-list.right .title-black") ||
document.querySelector(".drivers-list.right .title-black.top-round") ||
document.querySelector("#racingupdatesnew .drivers-list.right .title-black") ||
document.querySelector("#racingupdates .drivers-list.right .title-black");
const raw = (el?.textContent || "").trim();
return parseRaceTitle_(raw);
}
function getAllTrackNames_() {
// Harvest track names from any track selectors on the page + saved records.
const out = new Set();
try {
for (const sel of Array.from(document.querySelectorAll("select"))) {
const id = (sel.id || "").toLowerCase();
const name = (sel.getAttribute("name") || "").toLowerCase();
if (!id.includes("track") && !name.includes("track")) continue;
for (const opt of Array.from(sel.querySelectorAll("option"))) {
const t = (opt.textContent || "").trim();
if (t && t.length >= 3 && !/^[-—]+$/.test(t)) out.add(t);
}
}
for (const el of Array.from(document.querySelectorAll("[class*='track']"))) {
const raw = (el.textContent || "").trim();
if (!raw || raw.length > 50) continue;
const parts = raw.split(" - ").map(x => x.trim()).filter(Boolean);
if (!parts.length) continue;
const t = parts[0];
if (t.length >= 3 && t.length <= 40) out.add(t);
}
} catch { }
try { for (const r of (records || [])) if (r && r.track) out.add(String(r.track)); } catch { }
const cur = (raceMeta?.track || "").trim();
if (cur) out.add(cur);
// Fallback: common track names (if the current page view doesn't expose a track selector).
if (out.size < 3) {
["Uptown", "Parkland", "Docks", "Commerce", "Industrial", "Stone Park", "Underground", "Airport", "Harbor", "City", "Mountain", "Coastal", "Suburbs"]
.forEach(t => out.add(t));
}
return Array.from(out).sort((a, b) => a.localeCompare(b));
}
function getCarName_() {
const candidates = [
".model p",
".model-wrap .model p",
"#racingupdatesnew .model p",
"#racingupdates .model p",
"#racing .model p",
".car-selected-wrap .img-title",
".car-selected-wrap span.img-title",
"#racingupdatesnew .car-selected-wrap .img-title",
"#racingupdates .car-selected-wrap .img-title",
".model-wrap .img-title",
".model-wrap span.img-title",
".car-selected .img-title",
"#racingupdatesnew .img-title",
"#racingupdates .img-title",
"#racing .img-title"
];
for (const sel of candidates) {
const el = document.querySelector(sel);
const t = (el?.textContent || "").trim();
if (/^replay(?:\s+driver)?$/i.test(t)) continue;
if (t) return t;
}
return "";
}
function findCarImageUrl_(carName) {
try {
const wanted = String(carName || "").trim();
if (!wanted) return "";
// Prefer the selected driver card (leaderboard)
const lb = document.querySelector(SEL_LEADERBOARD);
if (lb) {
const sel = lb.querySelector("li.selected") || lb.querySelector("li[class*='selected']");
const imgEl = sel?.querySelector("li.car img[title]");
const title = (imgEl?.getAttribute("title") || "").trim();
const src = (imgEl?.getAttribute("src") || "").trim();
if (src && title === wanted) return src;
}
// Fallback: search for any <img title="car"> using the Torn item image path.
const imgs = document.querySelectorAll("img[title]");
for (const el of imgs) {
const title = (el.getAttribute("title") || "").trim();
if (title !== wanted) continue;
const src = (el.getAttribute("src") || "").trim();
if (src && /\/images\/items\//.test(src)) return src;
}
return "";
} catch {
return "";
}
}
function getRaceId_() {
try {
const u = new URL(location.href);
const p = u.searchParams;
return (p.get("raceID") || p.get("raceId") || p.get("raceid") || "").trim();
} catch (e) {
return "";
}
}
function isReplayPage_() {
try {
const u = new URL(location.href);
const tab = (u.searchParams.get("tab") || "").toLowerCase();
return tab === "log" || tab === "replay";
} catch (e) {
return false;
}
}
function getReplayInfo_() {
// Only meaningful on race log/replay pages where the left panel shows "Race info"
try {
// Find an element whose text is exactly "Race info"
const header = Array.from(document.querySelectorAll("div,span,h1,h2,h3"))
.find(el => (el.textContent || "").trim() === "Race info");
if (!header) return null;
// The content is usually within the same card/panel as the header.
// We'll grab a reasonably small container around it.
let panel = header.closest("div");
for (let i = 0; i < 6 && panel; i++) {
const t = (panel.innerText || "");
// heuristic: panel must contain at least one "Type:" line
if (/\bType:\s*/i.test(t) && /\bCars allowed:\s*/i.test(t)) break;
panel = panel.parentElement;
}
if (!panel) return null;
const lines = (panel.innerText || "")
.split("\n")
.map(s => s.trim())
.filter(Boolean);
const info = {};
for (const line of lines) {
const m = line.match(/^([A-Za-z ]+):\s*(.+)$/);
if (!m) continue;
const key = m[1].trim().toLowerCase().replace(/\s+/g, "_");
const val = m[2].trim();
info[key] = val;
}
// Keep only useful replay fields (avoid pulling random sidebar stats)
const allow = ["type", "cars_allowed", "upgrades_allowed", "bet_amount", "time_started", "name", "track", "laps"];
const out = {};
for (const k of allow) if (k in info) out[k] = info[k];
const has = ["type", "cars_allowed", "upgrades_allowed", "bet_amount"].some(k => k in out);
return has ? out : null;
} catch (e) {
return null;
}
}
function ensureRaceMeta_() {
const { title: trackFound, countdown } = getRaceTitleParts_();
const carFound = getCarName_();
if (!trackFound && !carFound && !raceMeta) return;
if (!raceMeta) {
const d = new Date();
const detectedAtIso = d.toISOString();
const rid = getRaceId_() || "";
raceMeta = {
track: trackFound || "UnknownTrack",
car: carFound || "Replay",
carImg: findCarImageUrl_(carFound) || spectateCarImg || "",
driver: currentDriverName_(),
driverId: currentDriverId_(),
raceId: rid,
replayInfo: (isReplayPage_() && rid) ? getReplayInfo_() : null,
theme: theme,
totalTime: "",
detectedAtIso,
detectedAtLocal: formatLocalDateTime_(d),
metaKey: makeRaceMetaKey_(trackFound || "UnknownTrack", carFound || "Replay", rid, detectedAtIso),
countdown: countdown || "",
startAtIso: ""
};
finishRecordedForMetaKey = "";
startRecordedForMetaKey = "";
clearFinishCapture_();
saveRaceMeta_(raceMeta);
refreshLastLapCompleted_();
return;
}
let changed = false;
if (trackFound && trackFound !== raceMeta.track) { raceMeta.track = trackFound; changed = true; }
if (carFound && carFound !== raceMeta.car) { raceMeta.car = carFound; raceMeta.carImg = findCarImageUrl_(carFound) || raceMeta.carImg || ""; changed = true; }
if (raceMeta && raceMeta.car && !raceMeta.carImg) { const u = findCarImageUrl_(raceMeta.car) || spectateCarImg || ""; if (u) { raceMeta.carImg = u; changed = true; } }
const nextDriver = currentDriverName_();
const nextDriverId = currentDriverId_();
if (nextDriver && raceMeta.driver !== nextDriver) { raceMeta.driver = nextDriver; changed = true; }
if (nextDriverId && raceMeta.driverId !== nextDriverId) { raceMeta.driverId = nextDriverId; changed = true; }
const oldRaceId = raceMeta.raceId || "";
const rid = getRaceId_() || "";
const raceIdChanged = oldRaceId !== rid;
if (raceIdChanged) { raceMeta.raceId = rid; changed = true; }
if (isReplayPage_() && rid && !raceMeta.replayInfo) {
const rinfo = getReplayInfo_();
if (rinfo) { raceMeta.replayInfo = rinfo; changed = true; }
}
const th = theme;
if ((raceMeta.theme || "dark") !== th) { raceMeta.theme = th; changed = true; }
if (!raceHasStarted_()) {
const cd = countdown || "";
if ((raceMeta.countdown || "") !== cd) { raceMeta.countdown = cd; changed = true; }
}
const newKey = makeRaceMetaKey_(raceMeta.track || "UnknownTrack", raceMeta.car || "Replay", raceMeta.raceId || "", raceMeta.detectedAtIso || "");
if (raceMeta.metaKey !== newKey) {
const wasLegacyKey = !/^(race|seen):/.test(String(raceMeta.metaKey || ""));
if (raceIdChanged || !wasLegacyKey) {
const d = new Date();
raceMeta.detectedAtIso = d.toISOString();
raceMeta.detectedAtLocal = formatLocalDateTime_(d);
}
raceMeta.metaKey = makeRaceMetaKey_(raceMeta.track || "UnknownTrack", raceMeta.car || "Replay", raceMeta.raceId || "", raceMeta.detectedAtIso);
resetSeenLapTime_();
lastLapCompleted = 0;
lastRecordedKey = "";
startRecordedForMetaKey = "";
finishRecordedForMetaKey = "";
clearFinishCapture_();
pendingCompletedTarget = 0;
pendingFinalLap = 0;
changed = true;
}
if (changed) {
saveRaceMeta_(raceMeta);
refreshLastLapCompleted_();
}
}
function maybeResetOnRaceIdChange_() {
const currentRaceId = getRaceId_();
if (!currentRaceId) return;
const storedRaceId = loadLastRaceId_();
if (!storedRaceId) {
saveLastRaceId_(currentRaceId);
return;
}
if (storedRaceId !== currentRaceId) {
if (clearOnRaceChange) {
resetForRaceChange_(currentRaceId);
} else {
saveLastRaceId_(currentRaceId);
}
}
}
/* ============================
* THEME APPLICATION
* ============================ */
function applyTheme_() {
const win = document.getElementById("rtLapWin");
const btn = document.getElementById("rtLapBtn");
if (!win || !btn) return;
win.dataset.theme = theme;
btn.dataset.theme = theme;
const label = document.getElementById("rtThemeLabel");
if (label) {
const t = THEMES.find(x => x.key === theme);
label.textContent = t ? t.name : "Theme";
}
updateHeaderCompact_(win);
}
function cycleTheme_() {
const i = THEMES.findIndex(x => x.key === theme);
const next = (i >= 0) ? THEMES[(i + 1) % THEMES.length].key : THEMES[0].key;
theme = next;
saveTheme_();
applyTheme_();
uiDirty = true;
scheduleRender_();
}
/* ============================
* UI STYLES
* ============================ */
GM_addStyle(`
/* ============================
* UI STYLES (Lap Recorder)
* ============================ */
#rtLapWin, #rtLapBtn{
/* defaults (dark) */
--bg:#161616;
--panel:#121212;
--border:#333;
--text:#ffffff;
--muted:#bbbbbb;
--header:#1a1a1a;
--hover:rgba(255,255,255,.04);
--pill:#1c1c1c;
--pillHover:#232323;
--tableBg:#0f0f0f;
--thBg:#1a1a1a;
--scrollTrack:#1c1c1c;
--scrollThumb:#9a9a9a;
--scrollThumbBorder:#2b2b2b;
--carIconBg: rgba(255,255,255,.08);
/* semantic */
--time: var(--text);
--gapNeg: #00FF00;
--gapPos: #FFC83D;
--gapZero: var(--muted);
--bestRowBg: rgba(180, 120, 255, 0.22);
--bestRowBorder: rgba(180, 120, 255, 0.55);
--bestText: #f2e9ff;
}
/* ========== Base icon styles ========== */
img.carIcon{
width:38px;
height:19px;
object-fit:contain;
border-radius:8px;
background: var(--carIconBg);
border:1px solid rgba(255,255,255,.12);
display:inline-block;
vertical-align:middle;
}
#rtCarImg.carIcon{
width:46px;
height:23px;
border-radius:10px;
border:1px solid rgba(255,255,255,.14);
}
/* ========== Force all UI text to follow vars (prevents black leaks) ========== */
#rtLapWin, #rtLapBtn,
#rtHdr, #rtBody, #rtMeta, #rtCountdown,
#rtTable, #rtTrack, #rtCar, #rtDriver, #rtRaceTime{
color: var(--text) !important;
}
.muted{ color: var(--muted) !important; }
/* Race ID link visibility */
#rtRaceId{ color: var(--gapPos) !important; text-decoration: underline; font-weight: 900; }
#rtRaceId:hover{ opacity:.9; }
/* ========== Floating Button ========== */
#rtLapBtn{
position:fixed;right:16px;bottom:16px;z-index:999999;
background:var(--panel);
border:1px solid var(--border);
color:var(--text);
border-radius:14px;
padding:10px 12px;
font:12px system-ui;
cursor:pointer;
display:flex;
gap:10px;
align-items:center;
box-shadow:0 12px 30px rgba(0,0,0,.35);
user-select:none;
}
#rtLapBtn .recDot{width:10px;height:10px;border-radius:50%;background:#ff3b3b}
#rtLapBtn .recDot.off{background:#777}
#rtLapBtn .badge{
padding:4px 8px;
border-radius:999px;
background:rgba(255,255,255,.10);
border:1px solid rgba(0,0,0,.25);
font-weight:700;
}
/* ========== Window ========== */
#rtLapWin{
position:fixed;
right:16px;
bottom:16px;
width:720px;
height:420px;
min-width:550px;
min-height:300px;
background:var(--bg);
border:1px solid var(--border);
border-radius:16px;
box-shadow:0 18px 46px rgba(0,0,0,.45);
display:none;
z-index:1000000;
overflow:hidden;
resize:both;
}
/* Cursor sanity */
#rtLapWin{ cursor:default; }
#rtLapWin button, #rtLapWin a, #rtLapWin input, #rtLapWin textarea, #rtLapWin select,
#rtLapWin .delBtn{ cursor:default; }
.totalCell{font-weight:800;opacity:.95}
/* Header / drag handle */
#rtHdr{
height:52px;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:0 12px;
border-bottom:1px solid var(--border);
cursor:move;
user-select:none;
background:var(--header);
color:var(--text);
position:relative;
z-index:200;
box-sizing:border-box;
overflow:hidden;
}
#rtHdr .left{
display:flex;
flex-direction:column;
align-items:flex-start;
gap:2px;
flex:1 1 auto;
min-width:115px;
overflow:hidden;
}
#rtHdr .title{font-weight:900;letter-spacing:.2px}
#rtHdr .count{opacity:.9;font-weight:800}
#rtVer{
max-width:100%;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
#rtHdr .right{
display:flex;
gap:8px;
align-items:center;
justify-content:flex-end;
flex:0 1 auto;
min-width:0;
flex-wrap:nowrap;
overflow:hidden;
}
/* Pills */
.pill{
border:1px solid rgba(20,20,20,.55);
background:var(--pill);
border-radius:999px;
padding:7px 10px;
color:var(--text) !important;
cursor:pointer;
font-size:12px;
display:flex;
align-items:center;
gap:8px;
flex:0 0 auto;
white-space:nowrap;
box-sizing:border-box;
}
.pill:hover{background:var(--pillHover)}
.pill.on{box-shadow: inset 0 0 0 2px var(--gapPos);}
#rtLapWin.rtCompact #rtHdr .right{gap:6px}
#rtLapWin.rtCompact .pill{
width:36px;
min-width:36px;
height:32px;
justify-content:center;
padding:7px 0;
gap:0;
}
#rtLapWin.rtCompact .pill span:nth-child(2){display:none !important}
#rtLapWin.rtCompact #rtRecText{display:none !important}
#rtLapWin.rtCompact #rtThemeLabel{display:none !important}
#rtRecDotInline{width:10px;height:10px;border-radius:50%;background:#ff3b3b;display:inline-block}
#rtRecDotInline.off{background:#777}
/* Body layout */
#rtBody{
position:relative;
z-index:1;
padding:12px 12px 22px 12px;
height:calc(100% - 52px);
display:flex;
flex-direction:column;
gap:10px;
box-sizing:border-box;
}
/* Meta */
#rtMeta{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:14px;
color:var(--muted) !important;
font:12px system-ui;
}
#rtMeta>div{
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
}
#rtMeta img{vertical-align:middle;}
#rtMeta .muted{line-height:1;}
#rtMeta .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
#rtCountdown{
color:var(--muted) !important;
font:12px system-ui;
margin-top:-6px;
padding-left:2px;
}
#rtCarWrap{display:inline-flex;align-items:center;gap:8px;}
/* ========== Main laps table wrapper ========== */
#rtTableWrap{
flex:1;
overflow:auto;
border:1px solid rgba(0,0,0,.25);
border-radius:12px;
background:var(--tableBg);
padding-bottom:14px;
box-sizing:border-box;
}
/* Scrollbar (main) */
#rtTableWrap::-webkit-scrollbar{ width:12px; height:12px; }
#rtTableWrap::-webkit-scrollbar-track{ background:var(--scrollTrack); border-radius:10px; }
#rtTableWrap::-webkit-scrollbar-thumb{
background:var(--scrollThumb);
border-radius:10px;
border:3px solid var(--scrollThumbBorder);
}
/* Main table */
#rtTable{
width:100%;
border-collapse:collapse;
font:12px system-ui;
color:var(--text);
}
#rtTable th,#rtTable td{
cursor:text;
user-select:text;
padding:9px;
border-bottom:1px solid rgba(0,0,0,.25);
text-align:left;
white-space:nowrap;
}
#rtTable th{
background:var(--thBg);
position:sticky;
top:0;
z-index:5;
color:var(--text);
}
#rtTable tr:hover td{background:var(--hover)}
#rtLapWin #rtTable tbody td:not(.gap-neg):not(.gap-pos):not(.gap-zero){
color: var(--text) !important;
}
#rtTable tbody td:nth-child(1),
#rtTable tbody td:nth-child(2),
#rtTable tbody td:nth-child(3){
font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
font-weight:900;
color:var(--time) !important;
}
/* Delta colors */
.gap-neg { color: var(--gapNeg) !important; font-weight: 900; }
.gap-pos { color: var(--gapPos) !important; font-weight: 900; }
.gap-zero{ color: var(--gapZero) !important; }
/* Fastest row */
.fastest { background: var(--bestRowBg) !important; }
.fastest td { border-bottom-color: var(--bestRowBorder) !important; }
.fastest td:nth-child(4){ color:#9C6DFF !important; font-weight:900; }
.flBadge{
display:inline-flex;
align-items:center;
gap:6px;
padding:2px 8px;
border-radius:999px;
border:1px solid var(--bestRowBorder);
background: var(--bestRowBg);
color: var(--bestText);
font-weight:900;
font-size:11px;
margin-left:8px;
}
/* Delete button (laps) */
.delBtn{
border:1px solid rgba(0,0,0,.25);
background:rgba(255,255,255,.10);
color:var(--text) !important;
border-radius:10px;
padding:4px 8px;
cursor:pointer;
font:12px system-ui;
display:inline-flex;
align-items:center;
justify-content:center;
}
.delBtn:hover{ background:rgba(255,255,255,.16); }
/* ========== RECORDS PANEL (bottom) ========== */
#rtRecords{
margin-top:10px;
color: var(--text);
border:1px solid var(--border);
border-radius:14px;
background:var(--panel);
overflow:hidden;
box-shadow: inset 0 1px 0 rgba(255,255,255,.03);
}
#rtRecords .recordsBar{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:10px 10px;
background: var(--header);
border-bottom:1px solid var(--border);
font-size:12px;
}
#rtRecords .recordsLeft{
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
}
#rtRecords .select{
background: var(--pill);
color: var(--text);
border:1px solid var(--border);
border-radius:10px;
padding:6px 10px;
font-size:12px;
outline:none;
}
/* Scroll container: shows scrollbar only when needed (e.g., >3 rows) */
#rtRecScroll{
max-height:168px; /* header + ~3 rows */
overflow-y:scroll;
overflow-x:hidden;
scrollbar-gutter: stable;
}
/* Scrollbar (records) – same as main */
#rtRecScroll::-webkit-scrollbar{ width:12px; height:12px; }
#rtRecScroll::-webkit-scrollbar-track{ background:var(--scrollTrack); border-radius:10px; }
#rtRecScroll::-webkit-scrollbar-thumb{
background:var(--scrollThumb);
border-radius:10px;
border:3px solid var(--scrollThumbBorder);
}
/* Records table */
#rtRecTable{
width:100%;
border-collapse:collapse;
table-layout:fixed;
font-size:12px;
}
#rtRecTable thead th{
position:sticky;
top:0;
z-index:5;
text-align:left;
padding:9px 10px;
background: var(--thBg);
border-bottom:1px solid var(--border);
color: var(--muted);
font-weight:800;
}
#rtRecTable tbody td{
padding:9px 10px;
border-bottom:1px solid rgba(255,255,255,.06);
vertical-align:middle;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color: var(--text);
}
#rtRecTable tbody tr:hover{ background: var(--hover); }
/* Column widths (balanced + prevents blending/cutoff) */
#rtRecTable .colNum{ width:4%; text-align:center; }
#rtRecTable .colImg{ width:9%; text-align:center; }
#rtRecTable .colCar{ width:28%; }
#rtRecTable .colTime{ width:18%; }
#rtRecTable .colDriver{ width:13%; }
#rtRecTable .colRace{ width:17%; text-align:left; }
.recRace{
font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
color: var(--muted);
}
.recRaceLink{
color: var(--gapPos);
text-decoration:none;
font-weight:700;
}
.recRaceLink:hover{
text-decoration:underline;
opacity:.9;
}
#rtRecTable .colWhen{ width:18%; }
#rtRecTable .colAct{ width:8%; text-align:center; }
/* Cells */
.recNum{ text-align:center; color:var(--muted); }
.recImg{ text-align:center; padding:6px 6px; }
.recImg .carIcon{
width:34px !important;
height:20px !important;
border-radius:8px;
display:block;
margin:0 auto;
object-fit:contain;
background:rgba(255,255,255,0.06);
border:1px solid rgba(255,255,255,0.10);
}
.recCar{ font-weight:700; min-width:0; }
.recCar span{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
.recWhen{ white-space:nowrap; color: var(--muted); font-size:12px; opacity:.9; }
.recAct{ text-align:center; }
/* Delete (records) */
.recDelBtn{
border:1px solid var(--border);
background: var(--pill);
color: var(--text);
border-radius:10px;
padding:4px 8px;
cursor:pointer;
}
.recDelBtn:hover{ background: var(--pillHover); }
/* ========== Toast ========== */
.rtToast{
position:fixed;
left:50%;
bottom:18px;
transform:translateX(-50%) translateY(10px);
opacity:0;
z-index:999999;
background:rgba(0,0,0,.75);
color:#fff;
padding:10px 12px;
border-radius:12px;
border:1px solid rgba(255,255,255,.15);
backdrop-filter:blur(6px);
font-size:13px;
max-width:min(720px,92vw);
text-align:center;
transition:opacity .22s ease,transform .22s ease;
}
.rtToast.show{opacity:1;transform:translateX(-50%) translateY(0);}
/* ============================
* THEMES
* ============================ */
/* Classic */
#rtLapWin[data-theme="classic"], #rtLapBtn[data-theme="classic"], #rtRecTable[data-theme="classic"]{
--bg:#2a2a2a; --panel:#2f2f2f; --border:#4a4a4a;
--text:#f5f5f5; --muted:#d0d0d0; --header:#343434;
--hover:rgba(255,255,255,.05); --pill:#3a3a3a; --pillHover:#444;
--tableBg:#262626; --thBg:#313131; --scrollTrack:#2b2b2b; --scrollThumb:#bdbdbd;
--scrollThumbBorder:#2b2b2b;
--time:#ffffff;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(180, 120, 255, 0.22);
--bestRowBorder: rgba(180, 120, 255, 0.55);
--bestText:#f2e9ff;
}
/* Dark */
#rtLapWin[data-theme="dark"], #rtLapBtn[data-theme="dark"], #rtRecTable[data-theme="dark"]{
--bg:#141414; --panel:#101010; --border:#2f2f2f; --header:#171717;
--tableBg:#0d0d0d; --thBg:#171717; --pill:#1a1a1a; --pillHover:#222;
--scrollTrack:#141414; --scrollThumb:#8a8a8a; --scrollThumbBorder:#141414;
--time:#ffffff;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(180, 120, 255, 0.22);
--bestRowBorder: rgba(180, 120, 255, 0.55);
--bestText:#f2e9ff;
}
/* Ice */
#rtLapWin[data-theme="ice"], #rtLapBtn[data-theme="ice"], #rtRecTable[data-theme="ice"]{
--bg:#e6eef7;
--panel:#f1f6fb;
--border:#b7c7d8;
--header:#d9e6f3;
--tableBg:#eef4fb;
--thBg:#d9e6f3;
--pill:#d4e2f1;
--pillHover:#c7d9eb;
--text:#1b1f24;
--muted:#33414f;
--hover:#e7f0fa;
--scrollTrack:#d9e6f3;
--scrollThumb:#8aa3bb;
--scrollThumbBorder:#d9e6f3;
--time:#0b0f14;
--gapNeg:#00AA00; --gapPos:#b18630; --gapZero:var(--muted);
--bestRowBg: rgba(180, 120, 255, 0.22);
--bestRowBorder: rgba(180, 120, 255, 0.55);
--bestText:#0b0f14;
}
#rtLapWin[data-theme="ice"] td:nth-child(4){
-webkit-text-stroke: 0.15px rgba(0,0,0,0.60);
}
/* Neon */
#rtLapWin[data-theme="neon"], #rtLapBtn[data-theme="neon"], #rtRecTable[data-theme="neon"]{
--bg:#0b0c10; --panel:#11131a; --border:#2d2f3a; --header:#151824;
--tableBg:#0b0c10; --thBg:#151824; --pill:#1c2030; --pillHover:#232944;
--text:#eaf0ff; --hover:rgba(255,255,255,.06);
--muted:#aab3d8;
--scrollTrack:#11131a; --scrollThumb:#7f8cff; --scrollThumbBorder:#11131a;
--time:#eaf0ff;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(180, 120, 255, 0.22);
--bestRowBorder: rgba(180, 120, 255, 0.55);
--bestText:#f2e9ff;
}
/* Torn Blue */
#rtLapWin[data-theme="class_a"], #rtLapBtn[data-theme="class_a"], #rtRecTable[data-theme="class_a"]{
--bg:#0d1218; --panel:#121a24; --border:#203042; --header:#162030;
--tableBg:#0b1016; --thBg:#162030; --pill:#17263a; --pillHover:#1d324c;
--text:#eaf3ff; --muted:#b8cbe2; --hover:rgba(255,255,255,.06);
--scrollTrack:#121a24; --scrollThumb:#3aa0ff; --scrollThumbBorder:#121a24;
--time:#eaf3ff;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(58, 160, 255, 0.18);
--bestRowBorder: rgba(58, 160, 255, 0.55);
--bestText:#eaf3ff;
}
/* Torn Red */
#rtLapWin[data-theme="class_b"], #rtLapBtn[data-theme="class_b"], #rtRecTable[data-theme="class_b"]{
--bg:#150b0c; --panel:#1d1012; --border:#3a1a1e; --header:#241215;
--tableBg:#12090a; --thBg:#241215; --pill:#2b1417; --pillHover:#35191d;
--text:#fff0f1; --muted:#e4b9bd; --hover:rgba(255,255,255,.06);
--scrollTrack:#1d1012; --scrollThumb:#ff4b57; --scrollThumbBorder:#1d1012;
--time:#fff0f1;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(255, 75, 87, 0.16);
--bestRowBorder: rgba(255, 75, 87, 0.55);
--bestText:#fff0f1;
}
/* Torn Green */
#rtLapWin[data-theme="class_c"], #rtLapBtn[data-theme="class_c"], #rtRecTable[data-theme="class_c"]{
--bg:#0c120e; --panel:#101a14; --border:#203826; --header:#132018;
--tableBg:#0a0f0c; --thBg:#132018; --pill:#163022; --pillHover:#1c3a29;
--text:#effff5; --muted:#bfe2cc; --hover:rgba(255,255,255,.06);
--scrollTrack:#101a14; --scrollThumb:#33ff7d; --scrollThumbBorder:#101a14;
--time:#effff5;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(51, 255, 125, 0.14);
--bestRowBorder: rgba(51, 255, 125, 0.55);
--bestText:#effff5;
}
/* Torn Purple */
#rtLapWin[data-theme="class_d"], #rtLapBtn[data-theme="class_d"], #rtRecTable[data-theme="class_d"]{
--bg:#120b16; --panel:#181020; --border:#2f1f3b; --header:#1e1427;
--tableBg:#0f0913; --thBg:#1e1427; --pill:#251730; --pillHover:#2d1d3a;
--text:#f7efff; --muted:#d7c2ea; --hover:rgba(255,255,255,.06);
--scrollTrack:#181020; --scrollThumb:#c056ff; --scrollThumbBorder:#181020;
--time:#f7efff;
--gapNeg:#00FF00; --gapPos:#FFC83D; --gapZero:var(--muted);
--bestRowBg: rgba(192, 86, 255, 0.16);
--bestRowBorder: rgba(192, 86, 255, 0.55);
--bestText:#f7efff;
}
/* Torn Gold */
#rtLapWin[data-theme="class_e"], #rtLapBtn[data-theme="class_e"], #rtRecTable[data-theme="class_e"]{
--bg:#17110a; --panel:#23180b; --border:#5a3c12; --header:#2b1e0d;
--tableBg:#130e08; --thBg:#261b10; --pill:#2d2013; --pillHover:#372818;
--text:#fff6e6; --muted:#e8d2ac; --hover:rgba(255,255,255,.06);
--scrollTrack:#1f160d; --scrollThumb:#FFD700; --scrollThumbBorder:#1f160d;
--time:#fff6e6;
--gapNeg:#00FF00; --gapPos:#ffd24a; --gapZero:var(--muted);
--bestRowBg: rgba(255, 210, 74, 0.18);
--bestRowBorder: rgba(255, 210, 74, 0.70);
--bestText:#fff6e6;
}
`);
/* ============================
* EXPORTS
* ============================ */
function buildHtmlReport_() {
const asc = currentRaceLaps_().slice().sort((a, b) => (a.lap || 0) - (b.lap || 0));
const fastestUiMs = Math.min(...asc.map(r => r.ms).filter(ms => typeof ms === "number" && ms > 0));
// Calc lap time based on capture timestamps (centiseconds precision)
const calcByLap = new Map();
for (let i = 0; i < asc.length; i++) {
const cur = asc[i];
const prev = i > 0 ? asc[i - 1] : null;
calcByLap.set(cur.lap, computeLapChronoMs_(cur, prev));
}
// Total (cumulative) based on calculated lap times
const cumByLap = new Map();
let cum = 0;
let ok = true;
let maxLapShown = 0;
for (const r of asc) {
const lapNo = (r && r.lap) ? r.lap : 0;
if (lapNo > maxLapShown) maxLapShown = lapNo;
const v = calcByLap.get(lapNo);
if (typeof v === "number" && isFinite(v) && v >= 0 && ok) {
cum += v;
cumByLap.set(lapNo, cum);
} else {
ok = false;
cumByLap.set(lapNo, null);
}
}
const calcVals = [...calcByLap.entries()].filter(([, v]) => typeof v === "number" && isFinite(v) && v > 0);
const fastestCalcMs = calcVals.length ? Math.min(...calcVals.map(([, v]) => v)) : Infinity;
const fastestLap = calcVals.length ? Math.min(...calcVals.filter(([, v]) => v === fastestCalcMs).map(([k]) => k)) : null;
const created = new Date().toLocaleString();
let raceTimeLocal = raceMeta?.detectedAtLocal || "—";
if (!raceHasStarted_() && (raceMeta?.countdown || "")) {
const secs = parseCountdownToSeconds_(raceMeta.countdown);
raceTimeLocal = formatLocalDateTime_(new Date(Date.now() + secs * 1000));
}
const rows = asc.filter(x => (x.lap || 0) > 0).map((r, i) => {
const prev = i > 0 ? asc[i - 1] : null;
const curCalc = calcByLap.get(r.lap);
const prevCalc = prev ? calcByLap.get(prev.lap) : null;
const deltaMs = (r.lap <= 1) ? 0 : ((typeof curCalc === "number" && typeof prevCalc === "number") ? (curCalc - prevCalc) : 0);
const isFastest = (fastestLap != null && r.lap === fastestLap);
const tip = [r.driverName, r.driverCar].filter(Boolean).join(" — ");
const tipAttr = tip ? ` title="${escAttr_(tip)}"` : "";
const totalTxt = (r.lap === maxLapShown) ? msToCalcText_(cumByLap.get(r.lap)) : "";
return `
<tr class="${isFastest ? "fastest" : ""}"${tipAttr}>
<td>${r.lap}</td>
<td>${esc_(r.position || "")}</td>
<td class="muted mono">${esc_(msToCalcText_(calcByLap.get(r.lap)))}${isFastest ? ` <span class="flBadge">🏁FL</span>` : ""}</td>
<td class="${gapClass_(deltaMs)} mono ${isFastest ? "delta-best" : ""}">${esc_(msToDeltaText_(deltaMs))}</td>
<td>${esc_(r.time)}</td>
<td class="mono">${esc_(totalTxt)}</td>
<td></td>
</tr>`;
}).join("");
const track = raceMeta?.track || "—";
const car = raceMeta?.car || "—";
const carImg = raceMeta?.carImg || "";
const rid = (raceMeta?.raceId || "").trim();
const raceLink = rid ? `https://www.torn.com/page.php?sid=racing&raceID=${encodeURIComponent(rid)}` : "";
const raceAtIso = bestRaceAtIso_();
const themeKey = (theme || raceMeta?.theme || "classic");
const THEME_MAP = {
classic: {
bg: "#2a2a2a", card: "#2f2f2f", border: "#4a4a4a", th: "#313131", table: "#262626", hover: "#2f2f2f", accent: "#6aa6ff", muted: "#d0d0d0", text: "#f5f5f5",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#d0d0d0"
},
dark: {
bg: "#141414", card: "#101010", border: "#2f2f2f", th: "#171717", table: "#0d0d0d", hover: "#151515", accent: "#6aa6ff", muted: "#bbbbbb", text: "#ffffff",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#bbbbbb"
},
ice: {
bg: "#e6eef7", card: "#f1f6fb", border: "#b7c7d8", th: "#d9e6f3", table: "#eef4fb", hover: "#e7f0fa", accent: "#2b6fff", muted: "#33414f", text: "#1b1f24",
gapNeg: "#00AA00", gapPos: "#b18630", gapZero: "#33414f"
},
neon: {
bg: "#0b0c10", card: "#11131a", border: "#2d2f3a", th: "#151824", table: "#0b0c10", hover: "#0f1320", accent: "#7f8cff", muted: "#aab3d8", text: "#eaf0ff",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#aab3d8"
},
// Class themes (in-game colourways)
class_a: {
bg: "#0d1218", card: "#121a24", border: "#203042", th: "#162030", table: "#0b1016", hover: "#111a26", accent: "#3aa0ff", muted: "#b8cbe2", text: "#eaf3ff",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#b8cbe2"
},
class_b: {
bg: "#150b0c", card: "#1d1012", border: "#3a1a1e", th: "#241215", table: "#12090a", hover: "#1a0d0f", accent: "#ff4b57", muted: "#e4b9bd", text: "#fff0f1",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#e4b9bd"
},
class_c: {
bg: "#0c120e", card: "#101a14", border: "#203826", th: "#132018", table: "#0a0f0c", hover: "#0e1510", accent: "#33ff7d", muted: "#bfe2cc", text: "#effff5",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#bfe2cc"
},
class_d: {
bg: "#120b16", card: "#181020", border: "#2f1f3b", th: "#1e1427", table: "#0f0913", hover: "#140d18", accent: "#c056ff", muted: "#d7c2ea", text: "#f7efff",
gapNeg: "#00FF00", gapPos: "#FFC83D", gapZero: "#d7c2ea"
},
class_e: {
bg: "#17110a", card: "#23180b", border: "#5a3c12", th: "#2b1e0d", table: "#130e08", hover: "#1d150b", accent: "#FFD700", muted: "#e8d2ac", text: "#fff6e6",
gapNeg: "#00FF00", gapPos: "#FFD700", gapZero: "#e8d2ac"
}
};
const THEME = THEME_MAP[themeKey] || THEME_MAP.classic;
const replayInfo = (isReplay_() ? (raceMeta?.replayInfo || null) : null);
const replayRows = replayInfo ? (() => {
const LABEL = {
name: "Name",
type: "Type",
cars_allowed: "Cars allowed",
upgrades_allowed: "Upgrades allowed",
bet_amount: "Bet amount"
};
const ORDER = ["name", "type", "cars_allowed", "upgrades_allowed", "bet_amount"];
return ORDER
.filter(k => replayInfo[k] != null && String(replayInfo[k]).trim() !== "")
.map(k => `<div class="muted"><b>${esc_(LABEL[k])}:</b> ${esc_(replayInfo[k])}</div>`)
.join("");
})() : "";
return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="rt-track" content="${escAttr_(track)}"><meta name="rt-car" content="${escAttr_(car)}"><meta name="rt-car-img" content="${escAttr_(carImg)}"><meta name="rt-race-at" content="${escAttr_(raceAtIso)}"><meta name="rt-race-id" content="${escAttr_(rid)}"><title>Torn Lap Times</title>
<style>
body{font-family:system-ui;margin:20px;background:${THEME.bg};color:${THEME.text}}
.card{background:${THEME.card};border:1px solid ${THEME.border};border-radius:14px;padding:14px;box-shadow:0 12px 28px rgba(0,0,0,.35)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{padding:10px;border-bottom:1px solid rgba(0,0,0,.25);text-align:left;white-space:nowrap}
th{background:${THEME.th};position:sticky;top:0;z-index:5;color:${THEME.text}}
tr:hover td{background:${THEME.hover}}
.muted{opacity:.8}
.mono{font-family:ui-monospace,Menlo,Consolas,monospace}
a{color:${THEME.accent};text-decoration:none;font-weight:600}
a:hover{text-decoration:underline}
.gap-neg { color: ${THEME.gapNeg}; font-weight: 900; }
.gap-pos { color: ${THEME.gapPos}; font-weight: 900; }
.gap-zero { color: ${THEME.gapZero}; }
.delta-best{ color:#9C6DFF; font-weight:900; }
.delta-best { color:#9C6DFF; font-weight:900; }
.fastest { background: rgba(180, 120, 255, 0.22); }
.fastest td:nth-child(4){ color:#9C6DFF !important; font-weight:900; }
.flBadge{
display:inline-flex;
align-items:center;
gap:6px;
padding:2px 8px;
border-radius:999px;
border:1px solid rgba(180,120,255,.65);
background: rgba(180,120,255,.22);
color:#f2e9ff;
font-weight:900;
font-size:11px;margin-left:8px;
}
</style></head>
<body>
<div class="card">
<h2 style="margin:0 0 8px 0">Torn Lap Times</h2>
<div class="muted"><b>Generated:</b> ${esc_(created)}</div>
<div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<div><b>Track:</b> ${esc_(track)} <b>Car:</b> ${esc_(car)}</div>
${carImg ? `<img src="${escAttr_(carImg)}" alt="" style="height:22px;width:auto;border-radius:6px;vertical-align:middle;opacity:.95;">` : ""}
</div>
<div class="mono"><b>Race:</b> ${esc_(raceTimeLocal)}${raceLink ? ` <a href="${raceLink}" target="_blank" rel="noopener noreferrer">(Replay link)</a>` : ""}</div>${rid ? `<div class="muted"><b>Race ID:</b> ${esc_(rid)}</div>` : ""}${replayRows ? `<div style="margin-top:8px">${replayRows}</div>` : ""}
<div class="muted" style="margin-top:6px">Gap = vs previous lap • 🏁FL = Fastest Lap</div>
<div style="max-height:70vh;overflow:auto;border:1px solid rgba(0,0,0,.25);border-radius:12px;margin-top:12px">
<table>
<thead><tr><th>Lap</th><th>Pos</th><th>Chrono</th><th>Delta</th><th>Last lap</th><th>Total</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="7" class="muted">No laps recorded.</td></tr>`}</tbody>
</table>
</div>
</div>
</body></html>`;
}
function exportHtml_() {
try {
ensureRaceMeta_();
// ensure we export with the currently selected theme
if (raceMeta) { raceMeta.theme = theme; saveRaceMeta_(raceMeta); }
const html = buildHtmlReport_();
const base = buildBaseFilename_();
// Download first (keeps the browser "user gesture" even on stricter download policies)
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${base}.html`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
// Copy to clipboard (best effort)
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(html).catch(() => { });
} else {
const ta = document.createElement("textarea");
ta.value = html;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
} catch { }
// Export complete.
} catch (e) {
console.warn("[MoDuL's Lap Recorder] exportHtml_ failed", e);
toast_("HTML export failed — check console.");
}
}
/* ============================
* HTML IMPORT (Records)
* ============================ */
function toast_(msg) {
try {
const t = document.createElement('div');
t.className = 'rtToast';
t.textContent = String(msg || '');
document.body.appendChild(t);
setTimeout(() => t.classList.add('show'), 10);
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 250); }, 2600);
} catch { }
}
function readFileText_(file) {
return new Promise((resolve, reject) => {
try {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || ''));
fr.onerror = () => reject(fr.error || new Error('read failed'));
fr.readAsText(file);
} catch (e) { reject(e); }
});
}
function hash32_(s) {
// FNV-1a 32-bit
s = String(s || '');
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
function makeBestRecId_(mode, track, car) {
return `best:${mode}:${hash32_(track)}:${hash32_(car)}`;
}
function updateBestRecord_(mode, track, car, ms, timeText, extra) {
if (!mode || !track || !car) return false;
if (!(typeof ms === 'number' && isFinite(ms) && ms > 0)) return false;
const id = makeBestRecId_(mode, track, car);
const prev = records.find(r => r && r.id === id);
if (prev && (prev.ms || 0) <= ms) return false; // existing is better or equal
// remove any older entries for this tuple (including legacy ids)
records = records.filter(r => !(r && r.mode === mode && r.track === track && r.car === car));
const atIso = (extra && extra.atIso) ? extra.atIso : bestRaceAtIso_();
records.push({
id,
track,
mode,
car,
carImg: (extra && extra.carImg) ? extra.carImg : '',
carClass: (extra && extra.carClass) ? extra.carClass : '',
driverName: (extra && extra.driverName) ? extra.driverName : '',
driverId: (extra && extra.driverId) ? extra.driverId : '',
raceId: (extra && extra.raceId) ? extra.raceId : (raceMeta?.raceId || ""),
timeText: timeText || msToTimeText_(ms, mode),
ms,
atIso
});
return true;
}
function importHtmlReportText_(htmlText, filename) {
try {
const doc = new DOMParser().parseFromString(String(htmlText || ''), 'text/html');
if (!doc || !doc.querySelector) return null;
const title = (doc.querySelector('title')?.textContent || '').trim();
if (!title.toLowerCase().includes('torn lap times')) return null;
const metaCarImg = doc.querySelector('meta[name="rt-car-img"]')?.getAttribute('content') || '';
const metaRaceAt = doc.querySelector('meta[name="rt-race-at"]')?.getAttribute('content') || '';
let raceId = (doc.querySelector('meta[name="rt-race-id"]')?.getAttribute('content') || '').trim();
// Track + Car
let track = '';
let car = '';
const tcEl = Array.from(doc.querySelectorAll('div')).find(d => {
const t = (d.textContent || '').replace(/\s+/g, ' ').trim();
return /Track:/i.test(t) && /Car:/i.test(t);
});
if (tcEl) {
const t = (tcEl.textContent || '').replace(/\s+/g, ' ').trim();
const m = t.match(/Track:\s*(.*?)\s*Car:\s*(.*?)(?:\s+Race:|\s+Driver:|\s+Generated:|$)/i);
if (m) { track = (m[1] || '').trim(); car = (m[2] || '').trim(); }
}
// Fallback from filename: "Commerce_-_15_laps.Honda_NSX_P3.html"
if ((!track || !car) && filename) {
const fn = String(filename);
const m = fn.match(/\.([^.]*)\.([^.]*)\./);
if (m && !track) track = (m[1] || '').replace(/_/g, ' ').trim();
if (m && !car) car = (m[2] || '').replace(/_/g, ' ').trim();
}
track = track || 'UnknownTrack';
car = car || 'UnknownCar';
// Race datetime -> ISO (best effort)
let atIso = '';
if (metaRaceAt) atIso = metaRaceAt;
const raceEl = Array.from(doc.querySelectorAll('div')).find(d => /Race:/i.test((d.textContent || '')));
if (!atIso && raceEl) {
const t = (raceEl.textContent || '').replace(/\s+/g, ' ').trim();
const m = t.match(/Race:\s*(\d{1,2})\/(\d{1,2})\/(\d{4}),\s*(\d{1,2}):(\d{2}):(\d{2})/);
if (m) {
const dd = parseInt(m[1], 10), mm = parseInt(m[2], 10), yyyy = parseInt(m[3], 10);
const HH = parseInt(m[4], 10), MI = parseInt(m[5], 10), SS = parseInt(m[6], 10);
const d = new Date(yyyy, mm - 1, dd, HH, MI, SS);
if (!isNaN(d.getTime())) atIso = d.toISOString();
}
}
if (!atIso) atIso = nowIso_();
if (!raceId) {
const raceLink = doc.querySelector('a[href*="raceID="], a[href*="raceId="], a[href*="raceid="]');
if (raceLink) {
try {
const u = new URL(raceLink.getAttribute('href') || '', location.href);
raceId = (u.searchParams.get('raceID') || u.searchParams.get('raceId') || u.searchParams.get('raceid') || '').trim();
} catch { }
}
}
if (!raceId) {
const raceIdEl = Array.from(doc.querySelectorAll('div')).find(d => /Race ID:/i.test((d.textContent || '')));
const m = raceIdEl ? String(raceIdEl.textContent || '').match(/Race ID:\s*(\d+)/i) : null;
if (m) raceId = m[1];
}
// Driver name from row title="Driver — Car"
let driverName = '';
const trAny = doc.querySelector('tbody tr[title]');
if (trAny) {
const t = String(trAny.getAttribute('title') || '').trim();
const parts = t.split('—').map(x => x.trim()).filter(Boolean);
if (parts.length) driverName = parts[0];
}
// Collect all chrono lap times
const chronoMs = [];
const rows = Array.from(doc.querySelectorAll('tbody tr'));
for (const tr of rows) {
const tds = tr.querySelectorAll('td');
if (!tds || tds.length < 3) continue;
const chronoTxt = String(tds[2].textContent || '').replace(/\s+/g, '').trim();
const ms = timeTextToMs_(chronoTxt);
if (ms && ms > 0) chronoMs.push(ms);
}
if (!chronoMs.length) return null;
// Best lap = fastest row (class fastest) else minimum chrono
let bestMs = 0;
const trFast = doc.querySelector('tbody tr.fastest');
if (trFast) {
const tds = trFast.querySelectorAll('td');
const chronoTxt = tds && tds[2] ? String(tds[2].textContent || '').replace(/\s+/g, '').trim() : '';
const ms = timeTextToMs_(chronoTxt);
if (ms && ms > 0) bestMs = ms;
}
if (!bestMs) bestMs = Math.min(...chronoMs);
const raceMs = chronoMs.reduce((a, b) => a + b, 0);
const extra = { atIso, carImg: metaCarImg || '', carClass: '', driverName, driverId: '', raceId };
const changedLap = updateBestRecord_('lap', track, car, bestMs, msToTimeText_(bestMs, 'lap'), extra);
const changedRace = updateBestRecord_('race', track, car, raceMs, msToTimeText_(raceMs, 'race'), extra);
return (changedLap || changedRace) ? true : false;
} catch (e) {
console.warn("[MoDuL's Lap Recorder] importHtmlReportText_ failed", e);
return null;
}
}
/* ============================
* RECORDING
* ============================ */
function maybeRecordLap0Baseline_() {
try {
ensureRaceMeta_();
if (!raceMeta || !raceMeta.metaKey) return;
const info = document.querySelector(SEL_INFOSPOT);
const txt = info ? String(info.textContent || "").trim().toLowerCase() : "";
const isReplay = isReplay_(); // replay controls present
const lapNow = (() => {
const el = document.querySelector(SEL_LAP_UI);
const t = el ? String(el.textContent || "").trim() : "";
const m = t.match(/^(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : 0;
})();
// When NOT in replay: require "started" wording.
// When in replay: infoSpot might be "Race replaying" or even "Race finished" while replay controls run,
// so allow baseline once playback has progressed (completion > 0 or lap counter > 0 or a last-lap time is visible).
const playbackHasProgressed =
(typeof completionPct === "number" && completionPct > 0) ||
(lapNow > 0) ||
!!readText_(SEL_LAST_UI);
if (!isReplay) {
if (!(txt.includes("started") || txt.includes("race started"))) return;
} else {
// For replay baseline, trigger immediately when Torn flips to "replaying" AND playback is actually running.
if (!(txt.includes("replaying") && isReplayPlaying_())) {
if (!playbackHasProgressed) return;
}
}
// Already captured baseline for this race?
if (startRecordedForMetaKey === raceMeta.metaKey && raceMeta.startAtIso) return;
const d = new Date();
raceMeta.startAtIso = d.toISOString();
startRecordedForMetaKey = raceMeta.metaKey;
saveRaceMeta_(raceMeta);
// Add a visible Lap 0 row so:
// - UI doesn't stay on "Waiting for lap times…"
// - Calc Chrono for Lap 1 has a baseline
const at0Iso = raceMeta.startAtIso;
const at0 = at0Iso.replace("T", " ").replace("Z", "");
const id0 = `${raceMeta.metaKey}|0|${at0}`;
const exists = laps.some(r => r && r.lap === 0 && lapBelongsToMeta_(r));
if (!exists) {
laps.push({
id: id0,
lap: 0,
position: "",
time: "", // "Last lap" blank for Lap 0
at: at0, // captured baseline time
atIso: at0Iso,
ms: null,
speed: getReplaySpeedFactor_(),
ui: "0",
track: raceMeta.track || "",
car: raceMeta.car || "",
raceDetectedAtIso: raceMeta.detectedAtIso || "",
driverName: currentDriverName_(),
driverCar: spectateCar || ""
});
saveLaps_();
}
uiDirty = true;
scheduleRender_();
} catch (e) {
console.warn("[MoDuL's Lap Recorder] baseline capture failed", e);
}
}
function recordLap_(lapNum, time, lapUi, positionText, opts = {}) {
ensureRaceMeta_();
const key = `${lapNum}|${time}|${lapUi || ""}|${raceMeta?.metaKey || ""}`;
if (key === lastRecordedKey) return;
lastRecordedKey = key;
refreshSpectate_();
const ms = timeTextToMs_(time);
const atIso = String(opts.atIso || "").trim() || nowIso_();
const capturedAt = atIso.replace("T", " ").replace("Z", "");
const id = `${raceMeta?.metaKey || "meta"}|${lapNum}|${capturedAt}`;
const speed = (typeof opts.speed === "number" && isFinite(opts.speed) && opts.speed > 0)
? opts.speed
: getReplaySpeedFactor_();
const pause = (typeof opts.pause === "number" && isFinite(opts.pause) && opts.pause >= 0)
? opts.pause
: (isReplay_() ? getReplayPauseAccumMs_() : 0);
laps.push({
id,
lap: lapNum,
position: String(positionText || "").trim(),
time,
at: capturedAt,
atIso: atIso,
ms,
speed,
pause,
ui: lapUi || "",
track: raceMeta?.track || "",
car: raceMeta?.car || "",
raceDetectedAtIso: raceMeta?.detectedAtIso || "",
driverName: currentDriverName_(),
driverCar: spectateCar || ""
});
saveLaps_();
uiDirty = true;
scheduleRender_();
}
function flushPending_(lapUiRaw, positionText) {
if (!recordingEnabled) return;
if (!raceHasStarted_()) return;
if (!hasFreshLastLapTime_()) return;
const pos = String(positionText || getPositionText_() || "").trim();
if (pendingCompletedTarget > lastLapCompleted) {
for (let l = lastLapCompleted + 1; l <= pendingCompletedTarget; l++) {
recordLap_(l, lastLapTimeSeen, lapUiRaw || "", pos);
}
lastLapCompleted = pendingCompletedTarget;
pendingCompletedTarget = 0;
markLastLapTimeUsed_();
}
if (pendingFinalLap && pendingFinalLap > 0) {
if (!hasFreshLastLapTime_()) return;
const finalCapture = deriveFinalLapCapture_(pendingFinalLap, lastLapTimeSeen) || {};
recordLap_(pendingFinalLap, lastLapTimeSeen, lapUiRaw || "", pos, finalCapture);
lastLapCompleted = Math.max(lastLapCompleted, pendingFinalLap);
pendingFinalLap = 0;
markLastLapTimeUsed_();
finishRaceRecording_();
}
}
function onLapUiChanged_() {
if (!recordingEnabled) return;
// Establish Lap 0 baseline as soon as the race actually starts moving (replays)
// so Lap 1 calculated time is sane.
maybeMarkRaceStarted_();
if (!raceHasStarted_()) return;
const { cur, raw } = parseLapUi_();
if (!cur) return;
const completed = Math.max(0, cur - 1);
if (completed <= lastLapCompleted) return;
pendingCompletedTarget = Math.max(pendingCompletedTarget || 0, completed);
flushPending_(raw, getPositionText_());
}
function updateSeenLastLap_(t) {
if (!noteLastLapTime_(t)) return;
maybeMarkRaceStarted_();
// Flush any lap changes that arrived before the Last lap text.
const { raw } = parseLapUi_();
flushPending_(raw, getPositionText_());
// finish check
maybeRecordFinalLapOnFinish_();
}
/* ============================
* OBSERVERS
* ============================ */
function hookObservers_() {
if (observersReady) return true;
const lapEl = document.querySelector(SEL_LAP_UI);
const lastEl = document.querySelector(SEL_LAST_UI);
if (!lapEl || !lastEl) return false;
new MutationObserver(() => updateSeenLastLap_((lastEl.textContent || "").trim()))
.observe(lastEl, { childList: true, subtree: true, characterData: true });
new MutationObserver(() => {
onLapUiChanged_();
maybeRecordFinalLapOnFinish_();
}).observe(lapEl, { childList: true, subtree: true, characterData: true });
const compEl = document.querySelector(SEL_COMP_UI);
if (compEl) {
new MutationObserver(() => {
({ pct: completionPct, time: completionFinishTime } = parseCompletionOrTime_());
maybeCaptureFinishMoment_({ pct: completionPct, time: completionFinishTime });
maybeMarkRaceStarted_();
maybeRecordFinalLapOnFinish_();
}).observe(compEl, { childList: true, subtree: true, characterData: true });
({ pct: completionPct, time: completionFinishTime } = parseCompletionOrTime_());
maybeCaptureFinishMoment_({ pct: completionPct, time: completionFinishTime });
}
const infoEl = document.querySelector(SEL_INFOSPOT);
if (infoEl) {
new MutationObserver(() => {
// Start baseline as soon as Torn flips "Race replaying"/"Race started".
maybeMarkRaceStarted_();
}).observe(infoEl, { childList: true, subtree: true, characterData: true });
}
// Replay play/pause button is the most reliable 'playing' signal.
hookReplayPlayObserver_();
hookReplaySpeedObserver_();
const lb = document.querySelector(SEL_LEADERBOARD);
if (lb) {
// Track spectated driver changes (li.selected moves)
new MutationObserver(() => refreshSpectate_())
.observe(lb, { attributes: true, childList: true, subtree: true, attributeFilter: ["class"] });
refreshSpectate_();
}
updateSeenLastLap_((lastEl.textContent || "").trim());
onLapUiChanged_();
maybeRecordFinalLapOnFinish_();
observersReady = true;
return true;
}
/* ============================
* UI
* ============================ */
function updateHeaderCompact_(win) {
try {
if (!win) return;
const hdr = win.querySelector("#rtHdr");
const left = hdr?.querySelector(".left");
const right = hdr?.querySelector(".right");
if (!hdr || !left || !right) return;
win.classList.remove("rtCompact");
const headerWidth = hdr.clientWidth || 0;
const buttons = Array.from(right.querySelectorAll(".pill"));
const buttonWidth = buttons.reduce((sum, btn) => sum + (btn.scrollWidth || btn.getBoundingClientRect().width || 0), 0);
const buttonGaps = Math.max(0, buttons.length - 1) * 8;
const titleMinWidth = 170;
const headerPaddingAndGap = 34;
const needsCompact = headerWidth > 0 && (titleMinWidth + buttonWidth + buttonGaps + headerPaddingAndGap > headerWidth);
win.classList.toggle("rtCompact", needsCompact);
} catch { }
}
function restorePositions_() {
const st = loadUiState_();
const btn = document.getElementById("rtLapBtn");
const win = document.getElementById("rtLapWin");
if (btn && st.btn && Number.isFinite(st.btn.left) && Number.isFinite(st.btn.top)) {
btn.style.right = "auto"; btn.style.bottom = "auto";
btn.style.left = `${st.btn.left}px`;
btn.style.top = `${st.btn.top}px`;
}
if (win && st.win && Number.isFinite(st.win.left) && Number.isFinite(st.win.top)) {
win.style.right = "auto"; win.style.bottom = "auto";
win.style.left = `${st.win.left}px`;
win.style.top = `${st.win.top}px`;
if (Number.isFinite(st.win.width) && Number.isFinite(st.win.height)) {
win.style.width = `${st.win.width}px`;
win.style.height = `${st.win.height}px`;
}
}
}
function openWin_() {
document.getElementById("rtLapWin").style.display = "block";
document.getElementById("rtLapBtn").style.display = "none";
saveWinOpen_(true);
}
function closeWin_() {
document.getElementById("rtLapWin").style.display = "none";
document.getElementById("rtLapBtn").style.display = "flex";
saveWinOpen_(false);
}
function clearLaps_(opts = {}) {
laps = [];
resetSeenLapTime_();
lastLapCompleted = 0;
lastRecordedKey = "";
finishRecordedForMetaKey = "";
pendingCompletedTarget = 0;
pendingFinalLap = 0;
completionPct = null;
completionFinishTime = "";
clearFinishCapture_();
replayIsPlaying = false;
replayPauseStartMs = null;
replayPauseAccumMs = 0;
replayEvents = [];
if (!opts.keepMeta) clearRaceMeta_();
saveLaps_();
uiDirty = true;
scheduleRender_();
}
function ensureUi_() {
if (!document.getElementById("rtLapBtn")) {
const b = document.createElement("div");
b.id = "rtLapBtn";
b.innerHTML = `
<span id="rtBtnDot" class="recDot"></span>
<b>Lap Recorder</b>
<span id="rtBtnCount" class="badge">(0)</span>
`;
b.addEventListener("click", (e) => {
if (b.dataset.dragging === "1") { e.preventDefault(); e.stopPropagation(); return; }
openWin_();
});
document.body.appendChild(b);
makeDraggable_(b, b, { storeKey: "btn", clickGuardAttr: "dragging", noDragSelector: null });
}
if (!document.getElementById("rtLapWin")) {
const w = document.createElement("div");
w.id = "rtLapWin";
w.innerHTML = `
<div id="rtHdr">
<div class="left">
<div style="display:flex;align-items:center;gap:10px">
<span class="title">Lap Recorder</span><span id="rtCount" class="count">(0)</span>
</div>
<div id="rtVer" class="muted" style="font-size:11px;opacity:.85">v${RT_LAP_REC_VERSION} <span style="opacity:.8">· Powered by <a href="/profiles.php?XID=4022159" style="color:inherit;text-decoration:underline;text-underline-offset:2px;">MoDuL</a></span></div>
</div>
<div class="right">
<button id="rtToggle" class="pill" title="Toggle recording">
<span id="rtRecDotInline"></span>
<span id="rtRecText">Recording</span>
</button>
<button id="rtTheme" class="pill" title="Cycle themes">
<span>🎨</span><span id="rtThemeLabel">Theme</span>
</button>
<button id="rtExportHtml" class="pill" title="Export HTML">
<span>🧾</span><span>HTML</span>
</button>
<button id="rtImportHtml" class="pill" title="Import Lap Recorder HTML exports into Records">
<span>📄</span><span>Import</span>
</button>
<button id="rtToggleRecords" class="pill" title="Show/hide per-track records">
<span>🏆</span><span>Records</span>
</button>
<button id="rtClear" class="pill" title="Clear laps (keeps REC state)">
<span>🧹</span><span>Clear</span>
</button>
<button id="rtAutoClear" class="pill" title="Toggle auto-clear when race/replay ID changes">
<span>🧼</span><span id="rtAutoClearText">Auto-clear ID</span>
</button>
<button id="rtClose" class="pill" title="Close">
<span>✕</span>
</button>
</div>
</div>
<div id="rtBody">
<div id="rtMeta">
<div class="rtMetaLeft">
<div class="rtMetaTop">
<div><span class="muted rtMetaLbl">Track:</span> <span id="rtTrack">—</span></div>
<div id="rtRaceIdWrap"><span class="muted rtMetaLbl">Race ID:</span> <a id="rtRaceId" href="#" target="_blank" rel="noopener noreferrer">—</a></div>
</div>
<div class="rtMetaLine">
<span class="muted rtMetaLbl">Car:</span>
<span class="rtMetaCar">
<img id="rtCarImg" class="carIcon" style="display:none" alt="" />
<span id="rtCar" class="carName">—</span>
</span>
<span class="muted" style="opacity:.85">Driver:</span> <span id="rtDriver">—</span>
</div>
</div>
<div class="rtMetaRight mono">
<div><span class="muted">Race:</span> <span id="rtRaceTime">—</span></div>
<div><span class="muted">Total:</span> <span id="rtRaceTotal">—</span></div>
</div>
</div>
<div id="rtCountdown" style="display:none"></div>
<div id="rtCalcNote" class="muted" style="font-size:11px;opacity:.85;display:none">Calc uses replay speed: <b id="rtSpeedLabel">1×</b>.</div>
<div id="rtEvents" class="mono muted" style="font-size:11px;opacity:.9;max-height:78px;overflow:auto;display:none;margin-top:6px"></div>
<div id="rtTableWrap">
<table id="rtTable">
<thead>
<tr>
<th>Lap</th>
<th>Pos</th>
<th>Chrono</th>
<th>Delta</th>
<th>Last lap</th>
<th></th>
</tr>
</thead>
<tbody id="rtTbody"></tbody>
</table>
</div>
<div id="rtRecords" class="recordsWrap" style="display:none">
<div class="recordsBar">
<div class="recordsLeft">
<span class="muted">Track:</span>
<select id="rtRecTrack" class="select"></select>
<span class="muted" style="margin-left:8px">Mode:</span>
<select id="rtRecMode" class="select">
<option value="lap">Best Lap</option>
<option value="race">Race Time</option>
</select>
</div>
<div class="recordsRight muted">Top 10 • unique cars</div>
</div>
<div class="recordsTable" id="rtRecScroll">
<table id="rtRecTable">
<thead>
<tr>
<th class="colNum">#</th>
<th class="colImg">Img</th>
<th class="colCar">Car</th>
<th class="colTime">Time</th>
<th class="colDriver">Driver</th>
<th class="colRace">Race</th>
<th class="colWhen">When</th>
<th class="colAct">Delete</th>
</tr>
</thead>
<tbody id="rtRecTbody"></tbody>
</table>
</div>
</div>
<input id="rtImportInput" type="file" accept="text/html,.html" multiple style="display:none" />
</div>
`;
document.body.appendChild(w);
const updateHeaderCompact = () => updateHeaderCompact_(w);
// Switch the header buttons to icon-only when the title and buttons no longer fit.
try {
const ro = new ResizeObserver(updateHeaderCompact);
ro.observe(w);
} catch (e) {
// ResizeObserver not supported (very old browsers) — ignore
}
updateHeaderCompact();
document.getElementById("rtClose").onclick = closeWin_;
document.getElementById("rtClear").onclick = () => {
clearLaps_();
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtToggle").onclick = () => {
recordingEnabled = !recordingEnabled;
saveRecEnabled_();
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtAutoClear").onclick = () => {
clearOnRaceChange = !clearOnRaceChange;
saveClearOnRaceChange_(clearOnRaceChange);
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtTheme").onclick = cycleTheme_;
document.getElementById("rtExportHtml").onclick = exportHtml_;
// Import HTML exports into Records
const impBtn = document.getElementById("rtImportHtml");
const impInput = document.getElementById("rtImportInput");
if (impBtn && impInput) {
impBtn.onclick = () => {
try { impInput.value = ""; } catch { }
impInput.click();
};
impInput.addEventListener("change", async () => {
const files = Array.from(impInput.files || []);
if (!files.length) return;
let ok = 0, skipped = 0, failed = 0;
for (const f of files) {
try {
const txt = await readFileText_(f);
const res = importHtmlReportText_(txt, f.name);
if (res === true) ok++;
else if (res === false) skipped++;
else failed++;
} catch {
failed++;
}
}
saveRecords_();
uiDirty = true;
scheduleRender_();
toast_(`Import complete: ${ok} added/updated, ${skipped} skipped, ${failed} failed.`);
});
}
document.getElementById("rtToggleRecords").onclick = () => {
recordsOpen = !recordsOpen;
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtRecMode").onchange = (e) => {
recordsMode = String(e.target.value || "lap");
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtRecTrack").onchange = (e) => {
recordsTrack = String(e.target.value || "");
uiDirty = true;
scheduleRender_();
};
document.getElementById("rtRecTbody").addEventListener("click", (e) => {
const btn = e.target.closest(".recDelBtn");
if (!btn) return;
const id = btn.getAttribute("data-id");
if (!id) return;
records = records.filter(r => r.id !== id);
saveRecords_();
uiDirty = true;
scheduleRender_();
});
document.getElementById("rtTbody").addEventListener("click", (e) => {
const btn = e.target.closest(".delBtn");
if (!btn) return;
const id = btn.getAttribute("data-id");
if (!id) return;
laps = laps.filter(r => r.id !== id);
refreshLastLapCompleted_();
saveLaps_();
uiDirty = true;
scheduleRender_();
});
makeDraggable_(w, w, { storeKey: "win", clickGuardAttr: null, noDragSelector: "button, a, input, textarea, select, .delBtn, td, th" });
observeResize_(w, "win");
}
restorePositions_();
applyTheme_();
const wasOpen = loadWinOpen_();
const win = document.getElementById("rtLapWin");
const btn = document.getElementById("rtLapBtn");
if (win && btn) {
win.style.display = wasOpen ? "block" : "none";
btn.style.display = wasOpen ? "none" : "flex";
}
uiDirty = true;
scheduleRender_();
}
/* ============================
* RENDER
* ============================ */
let lastRenderKey = "";
let renderScheduled = false;
function scheduleRender_() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
render_();
});
}
function render_() {
ensureRaceMeta_();
applyTheme_();
const btnDot = document.getElementById("rtBtnDot");
const btnCount = document.getElementById("rtBtnCount");
const count = document.getElementById("rtCount");
const recDot = document.getElementById("rtRecDotInline");
const recText = document.getElementById("rtRecText");
const autoClearBtn = document.getElementById("rtAutoClear");
if (autoClearBtn) {
autoClearBtn.onclick = () => {
clearOnRaceChange = !clearOnRaceChange;
saveClearOnRaceChange_(clearOnRaceChange);
uiDirty = true;
scheduleRender_();
};
}
const autoClearText = document.getElementById("rtAutoClearText");
const tb = document.getElementById("rtTbody");
const trackEl = document.getElementById("rtTrack");
const carEl = document.getElementById("rtCar");
const carImgEl = document.getElementById("rtCarImg");
const recWrap = document.getElementById("rtRecords");
const recTbody = document.getElementById("rtRecTbody");
const recTrackSel = document.getElementById("rtRecTrack");
const recModeSel = document.getElementById("rtRecMode");
const driverEl = document.getElementById("rtDriver");
const raceTimeEl = document.getElementById("rtRaceTime");
const raceTotalEl = document.getElementById("rtRaceTotal");
const raceIdEl = document.getElementById("rtRaceId");
const raceIdWrap = document.getElementById("rtRaceIdWrap");
const cdEl = document.getElementById("rtCountdown");
const raceLaps = currentRaceLaps_(true);
const shownLapCount = raceLaps.filter(r => (r.lap || 0) > 0).length;
if (btnDot) btnDot.className = recordingEnabled ? "recDot" : "recDot off";
if (btnCount) btnCount.textContent = `(${shownLapCount})`;
if (count) count.textContent = `(${shownLapCount})`;
if (autoClearBtn) autoClearBtn.classList.toggle("on", !!clearOnRaceChange);
if (autoClearText) autoClearText.textContent = clearOnRaceChange ? "Auto-clear ID: ON" : "Auto-clear ID: OFF";
if (recDot) recDot.className = recordingEnabled ? "" : "off";
if (recText) recText.textContent = recordingEnabled ? "Recording" : "Paused";
updateHeaderCompact_(document.getElementById("rtLapWin"));
if (trackEl) trackEl.textContent = raceMeta?.track || "—";
const selectedDriver = readSelectedDriver_();
const replayTargetName = String(playerName || "").trim();
const replayWaitingForTarget = isReplay_() && !!replayTargetName && !selectedDriver.name;
const replayNeedsSelection = isReplay_() && !selectedDriver.name && !replayTargetName;
const showReplayPrompt = replayWaitingForTarget || replayNeedsSelection;
if (carEl) {
carEl.textContent = showReplayPrompt
? (replayTargetName ? `Waiting for ${replayTargetName}` : "Select from replay list")
: (spectateCar || raceMeta?.car || "—");
}
if (carImgEl) {
const u = showReplayPrompt ? "" : (raceMeta?.carImg || findCarImageUrl_(raceMeta?.car) || spectateCarImg || "").trim();
if (u) { carImgEl.src = u; carImgEl.style.display = "inline-block"; }
else { carImgEl.removeAttribute("src"); carImgEl.style.display = "none"; }
}
if (driverEl) {
driverEl.textContent = showReplayPrompt
? (replayTargetName ? "Auto-selecting replay driver" : "Choose a driver to follow")
: (spectateName || playerName || raceMeta?.driver || "—");
}
if (raceIdEl) {
const rid = (raceMeta?.raceId || "").trim();
if (rid) {
const href = `https://www.torn.com/page.php?sid=racing&raceID=${encodeURIComponent(rid)}`;
raceIdEl.textContent = rid;
raceIdEl.href = href;
if (raceIdWrap) raceIdWrap.style.display = "";
} else {
if (raceIdWrap) raceIdWrap.style.display = "none";
}
}
if (raceTimeEl) {
if (!raceHasStarted_() && (raceMeta?.countdown || "")) {
const secs = parseCountdownToSeconds_(raceMeta.countdown);
const startAt = new Date(Date.now() + secs * 1000);
raceTimeEl.textContent = formatLocalDateTime_(startAt);
} else {
raceTimeEl.textContent = raceMeta?.detectedAtLocal || "—";
}
}
if (cdEl) {
const show = !raceHasStarted_() && !!(raceMeta?.countdown || "");
if (!show) {
cdEl.textContent = "";
cdEl.style.display = "none";
} else {
const secs = parseCountdownToSeconds_(raceMeta.countdown);
cdEl.textContent = `Race starts in: ${formatShortCountdown_(secs)}`;
cdEl.style.display = "block";
}
}
// Records panel (per track)
if (recWrap) {
recWrap.style.display = recordsOpen ? "" : "none";
}
if (recordsOpen && recTbody && recTrackSel && recModeSel) {
const trackNow = (raceMeta?.track || "").trim();
const tracks = getAllTrackNames_();
if (!recordsTrack && trackNow) recordsTrack = trackNow;
const optKey = tracks.join("||");
if (recTrackSel.dataset.key !== optKey) {
recTrackSel.innerHTML = tracks.map(t => `<option value="${escAttr_(t)}">${esc_(t)}</option>`).join("") || `<option value="">—</option>`;
recTrackSel.dataset.key = optKey;
}
if (recordsTrack && tracks.includes(recordsTrack)) recTrackSel.value = recordsTrack;
else if (tracks.length) { recordsTrack = tracks[0]; recTrackSel.value = recordsTrack; }
recModeSel.value = recordsMode;
const mode = recordsMode;
const track = recordsTrack || trackNow || "";
const rows = records
.filter(r => r && r.track === track && r.mode === mode)
.slice()
.sort((a, b) => (a.ms || 0) - (b.ms || 0));
const bestByCar = new Map();
for (const r of rows) {
const k = (r.car || "").trim();
if (!k) continue;
const prev = bestByCar.get(k);
if (!prev || (r.ms || 0) < (prev.ms || 0)) bestByCar.set(k, r);
}
const top = Array.from(bestByCar.values())
.sort((a, b) => (a.ms || 0) - (b.ms || 0))
.slice(0, 10);
if (!top.length) {
recTbody.innerHTML = `<tr><td colspan="8" class="muted">No records for this track yet.</td></tr>`;
} else {
recTbody.innerHTML = top.map((r, i) => {
const imgCell = r.carImg ? `<img class="carIcon" src="${escAttr_(r.carImg)}" alt="">` : `<div class="carIcon" aria-hidden="true"></div>`;
const drv = (r.driverName || r.driverId || "—");
const recRid = String(r.raceId || "").trim();
const raceCell = recRid ? `<a class="recRaceLink" href="https://www.torn.com/page.php?sid=racing&raceID=${encodeURIComponent(recRid)}" target="_blank" title="View Replay">${esc_(recRid)}</a>` : "";
return `<tr>
<td class="recNum">${i + 1}</td>
<td class="recImg">${imgCell}</td>
<td class="recCar"><span>${esc_(r.car || "—")}</span></td>
<td class="recTime">${esc_(r.timeText || "—")}</td>
<td class="recDrv">${esc_(drv)}</td>
<td class="recRace mono">${raceCell}</td>
<td class="recWhen">${esc_(fmtWhen_(r.atIso || r.at || ""))}</td>
<td class="recAct"><button class="recDelBtn" data-id="${escAttr_(r.id)}" title="Delete">🗑️</button></td>
</tr>`;
}).join("");
}
}
if (!tb) return;
const curKey = `${raceLaps.length}|${raceMeta?.metaKey || ""}|${recordingEnabled ? 1 : 0}|${theme}|${raceMeta?.raceId || ""}|${raceMeta?.totalTime || ""}`;
const needTable = uiDirty || (curKey !== lastRenderKey);
if (!shownLapCount) {
if (raceTotalEl) raceTotalEl.textContent = "—";
if (needTable) {
tb.innerHTML = raceIsFinished_()
? `<tr><td colspan="6" class="muted">Race finished — no laps captured (replay not played).</td></tr>`
: `<tr><td colspan="6" class="muted">Waiting for lap times…</td></tr>`;
}
lastRenderKey = curKey;
uiDirty = false;
return;
}
const fastestUiMs = Math.min(...raceLaps.map(r => r.ms).filter(ms => typeof ms === "number" && ms > 0));
const asc = raceLaps.slice().sort((a, b) => (a.lap || 0) - (b.lap || 0)).filter(r => (r.lap || 0) > 0);
const disp = asc.filter(r => (r.lap || 0) > 0); // hide Lap 0 baseline from display
const byLap = new Map(asc.map(r => [r.lap, r]));
// Calc lap time based on capture timestamps (centiseconds precision).
const calcByLap = new Map();
for (let i = 0; i < asc.length; i++) {
const cur = asc[i];
const prev = i > 0 ? asc[i - 1] : null;
// Lap 0 is the baseline start marker.
if ((cur.lap || 0) === 0) { calcByLap.set(0, 0); continue; }
calcByLap.set(cur.lap, computeLapChronoMs_(cur, prev));
}
const fastestCalcMs = Math.min(...[...calcByLap.values()].filter(v => typeof v === "number" && isFinite(v) && v > 0));
const fastestLap = (isFinite(fastestCalcMs) && fastestCalcMs > 0)
? Math.min(...[...calcByLap.entries()].filter(([, v]) => v === fastestCalcMs).map(([lap]) => lap))
: null;
// Cumulative (total) time up to each lap (using Chrono / calculated ms)
const cumByLap = new Map();
let cumMs = 0;
for (let i = 0; i < asc.length; i++) {
const lapN = asc[i].lap || 0;
if (lapN <= 0) continue;
const v = calcByLap.get(lapN);
if (typeof v === "number" && isFinite(v) && v > 0) cumMs += v;
cumByLap.set(lapN, cumMs);
}
const maxLapShown = disp.length ? Math.max(...disp.map(x => x.lap || 0)) : 0;
// Total shown under Race (no longer a column)
if (raceTotalEl) {
const tms = maxLapShown ? (cumByLap.get(maxLapShown) || 0) : 0;
raceTotalEl.textContent = tms > 0 ? msToCalcText_(tms) : "—";
}
const calcNote = document.getElementById("rtCalcNote");
if (calcNote) {
// Only show the 1× note when this looks like a replay.
const canCalc = asc.length > 1 && [...calcByLap.values()].some(v => typeof v === "number" && isFinite(v) && v > 0);
const show = (isReplay_() && canCalc);
calcNote.style.display = show ? "block" : "none";
if (show) {
const spd = getReplaySpeedFactor_();
const lbl = document.getElementById("rtSpeedLabel");
if (lbl) lbl.textContent = `${spd}×`;
}
}
const ev = document.getElementById("rtEvents");
if (ev) {
const showEv = (isReplay_() && replayEvents && replayEvents.length);
ev.style.display = showEv ? "block" : "none";
if (showEv) {
ev.innerHTML = replayEvents.slice().reverse().map(e =>
`<div>${escapeHtml_(e.text)} at <span class="mono">${escapeHtml_(e.at)}</span></div>`
).join("");
} else {
ev.textContent = "";
}
}
if (needTable) {
try {
tb.innerHTML = disp.slice().reverse().map(r => {
const prev = byLap.get((r.lap || 0) - 1);
const curCalc = calcByLap.get(r.lap);
const prevCalc = prev ? calcByLap.get(prev.lap) : null;
const deltaMs = (r.lap <= 1) ? 0 : ((typeof curCalc === "number" && typeof prevCalc === "number") ? (curCalc - prevCalc) : 0);
const isFastest = (fastestLap != null && r.lap === fastestLap);
const tip = [r.driverName, r.driverCar].filter(Boolean).join(" — ");
const tipAttr = tip ? ` title="${esc_(tip)}"` : "";
return `
<tr class="${isFastest ? "fastest" : ""}"${tipAttr}>
<td>${r.lap}</td>
<td>${esc_(r.position || "")}</td>
<td class="muted mono">${esc_(msToCalcText_(calcByLap.get(r.lap)))}${isFastest ? `<span class="flBadge">🏁FL</span>` : ""}</td>
<td class="${gapClass_(deltaMs)} mono ${isFastest ? "delta-best" : ""}">${esc_(msToDeltaText_(deltaMs))}</td>
<td>${esc_(r.time)}</td>
<td><button class="delBtn" data-id="${esc_(r.id)}" title="Delete this lap">🗑️</button></td>
</tr>`;
}).join("");
} catch (e) {
console.warn("[MoDuL's Lap Recorder] render table failed", e);
tb.innerHTML = `<tr><td colspan="6" class="muted">Render error — check console.</td></tr>`;
}
}
lastRenderKey = curKey;
uiDirty = false;
}
/* ============================
* DRAGGING
* ============================ */
function observeResize_(el, storeKey) {
if (!el || !storeKey || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
// Persist size (and current position) when the user resizes.
const st = loadUiState_();
const rect = el.getBoundingClientRect();
st[storeKey] = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
saveUiState_(st);
});
ro.observe(el);
}
function makeDraggable_(targetEl, handleEl, opts) {
const { storeKey, clickGuardAttr, noDragSelector } = opts || {};
let down = false, startX = 0, startY = 0, startL = 0, startT = 0;
let moved = false;
handleEl.addEventListener("mousedown", (e) => {
if (noDragSelector && e.target.closest(noDragSelector)) return;
down = true; moved = false;
const rect = targetEl.getBoundingClientRect();
// If user is grabbing the native resize handle, don't start a drag.
if (e.clientX > rect.right - 18 && e.clientY > rect.bottom - 18) { down = false; return; }
startX = e.clientX; startY = e.clientY;
startL = rect.left; startT = rect.top;
targetEl.style.right = "auto";
targetEl.style.bottom = "auto";
targetEl.style.left = `${startL}px`;
targetEl.style.top = `${startT}px`;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!down) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
targetEl.style.left = `${startL + dx}px`;
targetEl.style.top = `${startT + dy}px`;
if (clickGuardAttr) targetEl.dataset[clickGuardAttr] = "1";
});
window.addEventListener("mouseup", () => {
if (!down) return;
down = false;
if (storeKey) {
const st = loadUiState_();
const rect = targetEl.getBoundingClientRect();
st[storeKey] = { left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) };
saveUiState_(st);
}
if (clickGuardAttr) {
setTimeout(() => { targetEl.dataset[clickGuardAttr] = moved ? "1" : "0"; }, 0);
if (moved) setTimeout(() => { targetEl.dataset[clickGuardAttr] = "0"; }, 60);
}
});
}
/* ============================
* INIT
* ============================ */
async function init_() {
laps = loadLaps_();
records = loadRecords_();
recordingEnabled = loadRecEnabled_();
raceMeta = loadRaceMeta_();
theme = loadTheme_();
clearOnRaceChange = loadClearOnRaceChange_();
refreshLastLapCompleted_();
maybeResetOnRaceIdChange_();
laps = loadLaps_();
raceMeta = loadRaceMeta_();
refreshLastLapCompleted_();
ensureUi_();
ensurePlayer_();
refreshSpectate_();
for (let i = 0; i < 60; i++) {
if (hookObservers_()) break;
await new Promise(r => setTimeout(r, 250));
}
setInterval(() => {
maybeResetOnRaceIdChange_();
updateTabTitle_();
maybeMarkRaceStarted_();
ensurePlayer_();
refreshSpectate_();
if (!raceHasStarted_()) {
ensureRaceMeta_();
uiDirty = true;
scheduleRender_();
} else {
({ pct: completionPct, time: completionFinishTime } = parseCompletionOrTime_());
maybeCaptureFinishMoment_({ pct: completionPct, time: completionFinishTime });
maybeRecordFinalLapOnFinish_();
}
}, 1000);
}
function waitForRacingDom_(cb) {
const startedAt = Date.now();
const t = setInterval(() => {
if (document.querySelector("#racingdetails") || document.querySelector(SEL_INFOSPOT)) {
clearInterval(t);
cb();
} else if (Date.now() - startedAt > 30000) {
// Don't block forever; still try to init.
clearInterval(t);
cb();
}
}, 300);
}
waitForRacingDom_(init_);
})();