Add configurable manifest-driven race audio system
This commit is contained in:
@@ -262,7 +262,7 @@ export function renderTimingView(deps) {
|
||||
getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings,
|
||||
getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid,
|
||||
connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap,
|
||||
ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView,
|
||||
ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState, saveState, updateHeaderState, renderView,
|
||||
normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey,
|
||||
getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings,
|
||||
getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis,
|
||||
@@ -353,6 +353,10 @@ export function renderTimingView(deps) {
|
||||
<button id="stopSession" class="btn">${t("timing.stop")}</button>
|
||||
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
|
||||
</div>
|
||||
<div id="raceStartLights" class="race-start-lights" data-state="${escapeHtml(getStartLightState())}">
|
||||
${[1, 2, 3, 4, 5].map((light) => `<span data-start-light="${light}"></span>`).join("")}
|
||||
<strong>${escapeHtml(getStartLightState())}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body timing-session-summary">
|
||||
@@ -392,6 +396,15 @@ export function renderTimingView(deps) {
|
||||
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${t("timing.speaker_panel_hint")}</p>
|
||||
<div class="race-control-cues">
|
||||
<button class="btn btn-danger" type="button" data-race-audio-cue="track_call">${t("audio.track_call")}</button>
|
||||
<button class="btn" type="button" data-race-audio-cue="track_clear">${t("audio.track_clear")}</button>
|
||||
<button class="btn btn-danger" type="button" data-race-audio-cue="red_flag">${t("audio.red_flag")}</button>
|
||||
<button class="btn" type="button" data-race-audio-cue="race_paused">${t("audio.race_pause")}</button>
|
||||
<button class="btn" type="button" data-race-audio-cue="race_resumed">${t("audio.race_resume")}</button>
|
||||
<button class="btn" type="button" data-race-audio-cue="false_start">${t("audio.false_start")}</button>
|
||||
<button class="btn" type="button" data-race-audio-cue="marshal_warning">${t("audio.marshal_warning")}</button>
|
||||
</div>
|
||||
<div class="check-grid">
|
||||
${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue", { state, t })}
|
||||
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue", { state, t })}
|
||||
@@ -583,36 +596,57 @@ export function renderTimingView(deps) {
|
||||
|
||||
document.getElementById("startSession")?.addEventListener("click", () => {
|
||||
const session = getActiveSession();
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const startButton = document.getElementById("startSession");
|
||||
if (!session || startButton?.disabled) return;
|
||||
ensureAudioContext();
|
||||
|
||||
if (session.mode === "track") {
|
||||
const trackValidation = validateTrackSessionForStart(session);
|
||||
if (!trackValidation.ok) {
|
||||
alert(trackValidation.message);
|
||||
return;
|
||||
}
|
||||
if (!trackValidation.ok) { alert(trackValidation.message); return; }
|
||||
}
|
||||
|
||||
session.status = "running";
|
||||
session.startedAt = Date.now();
|
||||
session.endedAt = null;
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
setLastFinishAnnouncementSessionId(null);
|
||||
lastOverlayLeaderKeyBySession[session.id] = null;
|
||||
lastOverlayTop3BySession[session.id] = [];
|
||||
setOverlayEvents([]);
|
||||
ensureSessionResult(session.id);
|
||||
announceRaceStarted(session);
|
||||
saveState();
|
||||
updateHeaderState();
|
||||
renderView();
|
||||
if (startButton) startButton.disabled = true;
|
||||
const updateStartDisplay = (label, lights = 0) => {
|
||||
if (startButton && label) startButton.textContent = label;
|
||||
const panel = document.getElementById("raceStartLights");
|
||||
if (!panel) return;
|
||||
panel.dataset.state = label;
|
||||
panel.querySelector("strong")?.replaceChildren(document.createTextNode(label));
|
||||
panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights));
|
||||
};
|
||||
let committed = false;
|
||||
const commitStart = () => {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
session.status = "running";
|
||||
session.startedAt = Date.now();
|
||||
session.endedAt = null;
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
session.pausedAt = null;
|
||||
session.totalPausedMs = 0;
|
||||
session.followUpPausedMs = 0;
|
||||
session.audioAnnouncements = [];
|
||||
setLastFinishAnnouncementSessionId(null);
|
||||
lastOverlayLeaderKeyBySession[session.id] = null;
|
||||
lastOverlayTop3BySession[session.id] = [];
|
||||
setOverlayEvents([]);
|
||||
ensureSessionResult(session.id);
|
||||
announceRaceStarted(session);
|
||||
saveState();
|
||||
updateHeaderState();
|
||||
renderView();
|
||||
};
|
||||
startSessionWithAudio(session, {
|
||||
onCountdown: (seconds) => updateStartDisplay(String(seconds), 0),
|
||||
onLight: (light) => updateStartDisplay("LIGHT_" + light, light),
|
||||
onArmed: () => updateStartDisplay("WAIT_RANDOM", 5),
|
||||
onTone: commitStart,
|
||||
}).then((started) => {
|
||||
if (started === false && startButton) { startButton.disabled = false; updateStartDisplay(t("timing.start"), 0); }
|
||||
}).catch(() => commitStart());
|
||||
});
|
||||
|
||||
document.getElementById("stopSession")?.addEventListener("click", () => {
|
||||
cancelRaceStart();
|
||||
const session = getActiveSession();
|
||||
if (!session) {
|
||||
return;
|
||||
@@ -646,6 +680,33 @@ export function renderTimingView(deps) {
|
||||
renderView();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-race-audio-cue]").forEach((node) => {
|
||||
node.addEventListener("click", () => {
|
||||
const cue = node.dataset.raceAudioCue;
|
||||
const session = getActiveSession();
|
||||
if (cue === "false_start") {
|
||||
cancelRaceStart();
|
||||
const startButton = document.getElementById("startSession");
|
||||
if (startButton) { startButton.disabled = false; startButton.textContent = t("timing.start"); }
|
||||
} else if (cue === "race_paused" && session?.status === "running") {
|
||||
session.status = "paused";
|
||||
session.pausedAt = Date.now();
|
||||
saveState();
|
||||
} else if (cue === "race_resumed" && session?.status === "paused") {
|
||||
const pausedDuration = Math.max(0, Date.now() - Number(session.pausedAt || Date.now()));
|
||||
session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration;
|
||||
if (session.followUpStartedAt) session.followUpPausedMs = Math.max(0, Number(session.followUpPausedMs || 0)) + pausedDuration;
|
||||
session.pausedAt = null;
|
||||
session.status = "running";
|
||||
saveState();
|
||||
}
|
||||
triggerRaceAudio(cue, { interrupt: true, force: true, logData: { raceId: session?.eventId, sessionId: session?.id } });
|
||||
// Persist after dispatch so the audio-event log includes this manual race-control call.
|
||||
saveState();
|
||||
if (["race_paused", "race_resumed"].includes(cue)) { updateHeaderState(); renderView(); }
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
|
||||
node.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
|
||||
Reference in New Issue
Block a user