Files
Live_RC/src/settings.js
2026-09-01 07:07:23 +02:00

959 lines
55 KiB
JavaScript

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,
} = 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 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>
<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>
<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("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.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>
`;
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.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("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();
});
}