diff --git a/README.md b/README.md index d00ab97..a56eb3d 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Recommended deployment: - shared start/finish/pause logic with the existing Timing view - automatic consistent SQLite backup before every session start, full data import and data clear operation - latest 30 server backups listed under `Settings -> Storage` + - isolated session replay in `Timing` with play, pause, reset, and 1x/2x/5x/10x speed; source results are never modified - Storage/import tools: - full race-data export/import for backup or moving to another machine - directory export/import for only classes, drivers and cars diff --git a/README.sv.md b/README.sv.md index 6380de0..0350e36 100644 --- a/README.sv.md +++ b/README.sv.md @@ -120,6 +120,7 @@ Rekommenderad deploy: - samma start-, stopp- och pauslogik som den befintliga Tidtagning-vyn - automatisk konsistent SQLite-backup före varje sessionstart, full dataimport och datarensning - de 30 senaste serverbackuperna visas under `Inställningar -> Lagring` + - isolerad session replay i `Tidtagning` med spela, pausa, reset och 1x/2x/5x/10x; källresultatet ändras aldrig - Lagring/import: - full export/import av race-data för backup eller flytt till annan maskin - register-export/import för bara klasser, förare och bilar diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index cd6ea32..f840875 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -449,6 +449,21 @@ Owns: - side panels - overlay leaderboard rows +### `src/session_replay.js` +Type: +- pure transient replay logic + +Owns: +- normalization and chronological sorting of saved session passings +- deterministic play, pause, speed, finish, and reset state +- time-sliced replay leaderboard snapshots for race and practice ranking +- strict isolation from `state.resultsBySession`, SQLite, decoder ingestion, and training ledger mutations + +Imported by: +- `src/app.js` for the single transient replay runtime +- `src/timing_views.js` for controls and incremental rendering +- `tests/session_replay.test.js` + ### `src/timing_views.js` Type: - timing/judging renderers @@ -1188,7 +1203,8 @@ sequenceDiagram | `src/event_manager_view.js` | view | none | `app.js` | yes | no | no | | `src/event_manager_controller.js` | controller | none | `app.js` | yes | no | yes | | `src/overlays.js` | view/render | none | `app.js` | yes | no | no | -| `src/timing_views.js` | view/render | none | `app.js` | yes | no | no | +| `src/session_replay.js` | pure replay logic | none | `app.js`, `timing_views.js`, tests | no | timer timestamps only | no | +| `src/timing_views.js` | view/render | `session_replay.js` | `app.js` | yes | replay timer only | no | | `src/core_views.js` | view/render | none | `app.js` | yes | no | no | | `src/misc_views.js` | view/render | none | `app.js` | yes | no | no | | `src/settings.js` | view/render | none | `app.js` | yes | no | no | diff --git a/src/app.js b/src/app.js index 566a0a6..fac7a1a 100644 --- a/src/app.js +++ b/src/app.js @@ -27,6 +27,7 @@ import { importDriversCsv, importCarsCsv } from "./csv_import.js"; import { renderTimingView, renderJudgingView } from "./timing_views.js"; +import { createSessionReplayRuntime } from "./session_replay.js"; import { renderTeamStintLog as renderTeamStintLogHelper, renderSessionChannelAnalysis as renderSessionChannelAnalysisHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js"; @@ -298,7 +299,7 @@ const refreshTimingDynamics = () => { node.textContent = clock; }); }; -const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); +const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, sessionReplay }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { @@ -1094,6 +1095,24 @@ const TRANSLATIONS = { "timing.connect": "Anslut decoder", "timing.disconnect": "Koppla ner", "timing.simulate": "Simulera passering", + "timing.session_replay": "Session replay", + "timing.session_replay_hint": "Spela upp sparade passeringar utan decoder. Originalsession, resultat och träningsstatistik ändras aldrig.", + "timing.replay_isolated": "Isolerat testläge", + "timing.replay_select": "Välj sparad session", + "timing.replay_load": "Ladda replay", + "timing.replay_play": "Spela", + "timing.replay_pause": "Pausa", + "timing.replay_reset": "Till start", + "timing.replay_speed": "Hastighet", + "timing.replay_latest": "Senaste passering", + "timing.replay_load_hint": "Välj en session och ladda dess passeringar.", + "timing.replay_empty": "Inga sparade sessioner med passeringar finns.", + "timing.replay_no_visible_passings": "Replay har inte nått någon passering ännu.", + "timing.replay_status_idle": "Ej laddad", + "timing.replay_status_empty": "Tom", + "timing.replay_status_paused": "Pausad", + "timing.replay_status_running": "Spelar", + "timing.replay_status_finished": "Klar", "timing.status": "Status", "timing.connected": "Ansluten", "timing.disconnected": "Frånkopplad", @@ -1573,6 +1592,7 @@ const TRANSLATIONS = { "guide.race_day_4": "4. Gula kontroller är varningar. Valfria split-/depådecoders, backend och ljud visas tydligt men ändrar inte vanlig varvräkning.", "guide.race_day_5": "5. Vid start synkas state till backend och en konsistent SQLite-backup skapas i data/backups innan den befintliga audio- och starttonssekvensen körs. De 30 senaste sparas.", "guide.race_day_6": "6. Använd Race Day för start, paus, fortsätt och avslut. Tidtagning, Domare och Overlay finns som direkta genvägar. Lokal restore kräver stoppad server och npm run backup:restore -- --confirm.", + "guide.race_day_7": "7. Session replay i Tidtagning spelar upp sparade passeringar i ett isolerat testläge. Välj session, ladda och kör i 1x, 2x, 5x eller 10x utan att ändra originalresultat eller träningsstatistik.", "guide.dashboard_title": "Schemaavvikelse på Översikt", "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", @@ -2440,6 +2460,24 @@ const TRANSLATIONS = { "timing.connect": "Connect Decoder", "timing.disconnect": "Disconnect", "timing.simulate": "Simulate Passing", + "timing.session_replay": "Session replay", + "timing.session_replay_hint": "Replay saved passings without a decoder. The source session, results, and training statistics are never changed.", + "timing.replay_isolated": "Isolated test mode", + "timing.replay_select": "Select saved session", + "timing.replay_load": "Load replay", + "timing.replay_play": "Play", + "timing.replay_pause": "Pause", + "timing.replay_reset": "Back to start", + "timing.replay_speed": "Speed", + "timing.replay_latest": "Latest passing", + "timing.replay_load_hint": "Select a session and load its passings.", + "timing.replay_empty": "No saved sessions with passings are available.", + "timing.replay_no_visible_passings": "Replay has not reached a passing yet.", + "timing.replay_status_idle": "Not loaded", + "timing.replay_status_empty": "Empty", + "timing.replay_status_paused": "Paused", + "timing.replay_status_running": "Playing", + "timing.replay_status_finished": "Finished", "timing.status": "Status", "timing.connected": "Connected", "timing.disconnected": "Disconnected", @@ -2919,6 +2957,7 @@ const TRANSLATIONS = { "guide.race_day_4": "4. Yellow checks are warnings. Optional split/pit decoders, backend, and audio are visible but do not change normal lap counting.", "guide.race_day_5": "5. Starting synchronizes state to the backend and creates a consistent SQLite backup in data/backups before the existing audio and start-tone sequence runs. The latest 30 are retained.", "guide.race_day_6": "6. Use Race Day for start, pause, resume, and finish. Timing, Judging, and Overlay remain one click away. Local restore requires a stopped server and npm run backup:restore -- --confirm.", + "guide.race_day_7": "7. Session replay in Timing replays saved passings in an isolated test mode. Select a session, load it, and run at 1x, 2x, 5x, or 10x without modifying source results or training statistics.", "guide.dashboard_title": "Schedule drift on overview", "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", @@ -3061,6 +3100,7 @@ const overlayViewMode = allowedOverlayModes.includes(routeOverlayMode) : "leaderboard"; const publicOverlayMode = Boolean(publicOverlayMatch); const state = loadState(); +const sessionReplay = createSessionReplayRuntime(); const requestedView = String(urlParams.get("view") || "").toLowerCase(); let currentView = overlayMode ? "overlay" : NAV_ITEMS.some((item) => item.id === requestedView) ? requestedView : "dashboard"; let wsClients = { main: null, split: null, pit_entry: null, pit_exit: null }; diff --git a/src/misc_views.js b/src/misc_views.js index a253161..5630248 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -39,7 +39,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6"], "", { t })} + ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7"], "", { t })}
diff --git a/src/session_replay.js b/src/session_replay.js new file mode 100644 index 0000000..111879b --- /dev/null +++ b/src/session_replay.js @@ -0,0 +1,143 @@ +const PRACTICE_TYPES = new Set(["open_practice", "free_practice", "practice", "qualification"]); + +export function normalizeSessionReplayPassings(passings) { + return (Array.isArray(passings) ? passings : []) + .filter((passing) => Number.isFinite(Number(passing?.timestamp))) + .map((passing, index) => ({ + ...passing, + replayIndex: index, + timestamp: Number(passing.timestamp), + lapMs: Math.max(0, Number(passing.lapMs || 0) || 0), + transponder: String(passing.transponder || ""), + competitorKey: String(passing.competitorKey || passing.driverId || passing.teamId || passing.transponder || `passing_${index}`), + driverName: String(passing.displayName || passing.teamName || passing.driverName || passing.transponder || "Unknown"), + carName: String(passing.subLabel || passing.carName || ""), + })) + .sort((left, right) => left.timestamp - right.timestamp || left.replayIndex - right.replayIndex); +} + +export function createSessionReplayRuntime() { + return { + sessionId: "", + sessionType: "", + sessionName: "", + status: "idle", + speed: 1, + elapsedMs: 0, + durationMs: 0, + startedWallAt: 0, + startedElapsedMs: 0, + passings: [], + timer: null, + }; +} + +export function loadSessionReplay(runtime, session, result) { + const passings = normalizeSessionReplayPassings(result?.passings); + runtime.sessionId = String(session?.id || ""); + runtime.sessionType = String(session?.type || ""); + runtime.sessionName = String(session?.name || ""); + runtime.status = passings.length ? "paused" : "empty"; + runtime.elapsedMs = 0; + runtime.durationMs = passings.length > 1 ? Math.max(0, passings[passings.length - 1].timestamp - passings[0].timestamp) : 0; + runtime.startedWallAt = 0; + runtime.startedElapsedMs = 0; + runtime.passings = passings; + return runtime; +} + +export function playSessionReplay(runtime, nowTs = Date.now()) { + if (!runtime.passings.length) return false; + if (runtime.elapsedMs >= runtime.durationMs && runtime.durationMs > 0) runtime.elapsedMs = 0; + runtime.status = "running"; + runtime.startedWallAt = nowTs; + runtime.startedElapsedMs = runtime.elapsedMs; + return true; +} + +export function pauseSessionReplay(runtime, nowTs = Date.now()) { + if (runtime.status !== "running") return false; + advanceSessionReplay(runtime, nowTs); + runtime.status = "paused"; + return true; +} + +export function resetSessionReplay(runtime) { + runtime.status = runtime.passings.length ? "paused" : "idle"; + runtime.elapsedMs = 0; + runtime.startedWallAt = 0; + runtime.startedElapsedMs = 0; + return runtime; +} + +export function setSessionReplaySpeed(runtime, speed, nowTs = Date.now()) { + if (runtime.status === "running") { + advanceSessionReplay(runtime, nowTs); + runtime.startedWallAt = nowTs; + runtime.startedElapsedMs = runtime.elapsedMs; + } + runtime.speed = [1, 2, 5, 10].includes(Number(speed)) ? Number(speed) : 1; + return runtime.speed; +} + +export function advanceSessionReplay(runtime, nowTs = Date.now()) { + if (runtime.status !== "running") return runtime.elapsedMs; + const advanced = runtime.startedElapsedMs + Math.max(0, nowTs - runtime.startedWallAt) * runtime.speed; + runtime.elapsedMs = Math.min(runtime.durationMs, advanced); + if (runtime.elapsedMs >= runtime.durationMs) runtime.status = "finished"; + return runtime.elapsedMs; +} + +export function buildSessionReplaySnapshot(runtime) { + const firstTimestamp = runtime.passings[0]?.timestamp || 0; + const cutoff = firstTimestamp + Math.max(0, runtime.elapsedMs); + const visiblePassings = runtime.passings.filter((passing, index) => index === 0 || passing.timestamp <= cutoff); + const competitors = new Map(); + + visiblePassings.forEach((passing) => { + const key = passing.competitorKey; + const row = competitors.get(key) || { + key, + driverName: passing.driverName, + carName: passing.carName, + transponder: passing.transponder, + laps: 0, + bestLapMs: 0, + lastLapMs: 0, + firstTimestamp: passing.timestamp, + lastTimestamp: passing.timestamp, + }; + row.driverName = passing.driverName || row.driverName; + row.carName = passing.carName || row.carName; + row.transponder = passing.transponder || row.transponder; + row.lastTimestamp = passing.timestamp; + if (passing.validLap !== false && passing.lapMs > 0) { + row.laps += 1; + row.lastLapMs = passing.lapMs; + row.bestLapMs = row.bestLapMs ? Math.min(row.bestLapMs, passing.lapMs) : passing.lapMs; + } + competitors.set(key, row); + }); + + const practiceRanking = PRACTICE_TYPES.has(runtime.sessionType); + const leaderboard = [...competitors.values()] + .map((row) => ({ ...row, totalMs: Math.max(0, row.lastTimestamp - row.firstTimestamp) })) + .sort((left, right) => { + if (practiceRanking) { + return (left.bestLapMs || Number.MAX_SAFE_INTEGER) - (right.bestLapMs || Number.MAX_SAFE_INTEGER) + || right.laps - left.laps + || left.driverName.localeCompare(right.driverName); + } + return right.laps - left.laps || left.totalMs - right.totalMs || left.driverName.localeCompare(right.driverName); + }); + + return { + status: runtime.status, + elapsedMs: runtime.elapsedMs, + durationMs: runtime.durationMs, + progress: runtime.durationMs ? Math.min(1, runtime.elapsedMs / runtime.durationMs) : visiblePassings.length ? 1 : 0, + visiblePassings, + latestPassing: visiblePassings[visiblePassings.length - 1] || null, + leaderboard, + }; +} diff --git a/src/styles.css b/src/styles.css index 6665ce5..3967174 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3943,3 +3943,23 @@ select:focus { .training-profile-qr img { width: min(220px, 70vw); aspect-ratio: 1; padding: 0.5rem; border-radius: 12px; background: #fff; } .training-profile-qr small { color: var(--muted); overflow-wrap: anywhere; } .training-profile-qr-hint { display: block; color: var(--muted); margin-bottom: 0.8rem; } + +.session-replay-controls { display: grid; grid-template-columns: minmax(240px, 1fr) repeat(4, auto) minmax(110px, auto); gap: 8px; align-items: end; } +.session-replay-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 0.72rem; } +.session-replay-progress { display: grid; grid-template-columns: minmax(180px, 1fr) auto auto; gap: 12px; align-items: center; margin: 14px 0 10px; } +.session-replay-progress progress { width: 100%; height: 10px; accent-color: var(--accent); } +.session-replay-latest { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-card); } +.session-replay-latest span, +.session-replay-latest small { color: var(--muted); } +.session-replay-panel .table-wrap { max-height: 390px; } + +@media (max-width: 980px) { + .session-replay-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .session-replay-controls > select { grid-column: 1 / -1; } +} + +@media (max-width: 620px) { + .session-replay-controls { grid-template-columns: 1fr 1fr; } + .session-replay-progress { grid-template-columns: 1fr auto; } + .session-replay-progress progress { grid-column: 1 / -1; } +} diff --git a/src/timing_views.js b/src/timing_views.js index a69c361..1d5145c 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -1,3 +1,89 @@ +import { + advanceSessionReplay, + buildSessionReplaySnapshot, + loadSessionReplay, + pauseSessionReplay, + playSessionReplay, + resetSessionReplay, + setSessionReplaySpeed, +} from "./session_replay.js"; + +function stopSessionReplayTimer(runtime) { + if (runtime?.timer) clearInterval(runtime.timer); + if (runtime) runtime.timer = null; +} + +function renderSessionReplayContent(runtime, { t, escapeHtml, formatLap, formatElapsedClock }) { + const snapshot = buildSessionReplaySnapshot(runtime); + const rows = snapshot.leaderboard.slice(0, 12); + return ` +
+ + ${formatElapsedClock(snapshot.elapsedMs)} / ${formatElapsedClock(snapshot.durationMs)} + ${escapeHtml(t(`timing.replay_status_${snapshot.status}`))} +
+ ${snapshot.latestPassing + ? `

${t("timing.replay_latest")}${escapeHtml(snapshot.latestPassing.driverName)}${formatLap(snapshot.latestPassing.lapMs)}

` + : `

${t("timing.replay_no_visible_passings")}

`} + ${rows.length + ? `
+ ${rows.map((row, index) => ``).join("")} +
${t("table.pos")}${t("table.driver")}${t("table.laps")}${t("table.last_lap")}${t("table.best_lap")}
${index + 1}${escapeHtml(row.driverName)}${escapeHtml(row.carName || row.transponder)}${row.laps}${formatLap(row.lastLapMs)}${formatLap(row.bestLapMs)}
` + : ""} + `; +} + +function updateSessionReplayPanel(runtime, deps) { + const content = document.querySelector("[data-session-replay-content]"); + if (content) content.innerHTML = renderSessionReplayContent(runtime, deps); +} + +function startSessionReplayTimer(runtime, deps) { + stopSessionReplayTimer(runtime); + runtime.timer = setInterval(() => { + advanceSessionReplay(runtime); + updateSessionReplayPanel(runtime, deps); + if (runtime.status !== "running") stopSessionReplayTimer(runtime); + }, 100); +} + +function renderSessionReplayPanel(runtime, deps) { + const { state, t, escapeHtml, getEventName, formatLap, formatElapsedClock } = deps; + if (runtime.status === "running") advanceSessionReplay(runtime); + const replayable = state.sessions.filter((session) => state.resultsBySession?.[session.id]?.passings?.length); + const selectedId = runtime.sessionId || replayable[0]?.id || ""; + return ` +
+
+

${t("timing.session_replay")}

${t("timing.session_replay_hint")}

+ ${t("timing.replay_isolated")} +
+
+
+ + + + + + +
+
+ ${runtime.passings.length + ? renderSessionReplayContent(runtime, { t, escapeHtml, formatLap, formatElapsedClock }) + : `

${t(replayable.length ? "timing.replay_load_hint" : "timing.replay_empty")}

`} +
+
+
+ `; +} + function renderSpeakerToggle(settingKey, labelKey, { state, t }) { return `