Clubs that reconfigure their track (e.g. running indoors in the same building) can now add named track layouts under Settings, each with just a start date/time - layouts are contiguous, so a layout's end is implicitly the next one's start, with no separate field to keep in sync. Every session freezes which layout was active when it started, so the "track record" audio cue and the Results Archive (which now shows each session's layout) only compare laps run on the same physical track. With no layouts configured, everything behaves exactly as before - the feature is fully inert until a club uses it. Design settled through conversation with the user: global scope (not per class), start-date-only (no redundant stop date), and no changes needed to driver/transponder mapping, since that's already fully independent of track geometry in this app. Deliberately out of scope for this pass: the mobile/driver training profile's own cross-session personal-best trend stats still compare across a driver's whole lap ledger regardless of layout - logged in ROADMAP.md as a real remaining gap for a later pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1088 lines
61 KiB
JavaScript
1088 lines
61 KiB
JavaScript
import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js";
|
|
|
|
function formatBackupSize(bytes) {
|
|
const value = Math.max(0, Number(bytes || 0));
|
|
if (value < 1024) return `${value} B`;
|
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export function renderSettings(deps) {
|
|
const {
|
|
dom,
|
|
ammc,
|
|
state,
|
|
t,
|
|
backend,
|
|
settingsStorageNotice,
|
|
settingsStorageNoticeIsError,
|
|
getManagedWsUrl,
|
|
getBackendUrl,
|
|
getDefaultBackendUrl,
|
|
escapeHtml,
|
|
saveState,
|
|
renderView,
|
|
connectDecoder,
|
|
playPassingBeep,
|
|
playFinishSiren,
|
|
playStartCue,
|
|
speakText,
|
|
getSpeechVoices,
|
|
openOverlayWindow,
|
|
buildOverlayUrl,
|
|
normalizeObsOverlaySettings,
|
|
DEFAULT_OBS_OVERLAY_SETTINGS,
|
|
OBS_LAYOUTS,
|
|
OBS_THEMES,
|
|
pingBackend,
|
|
syncStateToBackend,
|
|
buildDataExportPayload,
|
|
downloadJsonFile,
|
|
importDataPayload,
|
|
buildDirectoryExportPayload,
|
|
downloadCsvFile,
|
|
buildDriversCsvRows,
|
|
buildCarsCsvRows,
|
|
importDirectoryCsv,
|
|
clearDataScope,
|
|
buildExportMeta,
|
|
normalizeStoredRacePreset,
|
|
saveAmmcConfigToBackend,
|
|
refreshAmmcStatus,
|
|
startManagedAmmc,
|
|
stopManagedAmmc,
|
|
normalizeRaceAudioSettings,
|
|
raceAudioEventDefinitions,
|
|
triggerRaceAudio,
|
|
serverBackups,
|
|
serverBackupsLoaded,
|
|
createManualServerBackup,
|
|
createSafetyServerBackup,
|
|
refreshServerBackups,
|
|
trackLayouts,
|
|
nextTrackLayoutLabel,
|
|
addTrackLayout,
|
|
deleteTrackLayout,
|
|
updateStatus,
|
|
updateStatusLoaded,
|
|
updateCheckPending,
|
|
updateNotice,
|
|
updateNoticeIsError,
|
|
refreshUpdateStatus,
|
|
runUpdateNow,
|
|
setUpdateNotice,
|
|
} = deps;
|
|
const runWithSafetyBackup = async (action) => {
|
|
try {
|
|
await createSafetyServerBackup();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!window.confirm(t("settings.safety_backup_failed_confirm", { msg: message }))) return false;
|
|
}
|
|
await action();
|
|
return true;
|
|
};
|
|
const pageOrigin = /^https?:$/.test(window.location.protocol) ? window.location.origin : "";
|
|
const configuredBackendUrl = String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, "");
|
|
const backendUrlMismatch = Boolean(pageOrigin && configuredBackendUrl && pageOrigin !== configuredBackendUrl);
|
|
const ammcStatus = ammc.status || {};
|
|
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
|
|
const suggestedWsUrl = getManagedWsUrl("main");
|
|
const suggestedSplitWsUrl = getManagedWsUrl("split");
|
|
const suggestedPitEntryWsUrl = getManagedWsUrl("pit_entry");
|
|
const suggestedPitExitWsUrl = getManagedWsUrl("pit_exit");
|
|
const running = Boolean(ammcStatus.running);
|
|
const speechVoices = getSpeechVoices();
|
|
const raceAudio = normalizeRaceAudioSettings(state.settings.raceAudio);
|
|
const speechVoiceOptions = speechVoices
|
|
.map((voice) => `<option value="${escapeHtml(voice.voiceURI)}" ${state.settings.speechVoiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
|
|
.join("");
|
|
const raceAudioVoiceOptions = speechVoices
|
|
.map((voice) => `<option value="${escapeHtml(voice.voiceURI)}" ${raceAudio.voiceUri === voice.voiceURI ? "selected" : ""}>${escapeHtml(`${voice.name} (${voice.lang})`)}</option>`)
|
|
.join("");
|
|
const raceAudioEventRows = raceAudioEventDefinitions.map((definition) => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(definition.key.replaceAll("_", " "))}</strong><small>${escapeHtml(definition.group)} · ${escapeHtml(definition.priority)}</small></td>
|
|
<td><input type="checkbox" name="raceAudioEffect_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.effect ? "checked" : ""} ${definition.effect ? "" : "disabled"} /></td>
|
|
<td><input type="checkbox" name="raceAudioVoice_${definition.key}" form="settingsForm" ${raceAudio.events[definition.key]?.voice ? "checked" : ""} ${definition.voice ? "" : "disabled"} /></td>
|
|
<td><button type="button" class="btn" data-test-race-audio="${definition.key}">${t("audio.test")}</button></td>
|
|
</tr>
|
|
`).join("");
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("settings.decoder")}</h3></div>
|
|
<form id="settingsForm" class="panel-body decoder-settings-form">
|
|
<div class="decoder-layout-picker">
|
|
<div>
|
|
<strong>${t("settings.decoder_setup_title")}</strong>
|
|
<p class="hint">${t("settings.decoder_setup_hint")}</p>
|
|
</div>
|
|
<div class="decoder-layout-options" role="group" aria-label="${t("settings.decoder_setup_title")}">
|
|
<button class="btn decoder-layout-button" type="button" data-decoder-layout="main">${t("settings.decoder_layout_main")}</button>
|
|
<button class="btn decoder-layout-button" type="button" data-decoder-layout="split">${t("settings.decoder_layout_split")}</button>
|
|
<button class="btn decoder-layout-button" type="button" data-decoder-layout="pit">${t("settings.decoder_layout_pit")}</button>
|
|
<button class="btn decoder-layout-button" type="button" data-decoder-layout="full">${t("settings.decoder_layout_full")}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="hint">${t("settings.decoder_roles_hint")}</p>
|
|
<div class="decoder-role-grid">
|
|
<section class="decoder-role-card decoder-role-card-main">
|
|
<div class="decoder-role-header">
|
|
<div>
|
|
<strong>${t("timing.main_decoder")}</strong>
|
|
<span class="decoder-role-badge is-required">${t("settings.role_required")}</span>
|
|
</div>
|
|
<p>${t("settings.main_role_hint")}</p>
|
|
</div>
|
|
<label class="decoder-role-body">
|
|
<span>${t("settings.main_ws_url")}</span>
|
|
<input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
|
|
</label>
|
|
</section>
|
|
|
|
<section id="settingsSplitCard" class="decoder-role-card">
|
|
<div class="decoder-role-header">
|
|
<div>
|
|
<strong>${t("timing.split_decoder")}</strong>
|
|
<span class="decoder-role-badge" data-decoder-state="split"></span>
|
|
</div>
|
|
<label class="toggle decoder-role-toggle">
|
|
<input id="settingsSplitEnabled" type="checkbox" name="splitDecoderEnabled" ${state.settings.splitDecoderEnabled ? "checked" : ""} />
|
|
<span>${t("settings.enable_split_decoder")}</span>
|
|
</label>
|
|
<p>${t("settings.split_role_hint")}</p>
|
|
</div>
|
|
<label class="decoder-role-body">
|
|
<span>${t("settings.split_ws_url")}</span>
|
|
<input name="splitWsUrl" value="${escapeHtml(state.settings.splitWsUrl || "")}" placeholder="ws://127.0.0.1:9001" />
|
|
</label>
|
|
</section>
|
|
|
|
<section id="settingsPitCard" class="decoder-role-card">
|
|
<div class="decoder-role-header">
|
|
<div>
|
|
<strong>${t("timing.pit_decoders")}</strong>
|
|
<span class="decoder-role-badge" data-decoder-state="pit"></span>
|
|
</div>
|
|
<label class="toggle decoder-role-toggle">
|
|
<input id="settingsPitEnabled" type="checkbox" name="pitDecoderEnabled" ${state.settings.pitDecoderEnabled ? "checked" : ""} />
|
|
<span>${t("settings.enable_pit_decoders")}</span>
|
|
</label>
|
|
<p>${t("settings.pit_role_hint")}</p>
|
|
</div>
|
|
<div class="decoder-role-body decoder-pit-fields">
|
|
<label>
|
|
<span>${t("settings.pit_entry_ws_url")}</span>
|
|
<input name="pitEntryWsUrl" value="${escapeHtml(state.settings.pitEntryWsUrl || "")}" placeholder="ws://127.0.0.1:9002" />
|
|
</label>
|
|
<label>
|
|
<span>${t("settings.pit_exit_ws_url")}</span>
|
|
<input name="pitExitWsUrl" value="${escapeHtml(state.settings.pitExitWsUrl || "")}" placeholder="ws://127.0.0.1:9003" />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="decoder-connection-card">
|
|
<div>
|
|
<strong>${t("settings.connection_title")}</strong>
|
|
<p class="hint">${t("settings.backend_url_hint")}</p>
|
|
</div>
|
|
<label>
|
|
<span>${t("settings.backend_url")}</span>
|
|
<input name="backendUrl" value="${escapeHtml(
|
|
state.settings.backendUrl || getDefaultBackendUrl()
|
|
)}" placeholder="http://127.0.0.1:8081" />
|
|
</label>
|
|
${backendUrlMismatch ? `
|
|
<p class="settings-backend-mismatch">
|
|
${escapeHtml(t("settings.backend_url_mismatch", { origin: pageOrigin }))}
|
|
<button type="button" id="settingsUseCurrentOrigin" class="btn btn-small">${escapeHtml(t("settings.backend_url_use_current", { origin: pageOrigin }))}</button>
|
|
</p>
|
|
` : ""}
|
|
<label class="toggle decoder-reconnect-toggle">
|
|
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
|
|
<span>${t("settings.auto_reconnect")}</span>
|
|
</label>
|
|
<div class="decoder-settings-actions">
|
|
<button class="btn btn-primary" type="submit">${t("settings.save")}</button>
|
|
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
<div class="panel-body">
|
|
<p>${t("settings.expected_json")}: <code>{"msg":"PASSING", "transponder":232323, "rtc_time":"..."}</code></p>
|
|
<p class="hint">${t("dashboard.live_note")}</p>
|
|
</div>
|
|
</section>
|
|
|
|
${decoderDiagnosticsMarkup(state.settings.language)}
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("audio.race_sounds")}</h3></div>
|
|
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
|
|
<label class="toggle"><input type="checkbox" name="raceAudioEnabled" form="settingsForm" ${raceAudio.enabled ? "checked" : ""} /><span>${t("audio.enabled")}</span></label>
|
|
<label class="toggle"><input type="checkbox" name="raceAudioMuted" form="settingsForm" ${raceAudio.muted ? "checked" : ""} /><span>${t("audio.mute")}</span></label>
|
|
<label><span>${t("audio.language")}</span><select name="raceAudioLanguage" form="settingsForm"><option value="sv" ${raceAudio.language === "sv" ? "selected" : ""}>Svenska</option><option value="en" ${raceAudio.language === "en" ? "selected" : ""}>English</option></select></label>
|
|
<label><span>${t("audio.voice_select")}</span><select name="raceAudioVoiceUri" form="settingsForm"><option value="">${t("audio.voice_auto")}</option>${raceAudioVoiceOptions}</select></label>
|
|
<label><span>${t("audio.voice_rate")}</span><input type="range" min="0.65" max="1.35" step="0.05" name="raceAudioVoiceRate" form="settingsForm" value="${raceAudio.voiceRate}" /></label>
|
|
<label><span>${t("audio.voice_pitch")}</span><input type="range" min="0.7" max="1.3" step="0.05" name="raceAudioVoicePitch" form="settingsForm" value="${raceAudio.voicePitch}" /></label>
|
|
<label><span>${t("audio.start_countdown")}</span><select name="raceAudioCountdown" form="settingsForm">${[0,10,30,60].map((value) => `<option value="${value}" ${raceAudio.startCountdownSeconds === value ? "selected" : ""}>${value ? `${value} s` : t("common.off")}</option>`).join("")}</select></label>
|
|
<label><span>${t("audio.master_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioMaster" form="settingsForm" value="${raceAudio.masterVolume}" /></label>
|
|
<label><span>${t("audio.effects_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioEffects" form="settingsForm" value="${raceAudio.effectsVolume}" /></label>
|
|
<label><span>${t("audio.voice_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioVoice" form="settingsForm" value="${raceAudio.voiceVolume}" /></label>
|
|
<label><span>${t("audio.passing_volume")}</span><input type="range" min="0" max="1" step="0.05" name="raceAudioPassing" form="settingsForm" value="${raceAudio.passingVolume}" /></label>
|
|
<label class="toggle"><input type="checkbox" name="raceAudioStartLights" form="settingsForm" ${raceAudio.startLights ? "checked" : ""} /><span>${t("audio.start_lights")}</span></label>
|
|
<label class="toggle"><input type="checkbox" name="raceAudioRandomDelay" form="settingsForm" ${raceAudio.randomStartDelay ? "checked" : ""} /><span>${t("audio.random_delay")}</span></label>
|
|
<label><span>${t("audio.min_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMinDelay" form="settingsForm" value="${raceAudio.minStartDelay}" /></label>
|
|
<label><span>${t("audio.max_delay")}</span><input type="number" min="0" max="10" step="0.1" name="raceAudioMaxDelay" form="settingsForm" value="${raceAudio.maxStartDelay}" /></label>
|
|
<label class="toggle"><input type="checkbox" name="raceAudioPerCompetitor" form="settingsForm" ${raceAudio.perCompetitorPassingTone ? "checked" : ""} /><span>${t("audio.per_competitor")}</span></label>
|
|
<div class="actions-inline"><button id="settingsSaveRaceAudio" class="btn btn-primary" type="button">${t("common.save")}</button><button id="settingsRaceAudioTest" class="btn" type="button">${t("audio.test")}</button></div>
|
|
</div>
|
|
<div class="panel-body"><p class="hint">${t("audio.note")}</p><div class="table-wrap"><table class="audio-event-table"><thead><tr><th>Event</th><th>${t("audio.effect")}</th><th>${t("audio.voice")}</th><th></th></tr></thead><tbody>${raceAudioEventRows}</tbody></table></div></div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.audio")} · Legacy</h3></div>
|
|
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
|
|
<label class="toggle">
|
|
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
|
|
<span>${t("settings.audio_enabled")}</span>
|
|
</label>
|
|
<select name="passingSoundMode" form="settingsForm">
|
|
<option value="off" ${state.settings.passingSoundMode === "off" ? "selected" : ""}>${t("settings.passing_sound_off")}</option>
|
|
<option value="beep" ${state.settings.passingSoundMode === "beep" ? "selected" : ""}>${t("settings.passing_sound_beep")}</option>
|
|
<option value="name" ${state.settings.passingSoundMode === "name" ? "selected" : ""}>${t("settings.passing_sound_name")}</option>
|
|
</select>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="finishVoiceEnabled" form="settingsForm" ${state.settings.finishVoiceEnabled ? "checked" : ""} />
|
|
<span>${t("settings.finish_voice")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="startSoundEnabled" form="settingsForm" ${state.settings.startSoundEnabled ? "checked" : ""} />
|
|
<span>${t("settings.start_sound")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="finishSoundEnabled" form="settingsForm" ${state.settings.finishSoundEnabled ? "checked" : ""} />
|
|
<span>${t("settings.finish_sound")}</span>
|
|
</label>
|
|
<label class="settings-audio-wide">
|
|
<span>${t("settings.speech_voice")}</span>
|
|
<select name="speechVoiceUri" form="settingsForm">
|
|
<option value="">${t("settings.system_default_voice")}</option>
|
|
${speechVoiceOptions}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>${t("settings.speech_rate")}</span>
|
|
<input type="range" min="0.65" max="1.35" step="0.05" name="speechRate" form="settingsForm" value="${state.settings.speechRate || 0.95}" />
|
|
</label>
|
|
<label>
|
|
<span>${t("settings.speech_pitch")}</span>
|
|
<input type="range" min="0.7" max="1.3" step="0.05" name="speechPitch" form="settingsForm" value="${state.settings.speechPitch || 1}" />
|
|
</label>
|
|
<button id="settingsTestStart" class="btn" type="button">${t("settings.test_start")}</button>
|
|
<button id="settingsTestFinish" class="btn" type="button">${t("settings.test_finish")}</button>
|
|
<button id="settingsTestDriverVoice" class="btn" type="button">${t("settings.test_driver_voice")}</button>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerPassingCueEnabled" form="settingsForm" ${state.settings.speakerPassingCueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_passing_cue")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerLeaderCueEnabled" form="settingsForm" ${state.settings.speakerLeaderCueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_leader_cue")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerFinishCueEnabled" form="settingsForm" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_finish_cue")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerBestLapCueEnabled" form="settingsForm" ${state.settings.speakerBestLapCueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_bestlap_cue")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerTop3CueEnabled" form="settingsForm" ${state.settings.speakerTop3CueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_top3_cue")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="speakerSessionStartCueEnabled" form="settingsForm" ${state.settings.speakerSessionStartCueEnabled ? "checked" : ""} />
|
|
<span>${t("settings.speaker_start_cue")}</span>
|
|
</label>
|
|
</div>
|
|
<div class="panel-body">
|
|
<p>${t("settings.audio_note")}</p>
|
|
<p class="hint">${t("settings.voice_note")}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.branding")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("settings.branding_note")}</p>
|
|
</div>
|
|
<div class="panel-body form-grid cols-3 settings-grid">
|
|
<label><span>${t("settings.club_name")}</span><input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="JMK RB" /></label>
|
|
<label><span>${t("settings.club_tagline")}</span><input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="Live Timing & Race Control" /></label>
|
|
<label><span>${t("settings.pdf_footer")}</span><input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="Generated by RC RaceCore" /></label>
|
|
<label><span>${t("settings.pdf_theme")}</span><select name="pdfTheme" form="settingsForm">
|
|
<option value="classic" ${state.settings.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
|
|
<option value="minimal" ${state.settings.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
|
|
<option value="motorsport" ${state.settings.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
|
|
</select></label>
|
|
</div>
|
|
<div class="panel-body">
|
|
<h4>${t("settings.logo")}</h4>
|
|
<p class="hint">${t("settings.logo_note")}</p>
|
|
<div class="actions">
|
|
<input id="logoUpload" type="file" accept="image/*" />
|
|
<button id="clearLogo" class="btn" type="button">${t("settings.logo_clear")}</button>
|
|
<button class="btn btn-primary" type="submit" form="settingsForm">${t("settings.save_branding")}</button>
|
|
</div>
|
|
${
|
|
state.settings.logoDataUrl
|
|
? `<div class="logo-preview mt-16"><img src="${escapeHtml(state.settings.logoDataUrl)}" alt="logo" /></div>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.track_layouts")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("settings.track_layouts_hint")}</p>
|
|
<form id="trackLayoutForm" class="form-grid cols-3 settings-grid">
|
|
<label><span>${t("settings.track_layout_label")}</span><input name="label" placeholder="${escapeHtml(nextTrackLayoutLabel())}" /></label>
|
|
<label><span>${t("settings.track_layout_started_at")}</span><input name="startedAt" type="datetime-local" value="${escapeHtml((() => {
|
|
const now = new Date();
|
|
now.setSeconds(0, 0);
|
|
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
|
return now.toISOString().slice(0, 16);
|
|
})())}" /></label>
|
|
<div class="actions"><button class="btn btn-primary" type="submit">${t("settings.track_layout_add")}</button></div>
|
|
</form>
|
|
<div class="backup-list mt-16">
|
|
${(trackLayouts || []).length
|
|
? trackLayouts.slice().reverse().map((layout) => `
|
|
<article class="backup-row">
|
|
<div><strong>${escapeHtml(layout.label || t("settings.track_layout_unnamed"))}</strong><small>${escapeHtml(new Date(layout.startedAt).toLocaleString())}</small></div>
|
|
<span></span>
|
|
<button class="btn btn-mini" type="button" data-delete-track-layout="${escapeHtml(layout.id)}">${t("common.delete")}</button>
|
|
</article>`).join("")
|
|
: `<p class="hint">${t("settings.track_layout_none")}</p>`}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("overlay.obs_config")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("overlay.obs_public_hint")}</p>
|
|
<div class="actions">
|
|
<button id="settingsLaunchObs" class="btn" type="button">${t("timing.open_obs_overlay")}</button>
|
|
<button id="settingsCopyObsUrl" class="btn" type="button">${t("overlay.obs_copy_url")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-body overlay-obs-config">
|
|
<div class="form-grid cols-4">
|
|
<label>
|
|
${t("overlay.obs_rows")}
|
|
<input id="obsRows" type="number" min="3" max="12" value="${normalizeObsOverlaySettings(state.settings.obsOverlay).rows}" />
|
|
</label>
|
|
<label>
|
|
${t("overlay.obs_layout")}
|
|
<select id="obsLayout">
|
|
${OBS_LAYOUTS.map((layout) => `<option value="${layout}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).layout === layout ? "selected" : ""}>${t(`overlay.obs_layout_${layout}`)}</option>`).join("")}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
${t("overlay.obs_theme")}
|
|
<select id="obsTheme">
|
|
${OBS_THEMES.map((theme) => `<option value="${theme}" ${normalizeObsOverlaySettings(state.settings.obsOverlay).theme === theme ? "selected" : ""}>${t(`overlay.obs_theme_${theme}`)}</option>`).join("")}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
${t("overlay.obs_public_token")}
|
|
<input id="obsPublicToken" value="${escapeHtml(normalizeObsOverlaySettings(state.settings.obsOverlay).publicToken)}" placeholder="optional" />
|
|
<small>${t("overlay.obs_public_token_hint")}</small>
|
|
</label>
|
|
<label class="toggle"><input id="obsShowClock" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showClock ? "checked" : ""} /><span>${t("overlay.obs_show_clock")}</span></label>
|
|
<label class="toggle"><input id="obsShowFastest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showFastest ? "checked" : ""} /><span>${t("overlay.obs_show_fastest")}</span></label>
|
|
<label class="toggle"><input id="obsShowGrid" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGrid ? "checked" : ""} /><span>${t("overlay.obs_show_grid")}</span></label>
|
|
<label class="toggle"><input id="obsShowLaps" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showLaps ? "checked" : ""} /><span>${t("overlay.obs_show_laps")}</span></label>
|
|
<label class="toggle"><input id="obsShowResult" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showResult ? "checked" : ""} /><span>${t("overlay.obs_show_result")}</span></label>
|
|
<label class="toggle"><input id="obsShowBest" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showBest ? "checked" : ""} /><span>${t("overlay.obs_show_best")}</span></label>
|
|
<label class="toggle"><input id="obsShowGap" type="checkbox" ${normalizeObsOverlaySettings(state.settings.obsOverlay).showGap ? "checked" : ""} /><span>${t("overlay.obs_show_gap")}</span></label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.managed_ammc")}</h3></div>
|
|
<form id="ammcForm" class="panel-body form-grid cols-2 settings-grid">
|
|
<p class="hint form-grid-full">${t("settings.managed_channels_hint")}</p>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} />
|
|
<span>${t("settings.enable_managed")}</span>
|
|
</label>
|
|
<label class="toggle">
|
|
<input type="checkbox" name="autoStart" ${ammc.config.autoStart ? "checked" : ""} />
|
|
<span>${t("settings.auto_start_ammc")}</span>
|
|
</label>
|
|
<label><span>${t("timing.main_decoder")} - ${t("settings.decoder_host")}</span><input name="decoderHost" value="${escapeHtml(ammc.config.decoderHost || "")}" placeholder="192.168.1.10" /></label>
|
|
<label><span>${t("timing.main_decoder")} - ${t("settings.ws_port")}</span><input name="wsPort" value="${escapeHtml(String(ammc.config.wsPort || 9000))}" placeholder="9000" /></label>
|
|
<label class="toggle"><input type="checkbox" name="splitEnabled" ${ammc.config.splitEnabled ? "checked" : ""} /><span>${t("settings.enable_split_decoder")}</span></label>
|
|
<span></span>
|
|
<label><span>${t("settings.split_decoder_host")}</span><input name="splitDecoderHost" value="${escapeHtml(ammc.config.splitDecoderHost || "")}" placeholder="192.168.1.11" /></label>
|
|
<label><span>${t("timing.split_decoder")} - ${t("settings.ws_port")}</span><input name="splitWsPort" value="${escapeHtml(String(ammc.config.splitWsPort || 9001))}" placeholder="9001" /></label>
|
|
<label class="toggle"><input type="checkbox" name="pitEnabled" ${ammc.config.pitEnabled ? "checked" : ""} /><span>${t("settings.enable_pit_decoders")}</span></label>
|
|
<span></span>
|
|
<label><span>${t("settings.pit_entry_decoder_host")}</span><input name="pitEntryDecoderHost" value="${escapeHtml(ammc.config.pitEntryDecoderHost || "")}" placeholder="192.168.1.12" /></label>
|
|
<label><span>${t("timing.pit_entry_decoder")} - ${t("settings.ws_port")}</span><input name="pitEntryWsPort" value="${escapeHtml(String(ammc.config.pitEntryWsPort || 9002))}" placeholder="9002" /></label>
|
|
<label><span>${t("settings.pit_exit_decoder_host")}</span><input name="pitExitDecoderHost" value="${escapeHtml(ammc.config.pitExitDecoderHost || "")}" placeholder="192.168.1.13" /></label>
|
|
<label><span>${t("timing.pit_exit_decoder")} - ${t("settings.ws_port")}</span><input name="pitExitWsPort" value="${escapeHtml(String(ammc.config.pitExitWsPort || 9003))}" placeholder="9003" /></label>
|
|
<label><span>${t("settings.executable_path")}</span><input name="executablePath" value="${escapeHtml(ammc.config.executablePath || "")}" placeholder="${t("settings.executable_path")}" /></label>
|
|
<label><span>${t("settings.working_dir")}</span><input name="workingDirectory" value="${escapeHtml(ammc.config.workingDirectory || "")}" placeholder="${t("settings.working_dir")}" /></label>
|
|
<label><span>${t("settings.extra_args")}</span><input name="extraArgs" value="${escapeHtml(ammc.config.extraArgs || "")}" placeholder="${t("settings.extra_args")}" /></label>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" type="submit">${t("settings.save_ammc")}</button>
|
|
<button id="ammcRefresh" class="btn" type="button">${t("settings.refresh_ammc")}</button>
|
|
<button id="ammcStart" class="btn" type="button">${t("settings.start_ammc")}</button>
|
|
<button id="ammcStop" class="btn" type="button">${t("settings.stop_ammc")}</button>
|
|
<button id="ammcUseServerWs" class="btn" type="button">${t("settings.use_server_ws")}</button>
|
|
</div>
|
|
</form>
|
|
<div class="panel-body">
|
|
<p>${t("settings.managed_ammc_sub")}</p>
|
|
<p>${t("settings.bundled_hint")}</p>
|
|
<p>${t("settings.ammc_status")}: <strong>${running ? t("settings.running") : t("settings.stopped")}</strong></p>
|
|
<p>${t("settings.server_platform")}: <strong>${escapeHtml(String(ammcStatus.serverPlatform || "-"))}</strong></p>
|
|
<p>${t("settings.pid")}: <strong>${escapeHtml(String(ammcStatus.pid || "-"))}</strong></p>
|
|
<p>${t("settings.started_at")}: ${ammcStatus.startedAt ? new Date(ammcStatus.startedAt).toLocaleString() : "-"}</p>
|
|
<p>${t("settings.stopped_at")}: ${ammcStatus.stoppedAt ? new Date(ammcStatus.stoppedAt).toLocaleString() : "-"}</p>
|
|
<p>${t("settings.executable_path")}: <strong>${escapeHtml(String(ammcStatus.resolvedExecutablePath || ammc.config.executablePath || "-"))}</strong></p>
|
|
<p>${ammcStatus.executableExists ? t("settings.executable_found") : t("settings.executable_missing")}</p>
|
|
<p>${t("settings.decoder_host")}: <strong>${escapeHtml(String(ammc.config.decoderHost || "-"))}</strong></p>
|
|
<p>${t("settings.ws_port")}: <strong>${escapeHtml(String(ammc.config.wsPort || 9000))}</strong></p>
|
|
${ammc.config.splitEnabled ? `<p>${t("timing.split_decoder")}: <strong>${ammcStatus.split?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.splitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.splitWsPort || 9001))}${ammcStatus.split?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.split.lastError)}</span>` : ""}</p>` : ""}
|
|
${ammc.config.pitEnabled ? `<p>${t("timing.pit_entry_decoder")}: <strong>${ammcStatus.pitEntry?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.pitEntryDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitEntryWsPort || 9002))}${ammcStatus.pitEntry?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.pitEntry.lastError)}</span>` : ""}</p><p>${t("timing.pit_exit_decoder")}: <strong>${ammcStatus.pitExit?.running ? t("settings.running") : t("settings.stopped")}</strong> • ${escapeHtml(String(ammc.config.pitExitDecoderHost || "-"))}:${escapeHtml(String(ammc.config.pitExitWsPort || 9003))}${ammcStatus.pitExit?.lastError ? ` • <span class="error">${escapeHtml(ammcStatus.pitExit.lastError)}</span>` : ""}</p>` : ""}
|
|
<p>${t("settings.last_error")}: ${escapeHtml(ammc.lastError || ammcStatus.lastError || "-")}</p>
|
|
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
|
|
<p>WebSocket URL: <strong>${escapeHtml(suggestedWsUrl)}</strong></p>
|
|
<pre class="log-box">${escapeHtml(
|
|
ammcOutput.length
|
|
? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.role || "main"}/${entry.stream}: ${entry.line}`).join("\n")
|
|
: "-"
|
|
)}</pre>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.update_title")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t("settings.update_current_version")}: <strong>v${escapeHtml(String(updateStatus?.currentVersion || ""))}</strong></p>
|
|
${!updateStatusLoaded
|
|
? `<p class="hint">${t("settings.update_checking")}</p>`
|
|
: updateStatus?.error
|
|
? `<p class="error">${escapeHtml(t("settings.update_error", { msg: updateStatus.error }))}</p>`
|
|
: updateStatus?.available
|
|
? `<p class="update-available"><strong>${escapeHtml(t("settings.update_available", { version: updateStatus.remoteVersion || "?" }))}</strong></p>`
|
|
: `<p class="hint">${t("settings.update_up_to_date")}</p>`}
|
|
<div class="actions">
|
|
<button id="settingsCheckUpdate" class="btn" type="button" ${updateCheckPending ? "disabled" : ""}>${t("settings.update_check_now")}</button>
|
|
${updateStatus?.available ? `<button id="settingsRunUpdate" class="btn btn-primary" type="button">${t("settings.update_run_now")}</button>` : ""}
|
|
</div>
|
|
${updateNotice ? `<p class="${updateNoticeIsError ? "error" : "hint"}">${escapeHtml(updateNotice)}</p>` : ""}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.storage")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
|
|
<p>${t("settings.backend_status")}: <strong>${backend.available ? t("settings.online") : t("settings.offline")}</strong></p>
|
|
<p>${t("settings.last_sync")}: ${backend.lastSyncAt ? new Date(backend.lastSyncAt).toLocaleString() : "-"}</p>
|
|
<p class="error">${escapeHtml(backend.lastError || "")}</p>
|
|
<div class="actions">
|
|
<button id="settingsTestBackend" class="btn" type="button">${t("settings.test_backend")}</button>
|
|
<button id="settingsSyncNow" class="btn btn-primary" type="button">${t("settings.sync_now")}</button>
|
|
</div>
|
|
<div class="backup-manager mt-16">
|
|
<div class="backup-manager-header">
|
|
<div>
|
|
<strong>${t("settings.server_backups")}</strong>
|
|
<p class="hint">${t("settings.server_backups_hint")}</p>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="createServerBackup" class="btn btn-primary" type="button">${t("settings.create_backup")}</button>
|
|
<button id="refreshServerBackups" class="btn" type="button">${t("settings.refresh_backups")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="backup-list">
|
|
${!serverBackupsLoaded
|
|
? `<p class="hint">${t("settings.loading_backups")}</p>`
|
|
: serverBackups.length
|
|
? serverBackups.slice(0, 10).map((backup) => `
|
|
<article class="backup-row">
|
|
<div><strong>${escapeHtml(new Date(backup.createdAt).toLocaleString())}</strong><small>${escapeHtml(backup.fileName)}</small></div>
|
|
<span>${escapeHtml(t(`settings.backup_reason_${backup.reason}`))}</span>
|
|
<span>${formatBackupSize(backup.sizeBytes)}</span>
|
|
</article>`).join("")
|
|
: `<p class="hint">${t("settings.no_backups")}</p>`}
|
|
</div>
|
|
<p class="hint">${t("settings.restore_backup_hint")}</p>
|
|
</div>
|
|
<div class="mt-16">
|
|
<p class="hint">${t("settings.storage_note_full")}</p>
|
|
<div class="actions">
|
|
<button id="exportData" class="btn" type="button">${t("settings.export_all_data")}</button>
|
|
<input id="importData" type="file" accept="application/json" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-16">
|
|
<p class="hint">${t("settings.storage_note_directory")}</p>
|
|
<div class="actions">
|
|
<button id="exportDirectoryData" class="btn" type="button">${t("settings.export_directory")}</button>
|
|
<input id="importDirectoryData" type="file" accept="application/json" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-16">
|
|
<p class="hint">${t("settings.storage_note_csv")}</p>
|
|
<div class="actions">
|
|
<button id="exportDriversCsv" class="btn" type="button">${t("settings.export_drivers_csv")}</button>
|
|
<button id="selectDriversCsv" class="btn" type="button">${t("settings.import_drivers_csv")}</button>
|
|
<button id="downloadDriversCsvTemplate" class="btn" type="button">${t("settings.download_drivers_csv_template")}</button>
|
|
<input id="importDriversCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
|
|
<button id="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
|
|
<button id="selectCarsCsv" class="btn" type="button">${t("settings.import_cars_csv")}</button>
|
|
<input id="importCarsCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
|
|
</div>
|
|
<details class="csv-import-guide">
|
|
<summary>${t("settings.driver_csv_guide_title")}</summary>
|
|
<div class="csv-import-guide-body">
|
|
<p><strong>${t("settings.driver_csv_required")}</strong></p>
|
|
<p>${t("settings.driver_csv_columns")}</p>
|
|
<p>${t("settings.driver_csv_behavior")}</p>
|
|
<span class="csv-import-example-label">${t("settings.driver_csv_example")}</span>
|
|
<pre><code>name;transponder;class;brand;club;country
|
|
Anna Andersson;123456;2WD;;JMK;SE
|
|
Erik Eriksson;654321;4WD;Associated;;</code></pre>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16 settings-danger-panel">
|
|
<div class="panel-header"><h3>${t("settings.clear_data_title")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("settings.clear_data_note")}</p>
|
|
<div class="actions mt-16">
|
|
<button class="btn btn-danger" type="button" data-clear-scope="drivers">${t("settings.clear_drivers")}</button>
|
|
<button class="btn btn-danger" type="button" data-clear-scope="cars">${t("settings.clear_cars")}</button>
|
|
<button class="btn btn-danger" type="button" data-clear-scope="classes">${t("settings.clear_classes")}</button>
|
|
<button class="btn btn-danger" type="button" data-clear-scope="races">${t("settings.clear_races")}</button>
|
|
<button class="btn btn-danger" type="button" data-clear-scope="training">${t("settings.clear_training")}</button>
|
|
<button class="btn btn-danger" type="button" data-clear-scope="all">${t("settings.clear_all")}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("settings.race_presets")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t("settings.race_presets_note")}</p>
|
|
<p>${(state.settings.racePresets || []).length} preset(s)</p>
|
|
<div class="actions">
|
|
<button id="exportRacePresets" class="btn" type="button">${t("settings.export_presets")}</button>
|
|
<input id="importRacePresets" type="file" accept="application/json" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
mountDecoderDiagnostics(document.getElementById("decoderDiagnostics"), () => state.settings);
|
|
|
|
const applyRaceAudioFormValues = (form) => {
|
|
const events = {};
|
|
raceAudioEventDefinitions.forEach((definition) => {
|
|
events[definition.key] = {
|
|
effect: form.get("raceAudioEffect_" + definition.key) === "on",
|
|
voice: form.get("raceAudioVoice_" + definition.key) === "on",
|
|
};
|
|
});
|
|
state.settings.raceAudio = normalizeRaceAudioSettings({
|
|
...state.settings.raceAudio,
|
|
enabled: form.get("raceAudioEnabled") === "on",
|
|
muted: form.get("raceAudioMuted") === "on",
|
|
language: String(form.get("raceAudioLanguage") || "sv"),
|
|
voiceUri: String(form.get("raceAudioVoiceUri") || ""),
|
|
voiceRate: Number(form.get("raceAudioVoiceRate")), voicePitch: Number(form.get("raceAudioVoicePitch")),
|
|
masterVolume: Number(form.get("raceAudioMaster")), effectsVolume: Number(form.get("raceAudioEffects")),
|
|
voiceVolume: Number(form.get("raceAudioVoice")), passingVolume: Number(form.get("raceAudioPassing")),
|
|
startCountdownSeconds: Number(form.get("raceAudioCountdown")), startLights: form.get("raceAudioStartLights") === "on",
|
|
randomStartDelay: form.get("raceAudioRandomDelay") === "on", minStartDelay: Number(form.get("raceAudioMinDelay")),
|
|
maxStartDelay: Number(form.get("raceAudioMaxDelay")), perCompetitorPassingTone: form.get("raceAudioPerCompetitor") === "on",
|
|
events,
|
|
});
|
|
};
|
|
|
|
const persistRaceAudioFormValues = () => {
|
|
const formNode = document.getElementById("settingsForm");
|
|
if (!(formNode instanceof HTMLFormElement)) return false;
|
|
applyRaceAudioFormValues(new FormData(formNode));
|
|
state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled;
|
|
saveState();
|
|
return true;
|
|
};
|
|
|
|
const applyAudioFormValues = () => {
|
|
const formNode = document.getElementById("settingsForm");
|
|
if (!(formNode instanceof HTMLFormElement)) return;
|
|
const form = new FormData(formNode);
|
|
state.settings.audioEnabled = form.get("audioEnabled") === "on";
|
|
state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep");
|
|
state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on";
|
|
state.settings.startSoundEnabled = form.get("startSoundEnabled") === "on";
|
|
state.settings.finishSoundEnabled = form.get("finishSoundEnabled") === "on";
|
|
state.settings.speechVoiceUri = String(form.get("speechVoiceUri") || "");
|
|
state.settings.speechRate = Math.max(0.65, Math.min(1.35, Number(form.get("speechRate") || 0.95) || 0.95));
|
|
state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(form.get("speechPitch") || 1) || 1));
|
|
};
|
|
|
|
const splitDecoderToggle = document.getElementById("settingsSplitEnabled");
|
|
const pitDecoderToggle = document.getElementById("settingsPitEnabled");
|
|
const updateDecoderLayoutUi = () => {
|
|
const splitEnabled = Boolean(splitDecoderToggle?.checked);
|
|
const pitEnabled = Boolean(pitDecoderToggle?.checked);
|
|
document.getElementById("settingsSplitCard")?.classList.toggle("is-disabled", !splitEnabled);
|
|
document.getElementById("settingsPitCard")?.classList.toggle("is-disabled", !pitEnabled);
|
|
document.querySelector('[data-decoder-state="split"]')?.replaceChildren(
|
|
document.createTextNode(t(splitEnabled ? "settings.role_enabled" : "settings.role_disabled"))
|
|
);
|
|
document.querySelector('[data-decoder-state="pit"]')?.replaceChildren(
|
|
document.createTextNode(t(pitEnabled ? "settings.role_enabled" : "settings.role_disabled"))
|
|
);
|
|
const activeLayout = splitEnabled && pitEnabled ? "full" : splitEnabled ? "split" : pitEnabled ? "pit" : "main";
|
|
document.querySelectorAll("[data-decoder-layout]").forEach((button) => {
|
|
button.classList.toggle("is-active", button.dataset.decoderLayout === activeLayout);
|
|
button.setAttribute("aria-pressed", String(button.dataset.decoderLayout === activeLayout));
|
|
});
|
|
};
|
|
|
|
splitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi);
|
|
pitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi);
|
|
document.querySelectorAll("[data-decoder-layout]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const layout = button.dataset.decoderLayout;
|
|
if (splitDecoderToggle) splitDecoderToggle.checked = layout === "split" || layout === "full";
|
|
if (pitDecoderToggle) pitDecoderToggle.checked = layout === "pit" || layout === "full";
|
|
updateDecoderLayoutUi();
|
|
});
|
|
});
|
|
updateDecoderLayoutUi();
|
|
|
|
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
state.settings.wsUrl = String(form.get("wsUrl") || "").trim();
|
|
state.settings.splitDecoderEnabled = form.get("splitDecoderEnabled") === "on";
|
|
state.settings.splitWsUrl = String(form.get("splitWsUrl") || "").trim();
|
|
state.settings.pitDecoderEnabled = form.get("pitDecoderEnabled") === "on";
|
|
state.settings.pitEntryWsUrl = String(form.get("pitEntryWsUrl") || "").trim();
|
|
state.settings.pitExitWsUrl = String(form.get("pitExitWsUrl") || "").trim();
|
|
state.settings.backendUrl = String(form.get("backendUrl") || "").trim();
|
|
state.settings.autoReconnect = form.get("autoReconnect") === "on";
|
|
applyAudioFormValues();
|
|
applyRaceAudioFormValues(form);
|
|
state.settings.audioEnabled = state.settings.audioEnabled || state.settings.raceAudio.enabled;
|
|
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
|
|
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
|
|
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on";
|
|
state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on";
|
|
state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on";
|
|
state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on";
|
|
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
|
|
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Timing & Race Control";
|
|
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by RC RaceCore";
|
|
state.settings.pdfTheme = ["classic", "minimal", "motorsport"].includes(String(form.get("pdfTheme") || "classic"))
|
|
? String(form.get("pdfTheme"))
|
|
: "classic";
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
|
|
document.getElementById("settingsUseCurrentOrigin")?.addEventListener("click", () => {
|
|
state.settings.backendUrl = pageOrigin;
|
|
saveState();
|
|
renderView();
|
|
});
|
|
document.querySelectorAll('[name^="raceAudio"]').forEach((control) => {
|
|
control.addEventListener("change", persistRaceAudioFormValues);
|
|
});
|
|
document.getElementById("settingsSaveRaceAudio")?.addEventListener("click", persistRaceAudioFormValues);
|
|
const testRaceAudio = (eventKey) => {
|
|
persistRaceAudioFormValues();
|
|
triggerRaceAudio(eventKey, { force: true, interrupt: true, variables: { driverName: state.drivers[0]?.name || "Driver", lapTime: "16.000", marshalPost: "1", transponder: "123", podium: "P1, P2, P3" } });
|
|
};
|
|
document.getElementById("settingsRaceAudioTest")?.addEventListener("click", () => testRaceAudio("audio_test"));
|
|
document.querySelectorAll("[data-test-race-audio]").forEach((button) => button.addEventListener("click", () => testRaceAudio(button.dataset.testRaceAudio)));
|
|
document.getElementById("settingsTestStart")?.addEventListener("click", () => {
|
|
applyAudioFormValues();
|
|
if (state.settings.startSoundEnabled) playStartCue();
|
|
if (state.settings.finishVoiceEnabled) {
|
|
setTimeout(() => speakText(t("timing.race_started_voice"), { interrupt: true }), state.settings.startSoundEnabled ? 650 : 0);
|
|
}
|
|
});
|
|
document.getElementById("settingsTestFinish")?.addEventListener("click", () => {
|
|
applyAudioFormValues();
|
|
if (state.settings.finishSoundEnabled) playFinishSiren();
|
|
if (state.settings.finishVoiceEnabled) {
|
|
setTimeout(() => speakText(t("timing.race_finished_voice"), { interrupt: true }), state.settings.finishSoundEnabled ? 1150 : 0);
|
|
}
|
|
});
|
|
document.getElementById("settingsTestDriverVoice")?.addEventListener("click", () => {
|
|
applyAudioFormValues();
|
|
const driver = state.drivers[0];
|
|
speakText(driver?.speechName || driver?.name || t("settings.driver_voice_sample"), { interrupt: true });
|
|
});
|
|
document.getElementById("settingsLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
|
|
document.getElementById("settingsCopyObsUrl")?.addEventListener("click", async () => {
|
|
const url = buildOverlayUrl("obs", { public: true });
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(url).catch(() => {});
|
|
return;
|
|
}
|
|
window.prompt("Copy OBS URL", url);
|
|
});
|
|
|
|
[
|
|
["obsRows", "rows", (value) => Math.max(3, Math.min(12, Number(value) || DEFAULT_OBS_OVERLAY_SETTINGS.rows))],
|
|
["obsLayout", "layout", (value) => OBS_LAYOUTS.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.layout],
|
|
["obsTheme", "theme", (value) => OBS_THEMES.includes(String(value || "").toLowerCase()) ? String(value).toLowerCase() : DEFAULT_OBS_OVERLAY_SETTINGS.theme],
|
|
["obsPublicToken", "publicToken", (value) => String(value || "").trim()],
|
|
["obsShowClock", "showClock", (value, el) => Boolean(el?.checked)],
|
|
["obsShowFastest", "showFastest", (value, el) => Boolean(el?.checked)],
|
|
["obsShowGrid", "showGrid", (value, el) => Boolean(el?.checked)],
|
|
["obsShowLaps", "showLaps", (value, el) => Boolean(el?.checked)],
|
|
["obsShowResult", "showResult", (value, el) => Boolean(el?.checked)],
|
|
["obsShowBest", "showBest", (value, el) => Boolean(el?.checked)],
|
|
["obsShowGap", "showGap", (value, el) => Boolean(el?.checked)],
|
|
].forEach(([id, key, mapper]) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const handler = () => {
|
|
const next = normalizeObsOverlaySettings({
|
|
...state.settings.obsOverlay,
|
|
[key]: mapper(el.value, el),
|
|
});
|
|
state.settings.obsOverlay = next;
|
|
saveState();
|
|
};
|
|
el.addEventListener("change", handler);
|
|
if (el instanceof HTMLInputElement && (el.type === "number" || el.type === "text")) {
|
|
el.addEventListener("input", handler);
|
|
}
|
|
});
|
|
|
|
document.getElementById("settingsTestBackend")?.addEventListener("click", async () => {
|
|
await pingBackend();
|
|
renderView();
|
|
});
|
|
document.getElementById("settingsSyncNow")?.addEventListener("click", async () => {
|
|
await syncStateToBackend();
|
|
renderView();
|
|
});
|
|
document.getElementById("createServerBackup")?.addEventListener("click", async (event) => {
|
|
const button = event.currentTarget;
|
|
if (button instanceof HTMLButtonElement) button.disabled = true;
|
|
try {
|
|
await createManualServerBackup();
|
|
await refreshServerBackups({ render: false });
|
|
alert(t("settings.backup_created"));
|
|
} catch (error) {
|
|
alert(t("settings.backup_failed", { msg: error instanceof Error ? error.message : String(error) }));
|
|
}
|
|
renderView();
|
|
});
|
|
document.getElementById("refreshServerBackups")?.addEventListener("click", () => refreshServerBackups());
|
|
|
|
document.getElementById("trackLayoutForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
if (!(form instanceof HTMLFormElement)) return;
|
|
const label = String(form.elements.namedItem("label")?.value || "").trim();
|
|
const startedAtRaw = String(form.elements.namedItem("startedAt")?.value || "");
|
|
const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : Date.now();
|
|
if (!Number.isFinite(startedAtMs)) {
|
|
alert(t("settings.track_layout_invalid_date"));
|
|
return;
|
|
}
|
|
const result = addTrackLayout(label, startedAtMs);
|
|
if (!result.ok) {
|
|
alert(result.message);
|
|
return;
|
|
}
|
|
renderView();
|
|
});
|
|
document.querySelectorAll("[data-delete-track-layout]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const id = button.getAttribute("data-delete-track-layout");
|
|
const layout = (trackLayouts || []).find((item) => item.id === id);
|
|
if (!window.confirm(t("settings.track_layout_delete_confirm", { label: layout?.label || t("settings.track_layout_unnamed") }))) return;
|
|
const result = deleteTrackLayout(id);
|
|
if (!result.ok) {
|
|
alert(result.message);
|
|
return;
|
|
}
|
|
renderView();
|
|
});
|
|
});
|
|
|
|
document.getElementById("settingsCheckUpdate")?.addEventListener("click", async () => {
|
|
setUpdateNotice("");
|
|
await refreshUpdateStatus({ render: false, force: true });
|
|
renderView();
|
|
});
|
|
document.getElementById("settingsRunUpdate")?.addEventListener("click", async (event) => {
|
|
const version = updateStatus?.remoteVersion ? `v${updateStatus.remoteVersion}` : "";
|
|
let message = t("settings.update_confirm", { version });
|
|
if (state.activeSessionId) message += "\n\n" + t("settings.update_confirm_active_session");
|
|
if (!window.confirm(message)) return;
|
|
const button = event.currentTarget;
|
|
if (button instanceof HTMLButtonElement) button.disabled = true;
|
|
try {
|
|
await runUpdateNow();
|
|
setUpdateNotice(t("settings.update_started"), false);
|
|
} catch (error) {
|
|
setUpdateNotice(t("settings.update_failed", { msg: error instanceof Error ? error.message : String(error) }), true);
|
|
}
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("logoUpload")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
state.settings.logoDataUrl = typeof reader.result === "string" ? reader.result : "";
|
|
saveState();
|
|
renderView();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
document.getElementById("clearLogo")?.addEventListener("click", () => {
|
|
state.settings.logoDataUrl = "";
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("exportData")?.addEventListener("click", () => {
|
|
const payload = buildDataExportPayload();
|
|
downloadJsonFile("live_rc_full_export.json", payload);
|
|
});
|
|
|
|
document.getElementById("importData")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
try {
|
|
const parsed = JSON.parse(String(reader.result || "{}"));
|
|
const completed = await runWithSafetyBackup(() => importDataPayload(parsed, "full"));
|
|
if (!completed && input instanceof HTMLInputElement) input.value = "";
|
|
} catch (error) {
|
|
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
settingsStorageNoticeIsError = true;
|
|
renderView();
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
document.getElementById("exportDirectoryData")?.addEventListener("click", () => {
|
|
const payload = buildDirectoryExportPayload();
|
|
downloadJsonFile("live_rc_directory_export.json", payload);
|
|
});
|
|
|
|
document.getElementById("exportDriversCsv")?.addEventListener("click", () => {
|
|
downloadCsvFile("live_rc_drivers.csv", buildDriversCsvRows());
|
|
});
|
|
|
|
document.getElementById("downloadDriversCsvTemplate")?.addEventListener("click", () => {
|
|
downloadCsvFile("racecore_driver_import_template.csv", [
|
|
["name", "transponder", "class", "brand", "spoken_name", "club", "country"],
|
|
]);
|
|
});
|
|
|
|
document.getElementById("exportCarsCsv")?.addEventListener("click", () => {
|
|
downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows());
|
|
});
|
|
|
|
const bindCsvImport = (selectButtonId, inputId, type) => {
|
|
const input = document.getElementById(inputId);
|
|
document.getElementById(selectButtonId)?.addEventListener("click", () => input?.click());
|
|
input?.addEventListener("change", (event) => {
|
|
const fileInput = event.currentTarget;
|
|
const file = fileInput instanceof HTMLInputElement ? fileInput.files?.[0] : null;
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => importDirectoryCsv(String(reader.result || ""), type);
|
|
reader.onerror = () => importDirectoryCsv("", type);
|
|
reader.readAsText(file);
|
|
});
|
|
};
|
|
bindCsvImport("selectDriversCsv", "importDriversCsv", "drivers");
|
|
bindCsvImport("selectCarsCsv", "importCarsCsv", "cars");
|
|
|
|
document.querySelectorAll("[data-clear-scope]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const scope = String(button.dataset.clearScope || "");
|
|
if (!scope || !window.confirm(t(`settings.clear_${scope}_confirm`))) return;
|
|
button.disabled = true;
|
|
const completed = await runWithSafetyBackup(() => clearDataScope(scope));
|
|
if (!completed) button.disabled = false;
|
|
});
|
|
});
|
|
|
|
document.getElementById("importDirectoryData")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const parsed = JSON.parse(String(reader.result || "{}"));
|
|
importDataPayload(parsed, "directory");
|
|
} catch (error) {
|
|
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
settingsStorageNoticeIsError = true;
|
|
renderView();
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
document.getElementById("exportRacePresets")?.addEventListener("click", () => {
|
|
const payload = {
|
|
...buildExportMeta("race_presets"),
|
|
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),
|
|
};
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = "live_rc_race_presets.json";
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
document.getElementById("importRacePresets")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const parsed = JSON.parse(String(reader.result || "{}"));
|
|
const incoming = Array.isArray(parsed?.racePresets) ? parsed.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [];
|
|
const existingById = new Map((state.settings.racePresets || []).map((preset) => [preset.id, normalizeStoredRacePreset(preset)]));
|
|
incoming.forEach((preset) => {
|
|
existingById.set(preset.id, preset);
|
|
});
|
|
state.settings.racePresets = [...existingById.values()];
|
|
saveState();
|
|
renderView();
|
|
} catch {
|
|
// ignore invalid preset file
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
document.getElementById("ammcForm")?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const config = {
|
|
managedEnabled: form.get("managedEnabled") === "on",
|
|
autoStart: form.get("autoStart") === "on",
|
|
decoderHost: String(form.get("decoderHost") || "").trim(),
|
|
wsPort: Number(form.get("wsPort") || 9000),
|
|
splitEnabled: form.get("splitEnabled") === "on",
|
|
splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(),
|
|
splitWsPort: Number(form.get("splitWsPort") || 9001),
|
|
pitEnabled: form.get("pitEnabled") === "on",
|
|
pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(),
|
|
pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002),
|
|
pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(),
|
|
pitExitWsPort: Number(form.get("pitExitWsPort") || 9003),
|
|
executablePath: String(form.get("executablePath") || "").trim(),
|
|
workingDirectory: String(form.get("workingDirectory") || "").trim(),
|
|
extraArgs: String(form.get("extraArgs") || "").trim(),
|
|
};
|
|
await saveAmmcConfigToBackend(config);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("ammcRefresh")?.addEventListener("click", async () => {
|
|
await refreshAmmcStatus();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("ammcStart")?.addEventListener("click", async () => {
|
|
const formElement = document.getElementById("ammcForm");
|
|
if (formElement instanceof HTMLFormElement) {
|
|
const form = new FormData(formElement);
|
|
await saveAmmcConfigToBackend({
|
|
managedEnabled: form.get("managedEnabled") === "on",
|
|
autoStart: form.get("autoStart") === "on",
|
|
decoderHost: String(form.get("decoderHost") || "").trim(),
|
|
wsPort: Number(form.get("wsPort") || 9000),
|
|
splitEnabled: form.get("splitEnabled") === "on",
|
|
splitDecoderHost: String(form.get("splitDecoderHost") || "").trim(),
|
|
splitWsPort: Number(form.get("splitWsPort") || 9001),
|
|
pitEnabled: form.get("pitEnabled") === "on",
|
|
pitEntryDecoderHost: String(form.get("pitEntryDecoderHost") || "").trim(),
|
|
pitEntryWsPort: Number(form.get("pitEntryWsPort") || 9002),
|
|
pitExitDecoderHost: String(form.get("pitExitDecoderHost") || "").trim(),
|
|
pitExitWsPort: Number(form.get("pitExitWsPort") || 9003),
|
|
executablePath: String(form.get("executablePath") || "").trim(),
|
|
workingDirectory: String(form.get("workingDirectory") || "").trim(),
|
|
extraArgs: String(form.get("extraArgs") || "").trim(),
|
|
});
|
|
}
|
|
await startManagedAmmc();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("ammcStop")?.addEventListener("click", async () => {
|
|
await stopManagedAmmc();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("ammcUseServerWs")?.addEventListener("click", () => {
|
|
state.settings.wsUrl = suggestedWsUrl;
|
|
state.settings.splitDecoderEnabled = Boolean(ammc.config.splitEnabled);
|
|
state.settings.splitWsUrl = suggestedSplitWsUrl;
|
|
state.settings.pitDecoderEnabled = Boolean(ammc.config.pitEnabled);
|
|
state.settings.pitEntryWsUrl = suggestedPitEntryWsUrl;
|
|
state.settings.pitExitWsUrl = suggestedPitExitWsUrl;
|
|
saveState();
|
|
renderView();
|
|
});
|
|
}
|