runtime service

This commit is contained in:
larssand
2026-03-26 19:44:36 +01:00
parent df9c5321b7
commit 9933c9c8f8

316
src/runtime_services.js Normal file
View File

@@ -0,0 +1,316 @@
export function createDefaultAmmcConfigHelper() {
return {
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
executablePath: "",
workingDirectory: "",
extraArgs: "",
};
}
export function getManagedWsUrlHelper({ ammc, getBackendUrl }) {
const port = Number(ammc.config?.wsPort || 9000);
try {
const backendUrl = new URL(getBackendUrl());
return `ws://${backendUrl.hostname}:${port}`;
} catch {
return `ws://127.0.0.1:${port}`;
}
}
export async function loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
ammc.status = payload.status || null;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
ammc.loaded = false;
}
}
export async function saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config) });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.config = { ...createDefaultAmmcConfig(), ...(payload.config || {}) };
ammc.status = payload.status || ammc.status;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_save_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function refreshAmmcStatusHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/status`);
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function startManagedAmmcHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/start`, { method: "POST" });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_start_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/stop`, { method: "POST" });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_stop_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export function applyPersistedStateHelper(persisted, deps) {
const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, applyTheme } = deps;
state.classes = persisted.classes || [];
state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name);
state.events = (persisted.events || []).map((event) => normalizeEvent(event));
state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session));
state.resultsBySession = persisted.resultsBySession || {};
state.activeSessionId = persisted.activeSessionId || null;
state.settings = {
wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000",
backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(),
language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE,
theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME,
autoReconnect: persisted.settings?.autoReconnect !== false,
audioEnabled: persisted.settings?.audioEnabled !== false,
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
speakerBestLapCueEnabled: persisted.settings?.speakerBestLapCueEnabled !== false,
speakerTop3CueEnabled: persisted.settings?.speakerTop3CueEnabled === true,
speakerSessionStartCueEnabled: persisted.settings?.speakerSessionStartCueEnabled !== false,
clubName: persisted.settings?.clubName || state.settings.clubName || "JMK RB RaceController",
clubTagline: persisted.settings?.clubTagline || state.settings.clubTagline || "RC Timing System",
pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController",
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
racePresets: Array.isArray(persisted.settings?.racePresets) ? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : Array.isArray(state.settings?.racePresets) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [],
obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay),
};
applyTheme();
}
export async function hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }) {
try {
const res = await fetch(`${getBackendUrl()}/api/state`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (payload && payload.state && typeof payload.state === "object") {
applyPersistedState(payload.state);
saveState({ skipBackend: true });
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
}
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.backend_offline", { msg: error instanceof Error ? error.message : String(error) });
}
}
export function scheduleBackendSyncHelper({ getBackendSyncTimer, setBackendSyncTimer, syncStateToBackend }) {
clearTimeout(getBackendSyncTimer());
setBackendSyncTimer(setTimeout(() => { syncStateToBackend(); }, 350));
}
export async function syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion, getSyncedStateVersion, setSyncedStateVersion, setLocalStateDirty }) {
const syncVersion = getLocalStateVersion();
try {
const res = await fetch(`${getBackendUrl()}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPersistableState()) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
backend.available = true;
backend.lastError = "";
backend.lastSyncAt = new Date().toISOString();
setSyncedStateVersion(Math.max(getSyncedStateVersion(), syncVersion));
if (syncVersion === getLocalStateVersion()) setLocalStateDirty(false);
} catch (error) {
backend.available = false;
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function pingBackendHelper({ backend, t, getBackendUrl }) {
try {
const res = await fetch(`${getBackendUrl()}/api/health`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion, setBaselineAppVersion }) {
try {
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
if (!res.ok) return;
const payload = await res.json();
const key = `${payload.revision}:${payload.updatedAt}`;
if (!getBaselineAppVersion()) { setBaselineAppVersion(key); return; }
if (key !== getBaselineAppVersion()) window.location.reload();
} catch {}
}
export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer, setAppVersionPollTimer }) {
if (!window.location.protocol.startsWith("http")) return;
clearInterval(getAppVersionPollTimer());
checkAppVersion();
setAppVersionPollTimer(setInterval(checkAppVersion, 3000));
}
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
clearInterval(getOverlaySyncTimer());
setOverlaySyncTimer(setInterval(async () => {
if (!getLocalStateDirty()) await hydrateFromBackend();
if (getCurrentView() === "overlay") renderView();
}, 800));
}
export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlayRotationTimer, getOverlayRotationIndex, setOverlayRotationIndex, getCurrentView, getOverlayViewMode, renderView }) {
clearInterval(getOverlayRotationTimer());
setOverlayRotationTimer(setInterval(() => {
setOverlayRotationIndex((getOverlayRotationIndex() + 1) % 3);
if (getCurrentView() === "overlay" && getOverlayViewMode() === "leaderboard") renderView();
}, 8000));
}
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, renderOverlay }) {
clearInterval(getOverlayLiveRefreshTimer());
setOverlayLiveRefreshTimer(setInterval(() => {
if (getCurrentView() === "overlay" && ["leaderboard", "tv", "team"].includes(getOverlayViewMode())) renderOverlay();
}, 250));
}
export function ensureAudioContextHelper({ state, getAudioCtx, setAudioCtx }) {
if (!state.settings.audioEnabled) return null;
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
if (!getAudioCtx()) setAudioCtx(new Ctx());
const ctx = getAudioCtx();
if (ctx.state === "suspended") ctx.resume().catch(() => {});
return ctx;
}
function playTone({ ensureAudioContext, type, startFreq, ramps = [], attack = 0.01, peak = 0.08, release = 0.2, stopAt = 0.25 }) {
const ctx = ensureAudioContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(startFreq, ctx.currentTime);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(peak, ctx.currentTime + attack);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + release);
ramps.forEach(([freq, at, kind]) => {
if (kind === 'linear') osc.frequency.linearRampToValueAtTime(freq, ctx.currentTime + at);
else osc.frequency.setValueAtTime(freq, ctx.currentTime + at);
});
osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + stopAt);
}
export function playPassingBeepHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'square', startFreq: 1320, peak: 0.08, release: 0.14, stopAt: 0.16 }); }
export function playFinishSirenHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sawtooth', startFreq: 720, peak: 0.12, release: 1.2, stopAt: 1.22, attack: 0.03, ramps: [[1280,0.28,'linear'],[720,0.56,'linear'],[1280,0.84,'linear'],[720,1.12,'linear']] }); }
export function playLeaderCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 880, peak: 0.09, release: 0.26, stopAt: 0.28, ramps: [[1320,0.12,'linear'],[1760,0.24,'linear']] }); }
export function playStartCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'triangle', startFreq: 520, peak: 0.08, release: 0.4, stopAt: 0.42, ramps: [[1040,0.4,'linear']] }); }
export function playBestLapCueHelper({ ensureAudioContext }) { playTone({ ensureAudioContext, type: 'sine', startFreq: 1540, peak: 0.07, release: 0.22, stopAt: 0.24, ramps: [[1980,0.2,'linear']] }); }
export function pushOverlayEventHelper(type, label, deps) {
const { uid, state, t, getOverlayEvents, setOverlayEvents, getOverlayMode, getCurrentView, getOverlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue } = deps;
const events = getOverlayEvents();
events.unshift({ id: uid("overlay"), type, label, ts: Date.now() });
setOverlayEvents(events.length > 12 ? events.slice(0, 12) : events);
if (getOverlayMode() && getCurrentView() === "overlay" && getOverlayViewMode() === "speaker") {
if (type === "leader" && state.settings.speakerLeaderCueEnabled) playLeaderCue();
else if (type === "passing" && state.settings.speakerPassingCueEnabled) playPassingBeep();
else if (type === "finish" && state.settings.speakerFinishCueEnabled) playFinishSiren();
else if (type === "start" && state.settings.speakerSessionStartCueEnabled) playStartCue();
else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) playBestLapCue();
else if (type === "top3" && state.settings.speakerTop3CueEnabled) playLeaderCue();
}
}
export function speakTextHelper(text, { state, currentLanguage }) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US";
utterance.rate = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
export function announcePassingHelper(entry, { state, t, playPassingBeep, speakText }) {
if (!state.settings.audioEnabled) return;
if (state.settings.passingSoundMode === "beep") { playPassingBeep(); return; }
if (state.settings.passingSoundMode === "name") speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver"));
}
export function announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren }) {
if (!state.settings.audioEnabled || !state.settings.finishVoiceEnabled) {
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name}${t("timing.race_finished")}`);
return;
}
const session = getActiveSession();
if (session) pushOverlayEvent("finish", `${session.name}${t("timing.race_finished")}`);
playFinishSiren();
}
export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId, announceRaceFinished }) {
const active = getActiveSession();
if (!active || active.status !== "running") return { changed: false };
if (isUntimedSession(active)) return { changed: false };
const timing = getSessionTiming(active);
if (timing.remainingMs > 0) return { changed: false };
if (Number(active.followUpSec || 0) > 0) {
if (!active.followUpStartedAt) { active.followUpStartedAt = Date.now(); saveState(); return { changed: true }; }
if (timing.followUpRemainingMs > 0) return { changed: false };
}
active.status = "finished";
active.endedAt = Date.now();
active.finishedByTimer = true;
active.followUpStartedAt = null;
if (getLastFinishAnnouncementSessionId() !== active.id) { announceRaceFinished(); setLastFinishAnnouncementSessionId(active.id); }
saveState();
return { changed: true };
}
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, updateHeaderState }) {
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
const timerState = handleSessionTimerTick();
const active = getActiveSession();
if (getCurrentView() === "timing" && active && (active.status === "running" || timerState.changed)) renderView();
if (getCurrentView() === "dashboard" && timerState.changed) renderView();
if (getOverlayMode() && getCurrentView() === "overlay" && active) renderView();
if (timerState.changed) updateHeaderState();
}