From 9933c9c8f84b89aca22dff3adde57c65d686bf82 Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 26 Mar 2026 19:44:36 +0100 Subject: [PATCH] runtime service --- src/runtime_services.js | 316 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/runtime_services.js diff --git a/src/runtime_services.js b/src/runtime_services.js new file mode 100644 index 0000000..ab8d0a9 --- /dev/null +++ b/src/runtime_services.js @@ -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(); +}