runtime service
This commit is contained in:
316
src/runtime_services.js
Normal file
316
src/runtime_services.js
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user