435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
// 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,
|
||
setSoundLevel,
|
||
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<void> {
|
||
try {
|
||
const [health, history] = await Promise.all([
|
||
fetchHealth(),
|
||
fetchHistory()
|
||
]);
|
||
|
||
consecutiveErrors = 0;
|
||
|
||
if (health.server_time) {
|
||
setServerTime(health.server_time);
|
||
}
|
||
|
||
setSoundLevel(health.sound_level ?? 0);
|
||
|
||
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("<br>");
|
||
}
|
||
}
|
||
|
||
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);
|
||
setSoundLevel(0);
|
||
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(); |