diff --git a/cmd/server/web/css/audio.css b/cmd/server/web/css/audio.css index e3730c9..be1cc24 100644 --- a/cmd/server/web/css/audio.css +++ b/cmd/server/web/css/audio.css @@ -1,4 +1,4 @@ -/* Стили аудио-индикатора */ +/* web/css/audio.css */ @keyframes pulse { 0%, 100% { opacity: 1; @@ -10,6 +10,7 @@ } } +/* ===== АУДИО ИНДИКАТОР ===== */ #audio-indicator { font-family: var(--font-tech); font-size: var(--font-size-lg); @@ -17,14 +18,231 @@ border-radius: 6px; background: rgba(0, 0, 0, 0.4); border: 1px solid #333; - min-width: 50px; + min-width: 60px; /* Фиксированная минимальная ширина */ + width: 60px; /* Фиксированная ширина */ text-align: center; user-select: none; transition: all 0.3s ease; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; } #audio-indicator.active { border-color: #00ff88; box-shadow: 0 0 15px rgba(0, 255, 136, 0.3); animation: pulse 1s ease-in-out infinite; +} + +#audio-indicator.muted { + color: #666 !important; + border-color: #333; + animation: none !important; + box-shadow: none !important; +} + +/* ===== ОБЕРТКА ДЛЯ АУДИО ===== */ +.audio-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +/* ===== РЕГУЛЯТОР ГРОМКОСТИ ===== */ +.volume-control { + position: absolute; + top: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + background: rgba(10, 20, 15, 0.95); + border: 1px solid rgba(0, 255, 136, 0.25); + border-radius: 8px; + padding: 10px 16px; + display: none; + align-items: center; + backdrop-filter: blur(10px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7); + z-index: 1000; + animation: volumeFadeIn 0.15s ease; + min-width: 100px; +} + +.audio-wrapper:hover .volume-control { + display: flex; +} + +.volume-control:hover { + display: flex !important; +} + +@keyframes volumeFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +/* ===== ОБЕРТКА ДЛЯ ПОЛЗУНКА ===== */ +.volume-slider-wrapper { + position: relative; + width: 80px; + height: 20px; + display: flex; + align-items: center; +} + +/* ===== ТРЕК (фон) ===== */ +.volume-track { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(-50%); + height: 4px; + background: rgba(255, 255, 255, 0.12); + border-radius: 2px; + pointer-events: none; + overflow: hidden; +} + +/* ===== ЗАЛИВКА ТРЕКА ===== */ +.volume-track-fill { + height: 100%; + width: 0%; + background: #00ff88; + border-radius: 2px; + transition: width 0.05s linear; + box-shadow: 0 0 10px rgba(0, 255, 136, 0.3); +} + +/* ===== ПОЛЗУНОК ===== */ +.volume-slider { + -webkit-appearance: none; + appearance: none; + position: relative; + width: 100%; + height: 20px; + background: transparent; + outline: none; + cursor: pointer; + z-index: 2; + margin: 0; + padding: 0; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #00ff88; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 16px rgba(0, 255, 136, 0.5); + transition: all 0.15s ease; + border: 2px solid rgba(0, 255, 136, 0.3); + margin-top: -5px; +} + +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 24px rgba(0, 255, 136, 0.7); + border-color: #00ff88; +} + +.volume-slider::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + background: transparent; + border: none; + border-radius: 2px; +} + +.volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + background: #00ff88; + border: 2px solid rgba(0, 255, 136, 0.3); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 16px rgba(0, 255, 136, 0.5); +} + +.volume-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 24px rgba(0, 255, 136, 0.7); + border-color: #00ff88; +} + +.volume-slider::-moz-range-track { + width: 100%; + height: 4px; + background: transparent; + border: none; + border-radius: 2px; +} + +/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */ +@media (max-width: 768px) { + .volume-control { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + top: auto; + padding: 12px 20px; + border-radius: 10px; + background: rgba(10, 20, 15, 0.98); + border: 1px solid rgba(0, 255, 136, 0.3); + display: none !important; + min-width: 140px; + } + + .audio-wrapper .volume-control.visible { + display: flex !important; + animation: volumeFadeInMobile 0.2s ease; + } + + @keyframes volumeFadeInMobile { + from { + opacity: 0; + transform: translateX(-50%) translateY(10px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } + } + + .volume-slider-wrapper { + width: 120px; + } +} + +@media (max-width: 480px) { + .volume-control { + padding: 10px 16px; + bottom: 70px; + min-width: 120px; + } + + .volume-slider-wrapper { + width: 100px; + } +} + +@media (hover: none) { + .audio-wrapper:hover .volume-control { + display: none !important; + } +} + +.audio-wrapper.touch-active .volume-control { + display: flex !important; } \ No newline at end of file diff --git a/cmd/server/web/dashboard.html b/cmd/server/web/dashboard.html index 2b9e427..08ab7b9 100644 --- a/cmd/server/web/dashboard.html +++ b/cmd/server/web/dashboard.html @@ -25,11 +25,25 @@

