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:
18
src/app.js
18
src/app.js
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user