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:
Maxim Dolgolyov
2026-06-01 09:11:44 +03:00
parent 63ceeaabc2
commit ec2a207fb8
3 changed files with 107 additions and 75 deletions
+29 -1
View File
@@ -1183,7 +1183,7 @@
<div class="pref-row"> <div class="pref-row">
<div class="pref-row-info"> <div class="pref-row-info">
<div class="pref-row-label">Classroom</div> <div class="pref-row-label">Classroom</div>
<div class="pref-row-desc">Урок, участники, таймер, доска, файлы</div> <div class="pref-row-desc">Участники, таймер, доска, файлы</div>
</div> </div>
<label class="pref-toggle"> <label class="pref-toggle">
<input type="checkbox" id="pref-sfx-classroom" onchange="prefSfxCat('classroom',this.checked)"> <input type="checkbox" id="pref-sfx-classroom" onchange="prefSfxCat('classroom',this.checked)">
@@ -1191,6 +1191,18 @@
</label> </label>
</div> </div>
<!-- Lesson call melody -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Вызов на урок</div>
<div class="pref-row-desc">Мелодия-перезвон, когда учитель начал онлайн-урок</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-lesson-call" onchange="prefLessonCall(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Gamification sounds --> <!-- Gamification sounds -->
<div class="pref-row"> <div class="pref-row">
<div class="pref-row-info"> <div class="pref-row-info">
@@ -1218,6 +1230,10 @@
<!-- Preview buttons --> <!-- Preview buttons -->
<div class="pref-section-label" style="margin-top:14px">Прослушать</div> <div class="pref-section-label" style="margin-top:14px">Прослушать</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<button class="pref-test-btn" onclick="prefLessonTest()">
<i data-lucide="bell-ring" style="width:12px;height:12px;vertical-align:-2px"></i>
Вызов на урок
</button>
<button class="pref-test-btn" onclick="prefSfxTest('notification')"> <button class="pref-test-btn" onclick="prefSfxTest('notification')">
<i data-lucide="bell" style="width:12px;height:12px;vertical-align:-2px"></i> <i data-lucide="bell" style="width:12px;height:12px;vertical-align:-2px"></i>
Уведомление Уведомление
@@ -2068,6 +2084,7 @@
setChk('pref-sfx-classroom', sfx.prefs.classroom); setChk('pref-sfx-classroom', sfx.prefs.classroom);
setChk('pref-sfx-gamification', sfx.prefs.gamification); setChk('pref-sfx-gamification', sfx.prefs.gamification);
setChk('pref-sfx-quiz', sfx.prefs.quiz); setChk('pref-sfx-quiz', sfx.prefs.quiz);
setChk('pref-lesson-call', sfx.lessonCall !== false);
const vol = Math.round(sfx.volume * 100); const vol = Math.round(sfx.volume * 100);
const volEl = document.getElementById('pref-sfx-vol'); const volEl = document.getElementById('pref-sfx-vol');
const volVal = document.getElementById('pref-sfx-vol-val'); const volVal = document.getElementById('pref-sfx-vol-val');
@@ -2113,6 +2130,17 @@
LS.sfx.enabled = wasEnabled; LS.sfx.enabled = wasEnabled;
} }
function prefLessonCall(v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setLessonCall(v);
if (v) LS.sfx.play('click'); // короткое подтверждение, не вся мелодия
}
function prefLessonTest() {
if (!window.LS || !LS.sfx) return;
LS.sfx.preview('lesson_start'); // прослушать в обход тумблеров
}
function prefAnim(v) { function prefAnim(v) {
localStorage.setItem('ls_anim', v ? 'on' : 'off'); localStorage.setItem('ls_anim', v ? 'on' : 'off');
LS.toast(v ? 'Анимации включены' : 'Анимации отключены', 'success'); LS.toast(v ? 'Анимации включены' : 'Анимации отключены', 'success');
+19 -61
View File
@@ -1696,68 +1696,25 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
if (bannerEl) bannerEl.classList.remove('open'); if (bannerEl) bannerEl.classList.remove('open');
} }
/* ── Мелодия-«вызов на урок» (Web Audio, без аудиофайлов) ──────────────── */ /* ── Мелодия-«вызов на урок» через общий движок LS.sfx ────────────────── */
let _ac = null; // sound.js подключён не на всех страницах — подгрузим лениво, затем играем
// Браузеры блокируют звук без действия пользователя — «разблокируем» контекст // звук 'lesson_start'. Движок сам уважает мастер-тумблер, громкость, отдельный
// на первом клике/нажатии, чтобы перезвон сыграл, когда придёт SSE-событие. // тумблер «Вызов на урок» в профиле и разблокировку аудио по первому жесту.
function unlockAudio() { function ensureSfx(cb) {
try { if (window.LS && LS.sfx) { if (cb) cb(); return; }
const AC = window.AudioContext || window.webkitAudioContext; let s = document.getElementById('ls-sound-loader');
if (AC) { _ac = _ac || new AC(); if (_ac.state === 'suspended') _ac.resume(); } if (!s) {
} catch {} s = document.createElement('script');
window.removeEventListener('pointerdown', unlockAudio); s.id = 'ls-sound-loader';
window.removeEventListener('keydown', unlockAudio); 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() { function playLessonCall() {
if (localStorage.getItem('ls_cr_chime') === 'off') return; // запасной выключатель ensureSfx(() => { if (window.LS && LS.sfx) LS.sfx.play('lesson_start'); });
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 ready(fn) { function ready(fn) {
@@ -1766,6 +1723,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
} }
ready(() => { ready(() => {
ensureSfx(); // заранее подгрузить движок звуков
// Урок мог начаться ДО загрузки страницы — спросим сервер // Урок мог начаться ДО загрузки страницы — спросим сервер
if (typeof LS !== 'undefined' && LS.api) { if (typeof LS !== 'undefined' && LS.api) {
LS.api('/api/classroom/my/active') LS.api('/api/classroom/my/active')
@@ -1774,7 +1732,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
} }
// Реалтайм: старт/конец урока // Реалтайм: старт/конец урока
connectSSE(d => { 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(); else if (d.type === 'classroom_ended') goEnded();
}); });
}); });
+52 -6
View File
@@ -191,11 +191,31 @@
setTimeout(function () { _tone(1319, 140, 'sine', 0.16, 5, 90); }, 170); setTimeout(function () { _tone(1319, 140, 'sine', 0.16, 5, 90); }, 170);
}, },
lesson_start: function () { lesson_start: function () {
// C4-E4-G4 major chord arpeggio up // «Вызов на урок» — полный вестминстерский бой: 5 фраз по 4 ноты
_tone(261, 160, 'sine', 0.14, 10, 100); // (G4 · C5 · D5 · E5). Колоколообразно: тон + обертон-октава.
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 130); var G4 = 392, C5 = 523, D5 = 587, E5 = 659;
setTimeout(function () { _tone(392, 280, 'sine', 0.16, 10, 160); }, 260); var phrases = [
setTimeout(function () { _tone(523, 350, 'sine', 0.18, 10, 220); }, 390); [E5, D5, C5, G4],
[C5, E5, D5, G4],
[C5, D5, E5, C5],
[E5, C5, D5, G4],
[G4, D5, E5, C5]
];
var step = 320, gap = 220, t = 0;
phrases.forEach(function (ph, pi) {
ph.forEach(function (freq, ni) {
var lastOfPhrase = ni === ph.length - 1;
var lastOverall = pi === phrases.length - 1 && lastOfPhrase;
var dur = lastOverall ? 2000 : (lastOfPhrase ? 1100 : 760); // концы фраз длиннее
(function (f, when, d) {
setTimeout(function () {
_tone(f, d, 'sine', 0.16, 10, Math.round(d * 0.7)); // основной тон
_tone(f * 2, d, 'sine', 0.05, 10, Math.round(d * 0.7)); // обертон-октава
}, when);
})(freq, t, dur);
t += step + (lastOfPhrase ? gap : 0);
});
});
}, },
lesson_end: function () { lesson_end: function () {
// G4-E4-C4 descending // G4-E4-C4 descending
@@ -337,6 +357,7 @@
localStorage.setItem(LS_SFX_KEY, JSON.stringify({ localStorage.setItem(LS_SFX_KEY, JSON.stringify({
enabled : sfx.enabled, enabled : sfx.enabled,
volume : sfx.volume, volume : sfx.volume,
lessonCall : sfx.lessonCall,
prefs : sfx.prefs, prefs : sfx.prefs,
})); }));
} catch (e) {} } catch (e) {}
@@ -346,6 +367,7 @@
var sfx = { var sfx = {
enabled : true, enabled : true,
volume : 0.75, volume : 0.75,
lessonCall : true, // мелодия-«вызов на урок» — отдельный тумблер
prefs : { prefs : {
ui : true, ui : true,
navigation : true, navigation : true,
@@ -362,7 +384,7 @@
hand_raise: 'classroom', chat_message: 'classroom', hand_raise: 'classroom', chat_message: 'classroom',
user_joined: 'classroom', user_left: 'classroom', user_joined: 'classroom', user_left: 'classroom',
muted: 'classroom', draw_permitted: 'classroom', muted: 'classroom', draw_permitted: 'classroom',
lesson_start: 'classroom', lesson_end: 'classroom', lesson_end: 'classroom',
timer_warning: 'classroom', wb_clear: 'classroom', file_shared: 'classroom', timer_warning: 'classroom', wb_clear: 'classroom', file_shared: 'classroom',
xp_gain: 'gamification', level_up: 'gamification', xp_gain: 'gamification', level_up: 'gamification',
achievement: 'gamification', coin: 'gamification', streak: 'gamification', achievement: 'gamification', coin: 'gamification', streak: 'gamification',
@@ -373,6 +395,7 @@
play: function (name) { play: function (name) {
if (!this.enabled) return; if (!this.enabled) return;
if (name === 'lesson_start' && !this.lessonCall) return; // отдельный тумблер «Вызов на урок»
var cat = this._cats[name]; var cat = this._cats[name];
if (cat && !this.prefs[cat]) return; if (cat && !this.prefs[cat]) return;
if (!_sounds[name]) return; if (!_sounds[name]) return;
@@ -381,6 +404,13 @@
try { _sounds[name](); } catch (e) {} try { _sounds[name](); } catch (e) {}
}, },
// Проиграть звук в обход тумблеров (для кнопки «прослушать» в профиле)
preview: function (name) {
if (!_sounds[name]) return;
if (_masterGain) _masterGain.gain.value = this.volume;
try { _sounds[name](); } catch (e) {}
},
setEnabled: function (v) { setEnabled: function (v) {
this.enabled = !!v; this.enabled = !!v;
_save(); _save();
@@ -392,6 +422,11 @@
_save(); _save();
}, },
setLessonCall: function (v) {
this.lessonCall = !!v;
_save();
},
setPref: function (cat, v) { setPref: function (cat, v) {
if (this.prefs.hasOwnProperty(cat)) { if (this.prefs.hasOwnProperty(cat)) {
this.prefs[cat] = !!v; this.prefs[cat] = !!v;
@@ -404,6 +439,7 @@
if (!saved) return; if (!saved) return;
if (saved.enabled !== undefined) this.enabled = saved.enabled; if (saved.enabled !== undefined) this.enabled = saved.enabled;
if (saved.volume !== undefined) this.volume = saved.volume; if (saved.volume !== undefined) this.volume = saved.volume;
if (saved.lessonCall !== undefined) this.lessonCall = saved.lessonCall;
if (saved.prefs) { if (saved.prefs) {
var self = this; var self = this;
Object.keys(saved.prefs).forEach(function (k) { Object.keys(saved.prefs).forEach(function (k) {
@@ -420,4 +456,14 @@
// Auto-init on load // Auto-init on load
sfx.init(); sfx.init();
// Разблокировать AudioContext после первого действия пользователя (политика автоплея),
// чтобы звук, пришедший по событию (напр. «вызов на урок»), смог прозвучать.
function _unlock() {
try { _getCtx(); } catch (e) {}
window.removeEventListener('pointerdown', _unlock, true);
window.removeEventListener('keydown', _unlock, true);
}
window.addEventListener('pointerdown', _unlock, true);
window.addEventListener('keydown', _unlock, true);
})(); })();