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">
|
||||||
<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');
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user