// web/src/app.ts - Оркестратор приложения import { fetchHealth, fetchHistory } from "./data.js"; import { state, addSignals, resetBufferTracking, updatePeakHolder, updateSignalPeakHolder, setPeakHolderListener } from "./state.js"; import { initDOM, setServerTime, setStatus, setMeta, setBars, setSignal, setAudioIndicator, DOM, setChannelValue, setChannelCurrentValue, channelElements } from "./ui.js"; import { initAccordion } from "./accordion.js"; import { drawChart } from "./chart.js"; import { initResize } from "./layout.js"; import { initCamera } from "./camera.js"; import { audioEngine } from "./audio.js"; let lastChart: number = 0; let connectionLost: boolean = false; let consecutiveErrors: number = 0; const MAX_ERRORS: number = 3; let pollTimer: number | null = null; let lastSignalLevel: number = 0; // ===== ИНИЦИАЛИЗАЦИЯ РЕГУЛЯТОРА ГРОМКОСТИ ===== function initVolumeControl(): void { const slider = document.getElementById('volumeSlider') as HTMLInputElement | null; const trackFill = document.getElementById('volumeTrackFill') as HTMLElement | null; const indicator = document.getElementById('audio-indicator'); const wrapper = document.getElementById('audioWrapper'); if (!slider || !trackFill || !indicator) return; // Функция обновления заливки function updateTrackFill(volume: number): void { if (trackFill) { trackFill.style.width = `${volume}%`; } } // Функция обновления индикатора в зависимости от громкости и уровня сигнала function updateIndicator(volume: number, signalLevel: number): void { if (!indicator) return; if (volume === 0) { indicator.textContent = '🔇'; indicator.classList.add('muted'); indicator.classList.remove('active'); } else if (signalLevel > 0) { indicator.textContent = '🔊'; indicator.classList.remove('muted'); indicator.classList.add('active'); } else { indicator.textContent = '🔇'; indicator.classList.add('muted'); indicator.classList.remove('active'); } } // Устанавливаем начальное состояние (звук выключен) audioEngine.setVolume(0); updateTrackFill(0); lastSignalLevel = 0; updateIndicator(0, 0); // Обработчик изменения громкости slider.addEventListener('input', () => { const volume = parseInt(slider.value); updateTrackFill(volume); audioEngine.setVolume(volume); if (volume === 0) { // Громкость 0 - звук выключен updateIndicator(0, 0); if (lastSignalLevel > 0) { audioEngine.updateLevel(0); } } else { // Громкость > 0 - показываем состояние сигнала updateIndicator(volume, lastSignalLevel); if (lastSignalLevel > 0) { audioEngine.updateLevel(lastSignalLevel); } } }); // Для тач-устройств if (wrapper) { wrapper.addEventListener('click', (e) => { const control = document.getElementById('volumeControl'); if (control && window.innerWidth <= 768) { control.classList.toggle('visible'); e.stopPropagation(); } }); document.addEventListener('click', (e) => { const control = document.getElementById('volumeControl'); if (control && window.innerWidth <= 768) { if (!wrapper.contains(e.target as Node)) { control.classList.remove('visible'); } } }); } // Сохраняем настройки const savedVolume = localStorage.getItem('gport_volume'); if (savedVolume) { const vol = parseInt(savedVolume); if (vol >= 0 && vol <= 100) { slider.value = String(vol); slider.dispatchEvent(new Event('input')); } } slider.addEventListener('change', () => { localStorage.setItem('gport_volume', slider.value); }); } // ================= INIT ================= window.addEventListener("DOMContentLoaded", () => { initDOM(); initAccordion(); initResize(); initCamera(); initVolumeControl(); document.addEventListener('click', () => { audioEngine.updateLevel(0); }, { once: true }); setPeakHolderListener(() => { if (!connectionLost) { setBars(state.peakValue); setSignal(state.peakSignalLevel); const signalLevel = state.peakSignalLevel; const currentVolume = audioEngine.getVolume(); if (signalLevel !== lastSignalLevel) { lastSignalLevel = signalLevel; // Обновляем индикатор в зависимости от громкости и уровня сигнала const indicator = document.getElementById('audio-indicator'); if (indicator) { if (currentVolume > 0 && signalLevel > 0) { indicator.textContent = '🔊'; indicator.classList.remove('muted'); indicator.classList.add('active'); audioEngine.updateLevel(signalLevel); } else if (currentVolume > 0 && signalLevel === 0) { indicator.textContent = '🔇'; indicator.classList.add('muted'); indicator.classList.remove('active'); audioEngine.updateLevel(0); } else { indicator.textContent = '🔇'; indicator.classList.add('muted'); indicator.classList.remove('active'); audioEngine.updateLevel(0); } } setAudioIndicator(signalLevel > 0 && currentVolume > 0, signalLevel); } } }); }); document.addEventListener('DOMContentLoaded', () => { const closeBtn = document.getElementById('camCloseBtn'); const toggleBtn = document.getElementById('camToggle'); if (closeBtn) { closeBtn.addEventListener('click', () => { if (toggleBtn) { toggleBtn.click(); } }); } }); document.addEventListener("keydown", (e: KeyboardEvent) => { const leftPanel = document.getElementById("leftPanel"); const rightPanel = document.getElementById("rightPanel"); switch (e.key.toLowerCase()) { case 'c': const camBtn = document.getElementById('camToggle'); if (camBtn) camBtn.click(); break; case 'f': if (leftPanel) { leftPanel.style.width = "100%"; if (rightPanel) rightPanel.style.display = "none"; } break; case 'escape': if (rightPanel) rightPanel.style.display = "flex"; if (leftPanel && rightPanel) { const total = window.innerWidth; const leftWidth = total * 0.6; leftPanel.style.flex = `0 0 ${leftWidth}px`; rightPanel.style.flex = `0 0 ${total - leftWidth}px`; } break; case 'm': if (lastSignalLevel > 0 && audioEngine.getVolume() > 0) { audioEngine.updateLevel(0); setAudioIndicator(false); window._mutedLevel = lastSignalLevel; lastSignalLevel = 0; } else { const level = window._mutedLevel || state.peakSignalLevel; if (level > 0 && audioEngine.getVolume() > 0) { audioEngine.updateLevel(level); lastSignalLevel = level; setAudioIndicator(true, level); } } break; } }); // ================= UPDATE ================= async function update(): Promise { try { const [health, history] = await Promise.all([ fetchHealth(), fetchHistory() ]); consecutiveErrors = 0; if (health.server_time) { setServerTime(health.server_time); } const isOnline = health.pipe_alive && health.has_data; if (isOnline) { setStatus("На связи"); connectionLost = false; } else { setStatus("Нет связи", true); if (!connectionLost) resetBufferTracking(); connectionLost = true; if (lastSignalLevel > 0) { lastSignalLevel = 0; audioEngine.updateLevel(0); setAudioIndicator(false); } } const arr = Array.isArray(history.bytes) ? history.bytes : []; const raw = arr[arr.length - 1] ?? 0; if (arr.length > 0 && isOnline) { const bufferSize = health.stats?.filled ?? 0; addSignals(arr, bufferSize); } // ===== НАХОДИМ МАКСИМУМ ИЗ БУФЕРА ===== let maxSignal = 0; let maxLevel = 0; let currentSignal = 0; let currentLevel = 0; if (isOnline && arr.length > 0) { for (const byte of arr) { const signal = byte & 0x3F; const amp = (byte >> 6) & 0x3; if (signal > maxSignal) { maxSignal = signal; } if (amp > maxLevel) { maxLevel = amp; } } currentSignal = raw & 0x3F; currentLevel = (raw >> 6) & 0x3; } const percent = (maxSignal / 63) * 100; // ===== ОБНОВЛЯЕМ КАНАЛЫ ===== if (isOnline && arr.length > 0) { setChannelValue(0, maxSignal, maxLevel); setChannelCurrentValue(0, currentSignal, currentLevel); for (let i = 1; i < 8; i++) { setChannelValue(i, 0, 0); setChannelCurrentValue(i, 0, 0); if (channelElements[i]) { channelElements[i].item.classList.remove('channel-active'); } } if (channelElements[0]) { channelElements[0].item.classList.add('channel-active'); } if (DOM.lastByte) { DOM.lastByte.textContent = currentSignal.toString(); } } else { for (let i = 0; i < 8; i++) { setChannelValue(i, 0, 0); setChannelCurrentValue(i, 0, 0); if (channelElements[i]) { channelElements[i].item.classList.remove('channel-active'); } } if (DOM.lastByte) { DOM.lastByte.textContent = '--'; } } if (isOnline) { updatePeakHolder(percent); updateSignalPeakHolder(maxLevel); } else { setBars(0, true); setSignal(0, true); if (lastSignalLevel > 0) { lastSignalLevel = 0; audioEngine.updateLevel(0); setAudioIndicator(false); } } const uptime = Math.round(health.uptime_sec || 0); const filled = health.stats?.filled ?? 0; const Bps = Math.round(health.stats?.bytes_per_sec ?? 0); setMeta(`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`); // ===== ИСТОРИЯ: НОВЫЕ СВЕРХУ ===== if (DOM.history) { if (arr.length === 0) { DOM.history.innerHTML = 'Нет данных'; } else { const reversed = [...arr].reverse(); DOM.history.innerHTML = reversed .map((b, i) => { const originalIndex = arr.length - 1 - i; const binary = (b & 0xFF).toString(2).padStart(8, "0"); return `[${originalIndex}] ${binary} = ${b & 0xFF}`; }) .join("
"); } } const now = performance.now(); if (now - lastChart > 100 && DOM.canvas) { drawChart(DOM.canvas, state.signalHistory); lastChart = now; } } catch (e) { consecutiveErrors++; setStatus("СЕРВЕР НЕДОСТУПЕН", true); setServerTime("--:--:--"); setBars(0, true); setSignal(0, true); resetBufferTracking(); if (lastSignalLevel > 0) { lastSignalLevel = 0; audioEngine.updateLevel(0); setAudioIndicator(false); } if (DOM.lastByte) DOM.lastByte.textContent = "--"; if (DOM.history) { DOM.history.innerHTML = "Ошибка загрузки данных"; } if (consecutiveErrors >= MAX_ERRORS && pollTimer) { clearInterval(pollTimer); } } } function startPolling(): void { if (pollTimer) clearInterval(pollTimer); if (consecutiveErrors > 0) update(); pollTimer = window.setInterval(update, 500); } window.addEventListener("focus", () => { if (consecutiveErrors >= MAX_ERRORS) { consecutiveErrors = 0; startPolling(); } }); window.addEventListener("beforeunload", () => { audioEngine.dispose(); }); declare global { interface Window { _mutedLevel?: number; } } startPolling(); update();