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:
+29
-1
@@ -1183,7 +1183,7 @@
|
||||
<div class="pref-row">
|
||||
<div class="pref-row-info">
|
||||
<div class="pref-row-label">Classroom</div>
|
||||
<div class="pref-row-desc">Урок, участники, таймер, доска, файлы</div>
|
||||
<div class="pref-row-desc">Участники, таймер, доска, файлы</div>
|
||||
</div>
|
||||
<label class="pref-toggle">
|
||||
<input type="checkbox" id="pref-sfx-classroom" onchange="prefSfxCat('classroom',this.checked)">
|
||||
@@ -1191,6 +1191,18 @@
|
||||
</label>
|
||||
</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 -->
|
||||
<div class="pref-row">
|
||||
<div class="pref-row-info">
|
||||
@@ -1218,6 +1230,10 @@
|
||||
<!-- Preview buttons -->
|
||||
<div class="pref-section-label" style="margin-top:14px">Прослушать</div>
|
||||
<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')">
|
||||
<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-gamification', sfx.prefs.gamification);
|
||||
setChk('pref-sfx-quiz', sfx.prefs.quiz);
|
||||
setChk('pref-lesson-call', sfx.lessonCall !== false);
|
||||
const vol = Math.round(sfx.volume * 100);
|
||||
const volEl = document.getElementById('pref-sfx-vol');
|
||||
const volVal = document.getElementById('pref-sfx-vol-val');
|
||||
@@ -2113,6 +2130,17 @@
|
||||
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) {
|
||||
localStorage.setItem('ls_anim', v ? 'on' : 'off');
|
||||
LS.toast(v ? 'Анимации включены' : 'Анимации отключены', 'success');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+59
-13
@@ -191,11 +191,31 @@
|
||||
setTimeout(function () { _tone(1319, 140, 'sine', 0.16, 5, 90); }, 170);
|
||||
},
|
||||
lesson_start: function () {
|
||||
// C4-E4-G4 major chord arpeggio up
|
||||
_tone(261, 160, 'sine', 0.14, 10, 100);
|
||||
setTimeout(function () { _tone(330, 160, 'sine', 0.14, 10, 100); }, 130);
|
||||
setTimeout(function () { _tone(392, 280, 'sine', 0.16, 10, 160); }, 260);
|
||||
setTimeout(function () { _tone(523, 350, 'sine', 0.18, 10, 220); }, 390);
|
||||
// «Вызов на урок» — полный вестминстерский бой: 5 фраз по 4 ноты
|
||||
// (G4 · C5 · D5 · E5). Колоколообразно: тон + обертон-октава.
|
||||
var G4 = 392, C5 = 523, D5 = 587, E5 = 659;
|
||||
var phrases = [
|
||||
[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 () {
|
||||
// G4-E4-C4 descending
|
||||
@@ -335,17 +355,19 @@
|
||||
function _save() {
|
||||
try {
|
||||
localStorage.setItem(LS_SFX_KEY, JSON.stringify({
|
||||
enabled : sfx.enabled,
|
||||
volume : sfx.volume,
|
||||
prefs : sfx.prefs,
|
||||
enabled : sfx.enabled,
|
||||
volume : sfx.volume,
|
||||
lessonCall : sfx.lessonCall,
|
||||
prefs : sfx.prefs,
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
var sfx = {
|
||||
enabled : true,
|
||||
volume : 0.75,
|
||||
enabled : true,
|
||||
volume : 0.75,
|
||||
lessonCall : true, // мелодия-«вызов на урок» — отдельный тумблер
|
||||
prefs : {
|
||||
ui : true,
|
||||
navigation : true,
|
||||
@@ -362,7 +384,7 @@
|
||||
hand_raise: 'classroom', chat_message: 'classroom',
|
||||
user_joined: 'classroom', user_left: 'classroom',
|
||||
muted: 'classroom', draw_permitted: 'classroom',
|
||||
lesson_start: 'classroom', lesson_end: 'classroom',
|
||||
lesson_end: 'classroom',
|
||||
timer_warning: 'classroom', wb_clear: 'classroom', file_shared: 'classroom',
|
||||
xp_gain: 'gamification', level_up: 'gamification',
|
||||
achievement: 'gamification', coin: 'gamification', streak: 'gamification',
|
||||
@@ -373,6 +395,7 @@
|
||||
|
||||
play: function (name) {
|
||||
if (!this.enabled) return;
|
||||
if (name === 'lesson_start' && !this.lessonCall) return; // отдельный тумблер «Вызов на урок»
|
||||
var cat = this._cats[name];
|
||||
if (cat && !this.prefs[cat]) return;
|
||||
if (!_sounds[name]) return;
|
||||
@@ -381,6 +404,13 @@
|
||||
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) {
|
||||
this.enabled = !!v;
|
||||
_save();
|
||||
@@ -392,6 +422,11 @@
|
||||
_save();
|
||||
},
|
||||
|
||||
setLessonCall: function (v) {
|
||||
this.lessonCall = !!v;
|
||||
_save();
|
||||
},
|
||||
|
||||
setPref: function (cat, v) {
|
||||
if (this.prefs.hasOwnProperty(cat)) {
|
||||
this.prefs[cat] = !!v;
|
||||
@@ -402,8 +437,9 @@
|
||||
init: function () {
|
||||
var saved = _load();
|
||||
if (!saved) return;
|
||||
if (saved.enabled !== undefined) this.enabled = saved.enabled;
|
||||
if (saved.volume !== undefined) this.volume = saved.volume;
|
||||
if (saved.enabled !== undefined) this.enabled = saved.enabled;
|
||||
if (saved.volume !== undefined) this.volume = saved.volume;
|
||||
if (saved.lessonCall !== undefined) this.lessonCall = saved.lessonCall;
|
||||
if (saved.prefs) {
|
||||
var self = this;
|
||||
Object.keys(saved.prefs).forEach(function (k) {
|
||||
@@ -420,4 +456,14 @@
|
||||
// Auto-init on load
|
||||
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);
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user