feat(drivers): test a driver's spoken name and keep search focus while typing

Adds a Test pronunciation button (forms and list) that plays the
passing sound and real announcements with the spoken name through race
audio. Fixes the Drivers/Cars search losing focus after one letter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-08 08:35:47 +02:00
parent a13c9a15f9
commit f65e526948
8 changed files with 136 additions and 6 deletions

View File

@@ -400,7 +400,17 @@ const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessa
}, decoderRole);
const renderDashboard = () => renderDashboardView({ addTrackLayout, state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml });
const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell });
const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName });
// Driver page "Test pronunciation": the passing sound, then two real announcements with the
// spoken name, through race audio (so the chosen voice / Piper is what you hear).
const testDriverVoice = ({ spokenName, passSound }) => {
if (!spokenName) return;
ensureAudioContext();
triggerRaceAudio("transponder_pass", { force: true, effectPath: passSound || "" });
const variables = { driverName: spokenName, lapTime: formatLap(16420), podium: spokenName };
triggerRaceAudio("personal_best", { force: true, interrupt: true, variables });
triggerRaceAudio("race_winner", { force: true, variables });
};
const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName , testDriverVoice });
const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar });
const renderGuide = () => renderGuideView({ dom, t });
const renderRaceDay = () => renderRaceDayView({
@@ -1030,8 +1040,9 @@ const TRANSLATIONS = {
"drivers.transponder_placeholder": "Personlig transponder (valfritt)",
"drivers.transponder_conflict_confirm": "Transpondern {tp} används redan av {name} ({class}). Spara ändå?",
"drivers.speech_name": "Uttalsnamn",
"drivers.voice_test": "🔊 Testa uttal",
"drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)",
"drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.",
"drivers.speech_name_hint": "Skriv namnet som rösten ska uttala det, eller ett smeknamn. Lämna tomt för vanligt förarnamn. Testa med 🔊.",
"drivers.training_profile": "Träningsprofil",
"drivers.training_profile_hint": "Länka flera klass- och transponderposter för samma person till en gemensam träningsprofil.",
"drivers.training_profile_auto": "Automatiskt: samma namn eller ny profil",
@@ -2694,8 +2705,9 @@ const TRANSLATIONS = {
"drivers.transponder_placeholder": "Personal transponder (optional)",
"drivers.transponder_conflict_confirm": "Transponder {tp} is already used by {name} ({class}). Save anyway?",
"drivers.speech_name": "Spoken name",
"drivers.voice_test": "🔊 Test pronunciation",
"drivers.speech_name_placeholder": "Pronunciation for announcements (optional)",
"drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.",
"drivers.speech_name_hint": "Enter the name as the voice should pronounce it, or a nickname. Leave blank to use the regular driver name. Try it with 🔊.",
"drivers.training_profile": "Practice profile",
"drivers.training_profile_hint": "Link multiple class and transponder entries for the same person to one shared practice profile.",
"drivers.training_profile_auto": "Automatic: same name or new profile",

View File

@@ -375,6 +375,7 @@ export function renderDriversView(deps) {
bindModalShell,
normalizeDriver,
getClassName,
testDriverVoice = () => {},
} = deps;
const classOptions = state.classes.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join("");
const driverSearch = driverBrandFilter().trim().toLowerCase();
@@ -408,6 +409,7 @@ export function renderDriversView(deps) {
<input name="brand" placeholder="${t("drivers.brand_placeholder")}" />
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
<input name="speechName" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
<button class="btn" type="button" data-driver-voice-test>${t("drivers.voice_test")}</button>
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions()}</select></label>
<input name="club" list="driverClubOptions" placeholder="${t("drivers.club_placeholder")}" />
<input name="country" list="driverCountryOptions" placeholder="${t("drivers.country_placeholder")}" />
@@ -444,6 +446,7 @@ export function renderDriversView(deps) {
<td>${escapeHtml(profile?.representative?.name || d.name)}${profile?.drivers.length > 1 ? ` · ${profile.drivers.length}` : ""}</td>
<td>${escapeHtml(d.speechName || d.name)}</td>
<td class="actions-inline">
<button class="btn" type="button" data-driver-voice-row="${escapeHtml(d.id)}" title="${escapeHtml(t("drivers.voice_test"))}">🔊</button>
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
@@ -474,6 +477,7 @@ export function renderDriversView(deps) {
<input name="brand" value="${escapeHtml(editingDriver.brand || "")}" placeholder="${t("drivers.brand_placeholder")}" />
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
<input name="speechName" value="${escapeHtml(editingDriver.speechName || "")}" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
<button class="btn" type="button" data-driver-voice-test>${t("drivers.voice_test")}</button>
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions(editingDriver.passSound)}</select></label>
<input name="club" list="driverClubOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.club || editingDriver.club || "")}" placeholder="${t("drivers.club_placeholder")}" />
<input name="country" list="driverCountryOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.country || editingDriver.country || "")}" placeholder="${t("drivers.country_placeholder")}" />
@@ -536,13 +540,33 @@ export function renderDriversView(deps) {
renderView();
});
// Hear the spoken name as race audio will say it, before saving: the form's current
// spoken name (or name) and passing sound.
dom.view.querySelectorAll("[data-driver-voice-test]").forEach((button) => button.addEventListener("click", () => {
const form = button.closest("form");
const value = (name) => String(form?.elements.namedItem(name)?.value || "").trim();
testDriverVoice({ spokenName: value("speechName") || value("name"), passSound: value("passSound") });
}));
dom.view.querySelectorAll("[data-driver-voice-row]").forEach((button) => button.addEventListener("click", () => {
const driver = state.drivers.find((item) => item.id === button.dataset.driverVoiceRow);
if (driver) testDriverVoice({ spokenName: driver.speechName || driver.name, passSound: driver.passSound || "" });
}));
document.getElementById("driverBrandFilter")?.addEventListener("input", (event) => {
const input = event.currentTarget;
if (!(input instanceof HTMLInputElement)) {
return;
}
// A full re-render replaces this input, which dropped focus after the first letter -
// put focus and the cursor back so a whole name can be typed (same as the archive search).
const caret = input.selectionStart;
setDriverBrandFilter(input.value);
renderDriversView(deps);
const restored = document.getElementById("driverBrandFilter");
if (restored instanceof HTMLInputElement) {
restored.focus();
restored.setSelectionRange(caret, caret);
}
});
const bindProfileMetadataFill = (formId) => {
@@ -785,8 +809,16 @@ export function renderCarsView(deps) {
if (!(input instanceof HTMLInputElement)) {
return;
}
// A full re-render replaces this input, which dropped focus after the first letter -
// put focus and the cursor back so a whole name can be typed (same as the archive search).
const caret = input.selectionStart;
setCarBrandFilter(input.value);
renderCarsView(deps);
const restored = document.getElementById("carBrandFilter");
if (restored instanceof HTMLInputElement) {
restored.focus();
restored.setSelectionRange(caret, caret);
}
});
state.cars.forEach((c) => {