diff --git a/src/app.js b/src/app.js index a6d1d65..d24a74a 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,8 @@ import { renderDashboardView, renderClassesView, renderDriversView, renderCarsVi import { renderGuideView, renderOverlayPageView } from "./misc_views.js"; import { getSessionsForEventHelper, getModeLabelHelper, normalizeStartModeHelper, getStartModeLabelHelper, getClassNameHelper, getEventNameHelper, renderAssignmentListView, renderSessionsTableView } from "./event_common.js"; +import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, speakTextHelper, announcePassingHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; +import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; import { renderTimingView, renderJudgingView } from "./timing_views.js"; @@ -110,7 +112,78 @@ const getClassName = (classId) => getClassNameHelper(state, classId, { t }); const getEventName = (eventId) => getEventNameHelper(state, eventId, { t }); const renderAssignmentList = (eventId) => renderAssignmentListView(eventId, { state, t, escapeHtml, getSessionsForEvent, getSessionTypeLabel, saveState, renderAssignmentList }); const renderSessionsTable = (sessions) => renderSessionsTableView(sessions, { t, renderTable, escapeHtml, getEventName, getSessionTypeLabel, getStatusLabel, getModeLabel }); +const createDefaultAmmcConfig = () => createDefaultAmmcConfigHelper(); +const getManagedWsUrl = () => getManagedWsUrlHelper({ ammc, getBackendUrl }); +const loadAmmcConfigFromBackend = () => loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }); +const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }); +const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl }); +const startManagedAmmc = () => startManagedAmmcHelper({ ammc, t, getBackendUrl }); +const stopManagedAmmc = () => stopManagedAmmcHelper({ ammc, t, getBackendUrl }); +const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, applyTheme }); +const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }); +const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend }); +const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } }); +const pingBackend = () => pingBackendHelper({ backend, t, getBackendUrl }); +const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion: () => baselineAppVersion, setBaselineAppVersion: (value) => { baselineAppVersion = value; } }); +const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } }); +const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView }); +const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView }); +const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderOverlay }); +const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } }); +const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext }); +const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext }); +const playLeaderCue = () => playLeaderCueHelper({ ensureAudioContext }); +const playStartCue = () => playStartCueHelper({ ensureAudioContext }); +const playBestLapCue = () => playBestLapCueHelper({ ensureAudioContext }); +const pushOverlayEvent = (type, label) => pushOverlayEventHelper(type, label, { uid, state, t, getOverlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, getOverlayMode: () => overlayMode, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue }); +const speakText = (text) => speakTextHelper(text, { state, currentLanguage }); +const announcePassing = (entry) => announcePassingHelper(entry, { state, t, playPassingBeep, speakText }); +const announceRaceFinished = () => announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren }); +const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, announceRaceFinished }); +const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState }); const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog }); +const connectDecoder = () => connectDecoderHelper({ + state, + t, + saveState, + getWsClient: () => wsClient, + setWsClient: (value) => { wsClient = value; }, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (value) => { reconnectTimer = value; }, + getCurrentView: () => currentView, + renderView, + updateConnectionBadge, + getActiveSession, + processDecoderMessage, + disconnectDecoder, +}); +const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, { + state, + saveState, + getWsClient: () => wsClient, + setWsClient: (value) => { wsClient = value; }, + getReconnectTimer: () => reconnectTimer, + setReconnectTimer: (value) => { reconnectTimer = value; }, + updateConnectionBadge, +}); +const processDecoderMessage = (msg) => processDecoderMessageHelper(msg, { + state, + t, + getActiveSession, + ensureSessionResult, + normalizeStartMode, + getSessionLapWindow, + findEventTeamForPassing, + persistPassingToBackend, + pushOverlayEvent, + buildLeaderboard, + formatLap, + announcePassing, + saveState, + lastOverlayLeaderKeyBySession, + lastOverlayTop3BySession, + lastOverlayBestLapByKey, +}); const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell }); const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName }); @@ -2480,323 +2553,6 @@ function getBackendUrl() { return String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, ""); } -function createDefaultAmmcConfig() { - return { - managedEnabled: false, - autoStart: false, - decoderHost: "", - wsPort: 9000, - executablePath: "", - workingDirectory: "", - extraArgs: "", - }; -} - -function getManagedWsUrl() { - 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}`; - } -} - -async function loadAmmcConfigFromBackend() { - 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; - } -} - -async function saveAmmcConfigToBackend(config) { - 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) }); - } -} - -async function refreshAmmcStatus() { - 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) }); - } -} - -async function startManagedAmmc() { - 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) }); - } -} - -async function stopManagedAmmc() { - 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) }); - } -} - -async function hydrateFromBackend() { - 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) }); - } -} - -function applyPersistedState(persisted) { - 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(); -} - -function normalizeSession(session) { - return { - ...session, - startMode: session?.startMode || "mass", - staggerGapSec: Number(session?.staggerGapSec || 5) || 5, - seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0), - seedMethod: ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) - ? String(session.seedMethod).toLowerCase() - : "best_sum", - followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0), - followUpStartedAt: Number(session?.followUpStartedAt || 0) || null, - driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [], - manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [], - gridCustomized: Boolean(session?.gridCustomized), - reservedBumpSlots: Math.max(0, Number(session?.reservedBumpSlots || 0) || 0), - generated: Boolean(session?.generated), - assignments: Array.isArray(session?.assignments) ? session.assignments : [], - }; -} - -function normalizeDriver(driver) { - const item = driver && typeof driver === "object" ? driver : {}; - return { - id: item.id || uid("driver"), - name: String(item.name || "").trim(), - classId: String(item.classId || ""), - brand: String(item.brand || "").trim(), - transponder: String(item.transponder || "").trim(), - }; -} - -function normalizeCar(car) { - const item = car && typeof car === "object" ? car : {}; - return { - id: item.id || uid("car"), - name: String(item.name || "").trim(), - brand: String(item.brand || "").trim(), - transponder: String(item.transponder || "").trim(), - }; -} - -function scheduleBackendSync() { - clearTimeout(backendSyncTimer); - backendSyncTimer = setTimeout(() => { - syncStateToBackend(); - }, 350); -} - -async function syncStateToBackend() { - const syncVersion = localStateVersion; - 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(); - syncedStateVersion = Math.max(syncedStateVersion, syncVersion); - if (syncVersion === localStateVersion) { - localStateDirty = false; - } - } catch (error) { - backend.available = false; - backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) }); - } -} - -async function pingBackend() { - 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) }); - } -} - -function startAppVersionPolling() { - if (!window.location.protocol.startsWith("http")) { - return; - } - - clearInterval(appVersionPollTimer); - checkAppVersion(); - appVersionPollTimer = setInterval(checkAppVersion, 3000); -} - -async function checkAppVersion() { - 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 (!baselineAppVersion) { - baselineAppVersion = key; - return; - } - if (key !== baselineAppVersion) { - window.location.reload(); - } - } catch { - // silent - normal when backend is temporarily unavailable - } -} - -function startOverlaySync() { - clearInterval(overlaySyncTimer); - overlaySyncTimer = setInterval(async () => { - if (!localStateDirty) { - await hydrateFromBackend(); - } - if (currentView === "overlay") { - renderView(); - } - }, 800); -} - -function startOverlayRotation() { - clearInterval(overlayRotationTimer); - overlayRotationTimer = setInterval(() => { - overlayRotationIndex = (overlayRotationIndex + 1) % 3; - if (currentView === "overlay" && overlayViewMode === "leaderboard") { - renderView(); - } - }, 8000); -} - -function startOverlayLiveRefresh() { - clearInterval(overlayLiveRefreshTimer); - overlayLiveRefreshTimer = setInterval(() => { - if (currentView === "overlay" && ["leaderboard", "tv", "team"].includes(overlayViewMode)) { - renderOverlay(); - } - }, 250); -} - function renderNav() { if (overlayMode) { dom.nav.innerHTML = ""; @@ -2981,243 +2737,6 @@ function updateConnectionBadge() { dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`; } -function tickClock() { - dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US"); - const timerState = handleSessionTimerTick(); - const active = getActiveSession(); - if (currentView === "timing" && active && (active.status === "running" || timerState.changed)) { - renderView(); - } - if (currentView === "dashboard" && timerState.changed) { - renderView(); - } - if (overlayMode && currentView === "overlay" && active) { - renderView(); - } - if (timerState.changed) { - updateHeaderState(); - } -} - -function ensureAudioContext() { - if (!state.settings.audioEnabled) { - return null; - } - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) { - return null; - } - if (!audioCtx) { - audioCtx = new Ctx(); - } - if (audioCtx.state === "suspended") { - audioCtx.resume().catch(() => {}); - } - return audioCtx; -} - -function playPassingBeep() { - const ctx = ensureAudioContext(); - if (!ctx) { - return; - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "square"; - osc.frequency.setValueAtTime(1320, ctx.currentTime); - gain.gain.setValueAtTime(0.001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.16); -} - -function playFinishSiren() { - const ctx = ensureAudioContext(); - if (!ctx) { - return; - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sawtooth"; - gain.gain.setValueAtTime(0.001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.03); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2); - osc.frequency.setValueAtTime(720, ctx.currentTime); - osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.28); - osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 0.56); - osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.84); - osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 1.12); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 1.22); -} - -function playLeaderCue() { - const ctx = ensureAudioContext(); - if (!ctx) { - return; - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "triangle"; - gain.gain.setValueAtTime(0.001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.09, ctx.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.26); - osc.frequency.setValueAtTime(880, ctx.currentTime); - osc.frequency.linearRampToValueAtTime(1320, ctx.currentTime + 0.12); - osc.frequency.linearRampToValueAtTime(1760, ctx.currentTime + 0.24); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.28); -} - -function playStartCue() { - const ctx = ensureAudioContext(); - if (!ctx) { - return; - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "triangle"; - gain.gain.setValueAtTime(0.001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); - osc.frequency.setValueAtTime(520, ctx.currentTime); - osc.frequency.linearRampToValueAtTime(1040, ctx.currentTime + 0.4); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.42); -} - -function playBestLapCue() { - const ctx = ensureAudioContext(); - if (!ctx) { - return; - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sine"; - gain.gain.setValueAtTime(0.001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.07, ctx.currentTime + 0.01); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22); - osc.frequency.setValueAtTime(1540, ctx.currentTime); - osc.frequency.linearRampToValueAtTime(1980, ctx.currentTime + 0.2); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - osc.stop(ctx.currentTime + 0.24); -} - -function pushOverlayEvent(type, label) { - overlayEvents.unshift({ - id: uid("overlay"), - type, - label, - ts: Date.now(), - }); - if (overlayEvents.length > 12) { - overlayEvents = overlayEvents.slice(0, 12); - } - if (overlayMode && currentView === "overlay" && overlayViewMode === "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(); - } - } -} - -function speakText(text) { - 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); -} - -function announcePassing(entry) { - 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")); - } -} - -function announceRaceFinished() { - 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(); -} - -function handleSessionTimerTick() { - 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 (lastFinishAnnouncementSessionId !== active.id) { - announceRaceFinished(); - lastFinishAnnouncementSessionId = active.id; - } - saveState(); - return { changed: true }; -} - function renderEvents() { renderEventWorkspace("track"); } @@ -5100,557 +4619,11 @@ function renderEventManager(eventId) { } } -function getQuickAddState(transponder) { - const normalized = String(transponder || "").trim(); - const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null; - const car = state.cars.find((item) => String(item.transponder || "").trim() === normalized) || null; - return { - transponder: normalized, - hasDriver: Boolean(driver), - hasCar: Boolean(car), - }; -} - -function getPreferredClassId(session) { - const event = state.events.find((item) => item.id === session?.eventId); - if (event?.classId) { - return event.classId; - } - return state.classes[0]?.id || ""; -} - -function beginQuickAddDraft(session, type, transponder) { - const normalized = String(transponder || "").trim(); - if (!normalized) { - return; - } - if (type === "driver" && state.drivers.some((item) => String(item.transponder || "").trim() === normalized)) { - return; - } - if (type === "car" && state.cars.some((item) => String(item.transponder || "").trim() === normalized)) { - return; - } - quickAddDraft = { - type, - transponder: normalized, - classId: getPreferredClassId(session), - name: type === "driver" ? normalized : `Car ${normalized}`, - }; - renderView(); -} - -function renderQuickAddActions(session, transponder, idPrefix) { - const quickState = getQuickAddState(transponder); - if (!quickState.transponder || (quickState.hasDriver && quickState.hasCar)) { - return ""; - } - return ` -
${t("timing.no_laps")}
`; - } - - return renderTable( - [ - t("table.pos"), - t("table.driver"), - t("table.car"), - t("table.transponder"), - t("table.laps"), - t("table.result"), - t("table.last_lap"), - t("table.best_lap"), - t("table.leader_gap"), - t("table.ahead_gap"), - t("table.own_delta"), - "", - ], - rows.map((row, idx) => { - const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; - return ` -${t("timing.no_session_selected")}
`; - } - const result = ensureSessionResult(session.id); - const items = result.passings.slice(-20).reverse(); - if (!items.length) { - return `${t("timing.no_passings")}
`; - } - - return renderTable( - [t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.last_lap"), t("table.status"), ""], - items.map((p, index) => { - return ` -${t("common.no_entries")}
`; diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js new file mode 100644 index 0000000..a0c4812 --- /dev/null +++ b/src/decoder_runtime.js @@ -0,0 +1,367 @@ +function parseRtcTime(value) { + if (!value || typeof value !== "string") { + return null; + } + const ts = Date.parse(value); + return Number.isFinite(ts) ? ts : null; +} + +function resolveCompetitor(session, transponder, deps) { + const { state, t, findEventTeamForPassing } = deps; + const sessionType = String(session?.type || "").toLowerCase(); + const isOpenPractice = sessionType === "open_practice"; + const isFreePractice = sessionType === "free_practice"; + const isOpenMonitoringSession = isOpenPractice || isFreePractice; + const event = state.events.find((item) => item.id === session?.eventId) || null; + if (session.mode === "track") { + const matchingAssignments = (session.assignments || []).filter((a) => { + const car = state.cars.find((c) => c.id === a.carId); + return car?.transponder === transponder; + }); + + if (matchingAssignments.length > 1) { + return { + key: `track_ambiguous_${transponder}`, + driverId: null, + driverName: `Ambiguous TP ${transponder}`, + carId: null, + carName: t("common.unknown_car"), + }; + } + + const assignment = matchingAssignments[0]; + if (assignment) { + const driver = state.drivers.find((d) => d.id === assignment.driverId); + const car = state.cars.find((c) => c.id === assignment.carId); + return { + key: `track_${assignment.id}`, + driverId: driver?.id || null, + driverName: driver?.name || t("common.unknown_driver"), + carId: car?.id || null, + carName: car?.name || t("common.unknown_car"), + }; + } + + return { + key: `track_tp_${transponder}`, + driverId: null, + driverName: t("common.unassigned_driver"), + carId: null, + carName: t("common.unknown_car"), + }; + } + + if (session.mode === "race" && sessionType === "team_race") { + const driver = state.drivers.find((d) => d.transponder === transponder) || null; + const car = state.cars.find((c) => c.transponder === transponder) || null; + const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null; + if (!team) { + return { + key: `ignore_team_${transponder}`, + ignore: true, + }; + } + + const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean); + return { + key: `team_${team.id}`, + teamId: team.id, + teamName: team.name, + displayName: team.name, + subLabel: memberBits.join(" • ") || transponder, + driverId: driver?.id || null, + driverName: driver?.name || team.name, + carId: car?.id || null, + carName: car?.name || t("common.unknown_car"), + }; + } + + const driver = state.drivers.find((d) => d.transponder === transponder); + if (driver) { + if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) { + return { + key: `ignore_${driver.id}`, + ignore: true, + }; + } + return { + key: `driver_${driver.id}`, + driverId: driver.id, + driverName: driver.name, + displayName: driver.name, + subLabel: driver.transponder || "", + carId: null, + carName: t("common.driver_car"), + }; + } + + return { + key: `driver_tp_${transponder}`, + driverId: null, + driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), + displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), + subLabel: transponder, + carId: null, + carName: t("common.unknown_car"), + }; +} + +export function connectDecoderHelper(deps) { + const { + state, t, saveState, getWsClient, setWsClient, getReconnectTimer, setReconnectTimer, + getCurrentView, renderView, updateConnectionBadge, processDecoderMessage, disconnectDecoder, + } = deps; + + disconnectDecoder({ silent: true }); + state.decoder.lastError = ""; + saveState(); + + const url = state.settings.wsUrl; + try { + setWsClient(new WebSocket(url)); + } catch (error) { + state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); + saveState(); + renderView(); + return; + } + + const wsClient = getWsClient(); + wsClient.onopen = () => { + state.decoder.connected = true; + state.decoder.lastError = ""; + saveState(); + updateConnectionBadge(); + if (["timing", "settings", "overlay"].includes(getCurrentView())) { + renderView(); + } + }; + + wsClient.onmessage = (event) => { + state.decoder.lastMessageAt = Date.now(); + + let parsed; + try { + parsed = JSON.parse(String(event.data)); + } catch { + return; + } + + if (Array.isArray(parsed)) { + parsed.forEach(processDecoderMessage); + } else { + processDecoderMessage(parsed); + } + + saveState(); + updateConnectionBadge(); + if (["timing", "dashboard", "overlay"].includes(getCurrentView())) { + renderView(); + } + }; + + wsClient.onclose = () => { + state.decoder.connected = false; + saveState(); + updateConnectionBadge(); + + if (state.settings.autoReconnect) { + clearTimeout(getReconnectTimer()); + setReconnectTimer(setTimeout(() => { + if (!state.decoder.connected) { + connectDecoderHelper(deps); + } + }, 2000)); + } + + if (["timing", "settings", "overlay"].includes(getCurrentView())) { + renderView(); + } + }; + + wsClient.onerror = () => { + state.decoder.lastError = t("error.decoder_connection"); + saveState(); + updateConnectionBadge(); + if (["timing", "settings", "overlay"].includes(getCurrentView())) { + renderView(); + } + }; +} + +export function disconnectDecoderHelper(options = {}, deps) { + const { state, saveState, getWsClient, setWsClient, getReconnectTimer, updateConnectionBadge } = deps; + clearTimeout(getReconnectTimer()); + const wsClient = getWsClient(); + if (wsClient) { + wsClient.onopen = null; + wsClient.onmessage = null; + wsClient.onclose = null; + wsClient.onerror = null; + wsClient.close(); + setWsClient(null); + } + state.decoder.connected = false; + if (!options.silent) { + saveState(); + } + updateConnectionBadge(); +} + +export function processDecoderMessageHelper(msg, deps) { + const { + state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow, + findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard, + formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession, + lastOverlayTop3BySession, lastOverlayBestLapByKey, + } = deps; + + if (!msg || typeof msg !== "object") { + return; + } + + const type = String(msg.msg || msg.type || "").toUpperCase(); + if (type !== "PASSING") { + return; + } + + const session = getActiveSession(); + if (!session || session.status !== "running") { + return; + } + + const timestamp = parseRtcTime(msg.rtc_time) || Date.now(); + const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim(); + if (!transponder) { + return; + } + + const result = ensureSessionResult(session.id); + const competitor = resolveCompetitor(session, transponder, { state, t, findEventTeamForPassing }); + if (competitor.ignore) { + return; + } + const key = competitor.key; + + if (!result.competitors[key]) { + result.competitors[key] = { + key, + teamId: competitor.teamId || null, + teamName: competitor.teamName || "", + driverId: competitor.driverId, + driverName: competitor.driverName, + displayName: competitor.displayName || competitor.driverName, + subLabel: competitor.subLabel || competitor.carName || "", + carId: competitor.carId, + carName: competitor.carName, + transponder, + laps: 0, + lastLapMs: null, + bestLapMs: null, + startTimestamp: null, + lastTimestamp: null, + }; + } + + const entry = result.competitors[key]; + entry.teamId = competitor.teamId || entry.teamId || null; + entry.teamName = competitor.teamName || entry.teamName || ""; + entry.displayName = competitor.displayName || entry.displayName || competitor.driverName; + entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || ""; + entry.driverId = competitor.driverId ?? entry.driverId; + entry.driverName = competitor.driverName || entry.driverName; + entry.carId = competitor.carId ?? entry.carId; + entry.carName = competitor.carName || entry.carName; + entry.transponder = transponder; + const startMode = normalizeStartMode(session.startMode); + + if (startMode === "staggered" && !entry.startTimestamp) { + entry.startTimestamp = timestamp; + entry.lastTimestamp = timestamp; + announcePassing(entry); + saveState(); + return; + } + + if (!entry.startTimestamp) { + entry.startTimestamp = session.startedAt || timestamp; + } + + const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp; + const lapMs = Math.max(0, timestamp - baseTs); + const { minLapMs, maxLapMs } = getSessionLapWindow(session); + let validLap = true; + let invalidReason = ""; + if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) { + validLap = false; + invalidReason = "below_min"; + } else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) { + validLap = false; + invalidReason = "above_max"; + } + + const passing = { + timestamp, + transponder, + teamId: entry.teamId, + teamName: entry.teamName, + driverId: entry.driverId, + driverName: entry.driverName, + displayName: entry.displayName, + subLabel: entry.subLabel, + carId: entry.carId, + carName: entry.carName, + competitorKey: key, + lapMs, + validLap, + invalidReason, + strength: msg.strength, + loopId: String(msg.loop_id || ""), + resend: Boolean(msg.resend), + }; + + if (!validLap) { + result.passings.push(passing); + if (invalidReason === "above_max") { + entry.lastTimestamp = timestamp; + } + persistPassingToBackend(session.id, passing); + saveState(); + return; + } + + entry.laps += 1; + entry.lastLapMs = lapMs; + entry.lastTimestamp = timestamp; + + if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { + entry.bestLapMs = lapMs; + } + + result.passings.push(passing); + persistPassingToBackend(session.id, passing); + pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`); + const leaderboard = buildLeaderboard(session); + const leader = leaderboard[0]; + if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) { + lastOverlayLeaderKeyBySession[session.id] = leader.key; + pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`); + } + if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) { + const bestKey = `${session.id}:${entry.key}`; + const previousBest = lastOverlayBestLapByKey[bestKey]; + if (!previousBest || entry.bestLapMs < previousBest) { + lastOverlayBestLapByKey[bestKey] = entry.bestLapMs; + pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`); + } + } + const top3Keys = leaderboard.slice(0, 3).map((row) => row.key); + const previousTop3 = lastOverlayTop3BySession[session.id] || []; + if (top3Keys.join("|") !== previousTop3.join("|")) { + lastOverlayTop3BySession[session.id] = top3Keys; + if (previousTop3.length) { + pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`); + } + } + announcePassing(entry); +}