G-Port Warden

Журнал -
🔇
+ +
+
🔇
+ +
+
+ +
+
+
+
+
+
--:--:--
+
diff --git a/cmd/server/web/js/app.ts b/cmd/server/web/js/app.ts index 664be08..5758a50 100644 --- a/cmd/server/web/js/app.ts +++ b/cmd/server/web/js/app.ts @@ -1,4 +1,4 @@ -// app.ts - Оркестратор приложения +// web/src/app.ts - Оркестратор приложения import { fetchHealth, @@ -38,12 +38,111 @@ 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); @@ -55,10 +154,32 @@ window.addEventListener("DOMContentLoaded", () => { setSignal(state.peakSignalLevel); const signalLevel = state.peakSignalLevel; + const currentVolume = audioEngine.getVolume(); + if (signalLevel !== lastSignalLevel) { lastSignalLevel = signalLevel; - audioEngine.updateLevel(signalLevel); - setAudioIndicator(signalLevel > 0, 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); } } }); @@ -102,14 +223,14 @@ document.addEventListener("keydown", (e: KeyboardEvent) => { } break; case 'm': - if (lastSignalLevel > 0) { + 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) { + if (level > 0 && audioEngine.getVolume() > 0) { audioEngine.updateLevel(level); lastSignalLevel = level; setAudioIndicator(true, level); @@ -239,12 +360,10 @@ async function update(): Promise { 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}`; }) diff --git a/cmd/server/web/js/audio.ts b/cmd/server/web/js/audio.ts index d1ace6f..45ef6d0 100644 --- a/cmd/server/web/js/audio.ts +++ b/cmd/server/web/js/audio.ts @@ -1,4 +1,4 @@ -// audio.ts - Звуковой движок для сигнала +// web/src/audio.ts - Звуковой движок для сигнала export class AudioEngine { private audioContext: AudioContext | null = null; @@ -8,20 +8,73 @@ export class AudioEngine { private currentLevel: number = 0; private currentFrequency: number = 800; private fadeTimeout: number | null = null; - private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки + private readonly FADE_TIME: number = 0.05; private readonly FREQUENCY_MAP: Record = { - 1: 800, // Уровень 1 → 800 Гц + 1: 600, // Уровень 1 → 600 Гц 2: 1000, // Уровень 2 → 1000 Гц 3: 1200 // Уровень 3 → 1200 Гц }; + // ===== НОВОЕ: громкость (0-100) ===== + private volume: number = 0; + private maxVolume: number = 0.45; // 45% максимум + constructor() { // Инициализация при первом использовании } /** - * Инициализация AudioContext (требует взаимодействия пользователя) + * Установка громкости (0-100) */ + public setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(100, volume)); + this.updateVolume(); + } + + /** + * Получение текущей громкости (0-100) + */ + public getVolume(): number { + return this.volume; + } + + /** + * Применение громкости к усилителю + */ + private updateVolume(): void { + if (!this.gainNode) return; + + const normalized = (this.volume / 100) * this.maxVolume; + const currentLevel = this.currentLevel; + + // Если уровень 0 или громкость 0 - выключаем звук + if (currentLevel === 0 || this.volume === 0) { + this.gainNode.gain.value = 0; + return; + } + + // Рассчитываем громкость на основе уровня и общей громкости + const volumeMap: Record = { + 1: 0.10 * (normalized / this.maxVolume), + 2: 0.25 * (normalized / this.maxVolume), + 3: 0.45 * (normalized / this.maxVolume) + }; + + const targetVolume = Math.min(volumeMap[currentLevel] || 0, this.maxVolume * (this.volume / 100)); + + try { + const currentTime = this.audioContext?.currentTime || 0; + this.gainNode.gain.exponentialRampToValueAtTime( + Math.max(targetVolume, 0.001), + currentTime + this.FADE_TIME + ); + } catch (error) { + this.gainNode.gain.value = targetVolume; + } + } + + // ===== ОСТАЛЬНЫЕ МЕТОДЫ ===== + private async initAudio(): Promise { try { if (!this.audioContext) { @@ -57,7 +110,6 @@ export class AudioEngine { if (!this.oscillator) return; try { - // Плавное изменение частоты за 50 мс const currentTime = this.audioContext?.currentTime || 0; this.oscillator.frequency.exponentialRampToValueAtTime( frequency, @@ -65,7 +117,6 @@ export class AudioEngine { ); this.currentFrequency = frequency; } catch (error) { - // Если не удалось плавно, устанавливаем сразу this.oscillator.frequency.value = frequency; this.currentFrequency = frequency; } @@ -77,29 +128,26 @@ export class AudioEngine { private createSoundGraph(level: number): void { if (!this.audioContext) return; - // Останавливаем старый звук this.stopSound(); const frequency = this.getFrequencyForLevel(level); this.currentFrequency = frequency; - // Создаём осциллятор this.oscillator = this.audioContext.createOscillator(); this.oscillator.type = 'sine'; this.oscillator.frequency.value = frequency; - // Создаём усилитель this.gainNode = this.audioContext.createGain(); - this.gainNode.gain.value = 0; // Начинаем с тишины + this.gainNode.gain.value = 0; - // Подключаем: осциллятор → усилитель → выход this.oscillator.connect(this.gainNode); this.gainNode.connect(this.audioContext.destination); - // Запускаем осциллятор this.oscillator.start(); - this.isPlaying = true; + + // Применяем текущую громкость + this.updateVolume(); } /** @@ -107,18 +155,14 @@ export class AudioEngine { * @param level - уровень сигнала (0-3) */ public async updateLevel(level: number): Promise { - // Инициализация при первом вызове if (!this.audioContext) { const initialized = await this.initAudio(); - if (!initialized) { - return; - } + if (!initialized) return; } const context = this.audioContext; if (!context) return; - // Если AudioContext не готов, пробуем восстановить if (context.state !== 'running') { try { await context.resume(); @@ -128,80 +172,42 @@ export class AudioEngine { } } - // Приводим уровень к числу const newLevel = Math.max(0, Math.min(3, Math.round(level))); - // Если уровень не изменился — ничего не делаем if (newLevel === this.currentLevel) { return; } this.currentLevel = newLevel; - // Получаем частоту для нового уровня - const newFrequency = this.getFrequencyForLevel(newLevel); - - // Если уровень > 0 и звук не создан — создаём - if (newLevel > 0 && !this.oscillator) { - this.createSoundGraph(newLevel); - // Устанавливаем громкость после создания - this.setVolume(newLevel); + // Если громкость 0 - ничего не включаем + if (this.volume === 0) { + this.stopSound(); + return; + } + + const newFrequency = this.getFrequencyForLevel(newLevel); + + if (newLevel > 0 && !this.oscillator) { + this.createSoundGraph(newLevel); return; } - // Если уровень 0 — выключаем звук if (newLevel === 0) { this.fadeOut(); return; } - // Если звук не играет — включаем заново if (!this.isPlaying || !this.oscillator) { this.createSoundGraph(newLevel); - this.setVolume(newLevel); return; } - // Изменяем частоту, если она изменилась if (newFrequency !== this.currentFrequency) { this.updateFrequency(newFrequency); } - // Обновляем громкость - this.setVolume(newLevel); - } - - /** - * Установка громкости для уровня - */ - private setVolume(level: number): void { - if (!this.gainNode || !this.audioContext) return; - - // Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45% - const volumeMap: Record = { - 1: 0.10, - 2: 0.25, - 3: 0.45 - }; - - const targetVolume = volumeMap[level] || 0; - - try { - const currentGain = this.gainNode.gain.value; - // Если звук выключен или очень тихий, включаем сразу - if (currentGain < 0.01) { - this.gainNode.gain.setValueAtTime(targetVolume, this.audioContext.currentTime); - } else { - // Плавное изменение за 50 мс - this.gainNode.gain.exponentialRampToValueAtTime( - Math.max(targetVolume, 0.001), - this.audioContext.currentTime + this.FADE_TIME - ); - } - } catch (error) { - // fallback - this.gainNode.gain.value = targetVolume; - } + this.updateVolume(); } /** @@ -210,22 +216,18 @@ export class AudioEngine { private fadeOut(): void { if (!this.gainNode || !this.audioContext) return; - // Отменяем старый таймер if (this.fadeTimeout) { clearTimeout(this.fadeTimeout); this.fadeTimeout = null; } try { - // Плавно убавляем громкость за 100 мс const currentTime = this.audioContext.currentTime; this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2); } catch (error) { - // fallback this.gainNode.gain.value = 0; } - // Останавливаем звук после затухания this.fadeTimeout = window.setTimeout(() => { this.stopSound(); this.fadeTimeout = null; @@ -240,18 +242,14 @@ export class AudioEngine { try { this.oscillator.stop(); this.oscillator.disconnect(); - } catch (e) { - // Игнорируем ошибки при остановке - } + } catch (e) {} this.oscillator = null; } if (this.gainNode) { try { this.gainNode.disconnect(); - } catch (e) { - // Игнорируем ошибки - } + } catch (e) {} this.gainNode = null; } @@ -270,15 +268,13 @@ export class AudioEngine { /** * Проверка, активен ли звук */ - // noinspection JSUnusedGlobalSymbols public isActive(): boolean { - return this.isPlaying && this.currentLevel > 0; + return this.isPlaying && this.currentLevel > 0 && this.volume > 0; } /** * Получение текущей частоты */ - // noinspection JSUnusedGlobalSymbols public getCurrentFrequency(): number { return this.currentFrequency; } @@ -286,7 +282,6 @@ export class AudioEngine { /** * Получение текущего уровня */ - // noinspection JSUnusedGlobalSymbols public getCurrentLevel(): number { return this.currentLevel; } @@ -294,23 +289,19 @@ export class AudioEngine { /** * Полная остановка и освобождение ресурсов */ - // noinspection JSUnusedGlobalSymbols public async dispose(): Promise { this.stopSound(); if (this.audioContext) { try { await this.audioContext.close(); - } catch (e) { - // Игнорируем - } + } catch (e) {} this.audioContext = null; } this.currentLevel = 0; this.currentFrequency = 800; + this.volume = 0; console.log('[Audio] Ресурсы освобождены'); } } - -// noinspection JSUnusedGlobalSymbols export const audioEngine = new AudioEngine(); \ No newline at end of file diff --git a/cmd/server/web/js/ui.ts b/cmd/server/web/js/ui.ts index ff5d57d..0ca5433 100644 --- a/cmd/server/web/js/ui.ts +++ b/cmd/server/web/js/ui.ts @@ -134,16 +134,24 @@ export function setAudioIndicator(active: boolean, level: number = 0): void { const el = DOM.audioIndicator; if (!el) return; - if (active && level > 0) { - el.textContent = `🔊 ${level}`; + // Проверяем громкость + const volume = localStorage.getItem('gport_volume'); + const currentVolume = volume ? parseInt(volume) : 0; + + if (active && level > 0 && currentVolume > 0) { + el.textContent = '🔊'; el.style.color = '#00ff88'; el.style.opacity = '1'; el.style.animation = 'pulse 1s ease-in-out infinite'; + el.classList.remove('muted'); + el.classList.add('active'); } else { el.textContent = '🔇'; el.style.color = '#666'; el.style.opacity = '0.5'; el.style.animation = 'none'; + el.classList.add('muted'); + el.classList.remove('active'); } }