feat(classroom): тумблер «Вызов на урок» в профиле + интеграция мелодии в LS.sfx
Мелодию-вызов перевёл с кастомного Web Audio на общий движок звуков LS.sfx: - длинный вестминстерский бой теперь в sound.js (звук lesson_start); - api.js лениво подгружает sound.js на любой странице и играет lesson_start по SSE classroom_started (вместо собственного синтезатора); - отдельный pref lessonCall + тумблер «Вызов на урок» и кнопка прослушивания в профиле (Настройки → Звуки); уважает мастер-тумблер и громкость; - lesson_start выведен из категории classroom (управляется своим тумблером); - разблокировка AudioContext по первому жесту перенесена в sound.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1696,68 +1696,25 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
if (bannerEl) bannerEl.classList.remove('open');
|
||||
}
|
||||
|
||||
/* ── Мелодия-«вызов на урок» (Web Audio, без аудиофайлов) ──────────────── */
|
||||
let _ac = null;
|
||||
// Браузеры блокируют звук без действия пользователя — «разблокируем» контекст
|
||||
// на первом клике/нажатии, чтобы перезвон сыграл, когда придёт SSE-событие.
|
||||
function unlockAudio() {
|
||||
try {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC) { _ac = _ac || new AC(); if (_ac.state === 'suspended') _ac.resume(); }
|
||||
} catch {}
|
||||
window.removeEventListener('pointerdown', unlockAudio);
|
||||
window.removeEventListener('keydown', unlockAudio);
|
||||
/* ── Мелодия-«вызов на урок» через общий движок LS.sfx ────────────────── */
|
||||
// sound.js подключён не на всех страницах — подгрузим лениво, затем играем
|
||||
// звук 'lesson_start'. Движок сам уважает мастер-тумблер, громкость, отдельный
|
||||
// тумблер «Вызов на урок» в профиле и разблокировку аудио по первому жесту.
|
||||
function ensureSfx(cb) {
|
||||
if (window.LS && LS.sfx) { if (cb) cb(); return; }
|
||||
let s = document.getElementById('ls-sound-loader');
|
||||
if (!s) {
|
||||
s = document.createElement('script');
|
||||
s.id = 'ls-sound-loader';
|
||||
s.src = '/js/sound.js';
|
||||
s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
if (cb) s.addEventListener('load', cb, { once: true });
|
||||
}
|
||||
window.addEventListener('pointerdown', unlockAudio);
|
||||
window.addEventListener('keydown', unlockAudio);
|
||||
|
||||
function playLessonChime() {
|
||||
if (localStorage.getItem('ls_cr_chime') === 'off') return; // запасной выключатель
|
||||
try {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return;
|
||||
_ac = _ac || new AC();
|
||||
const ctx = _ac;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
const t0 = ctx.currentTime + 0.05;
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.16;
|
||||
const comp = ctx.createDynamicsCompressor(); // мягкий лимитер от перегруза
|
||||
master.connect(comp); comp.connect(ctx.destination);
|
||||
// Полный вестминстерский бой (школьно-часовой перезвон) — 5 фраз по 4 ноты
|
||||
// на нотах G4 · C5 · D5 · E5. Узнаваемый «вызов на урок».
|
||||
const G4 = 392.00, C5 = 523.25, D5 = 587.33, E5 = 659.25;
|
||||
const PHRASES = [
|
||||
[E5, D5, C5, G4],
|
||||
[C5, E5, D5, G4],
|
||||
[C5, D5, E5, C5],
|
||||
[E5, C5, D5, G4],
|
||||
[G4, D5, E5, C5],
|
||||
];
|
||||
const step = 0.32, gap = 0.22; // шаг между нотами и пауза между фразами
|
||||
let t = t0;
|
||||
PHRASES.forEach((ph, pi) => {
|
||||
ph.forEach((freq, ni) => {
|
||||
const lastOfPhrase = ni === ph.length - 1;
|
||||
const lastOverall = pi === PHRASES.length - 1 && lastOfPhrase;
|
||||
const dur = lastOverall ? 2.2 : (lastOfPhrase ? 1.2 : 0.9); // концы фраз длиннее
|
||||
// Колоколообразный тембр: основной тон + обертоны, экспон. затухание
|
||||
[[1, 0.5], [2, 0.16], [3, 0.06]].forEach(([mult, amp]) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq * mult;
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(amp, t + 0.012);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
osc.connect(g); g.connect(master);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur + 0.05);
|
||||
});
|
||||
t += step + (lastOfPhrase ? gap : 0);
|
||||
});
|
||||
});
|
||||
} catch {}
|
||||
function playLessonCall() {
|
||||
ensureSfx(() => { if (window.LS && LS.sfx) LS.sfx.play('lesson_start'); });
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
@@ -1766,6 +1723,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
}
|
||||
|
||||
ready(() => {
|
||||
ensureSfx(); // заранее подгрузить движок звуков
|
||||
// Урок мог начаться ДО загрузки страницы — спросим сервер
|
||||
if (typeof LS !== 'undefined' && LS.api) {
|
||||
LS.api('/api/classroom/my/active')
|
||||
@@ -1774,7 +1732,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
|
||||
}
|
||||
// Реалтайм: старт/конец урока
|
||||
connectSSE(d => {
|
||||
if (d.type === 'classroom_started') { dismissed = false; playLessonChime(); goLive({ title: d.title, sessionId: d.sessionId }); }
|
||||
if (d.type === 'classroom_started') { dismissed = false; playLessonCall(); goLive({ title: d.title, sessionId: d.sessionId }); }
|
||||
else if (d.type === 'classroom_ended') goEnded();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user