/* Mandera Portal — notification sounds (Web Audio, no files needed). Branded tones: calm, bell-like, unhurried — to match the luxury feel. - New message while the Messages screen is CLOSED -> messageClosed() (a clear rising two-note chime, so you notice it). - New message while the Messages screen is OPEN -> messageOpen() (a softer, lower single tone — different, less intrusive). - Any other notification (shoot, content, file...) -> notification(). A speaker toggle in the top bar mutes/unmutes (saved per browser). */ const MS_KEY = 'mandera_sound_enabled'; const MSound = { ctx: null, isEnabled() { try { return localStorage.getItem(MS_KEY) !== '0'; } catch (e) { return true; } }, setEnabled(on) { try { localStorage.setItem(MS_KEY, on ? '1' : '0'); } catch (e) {} if (on) { MSound._resume(); MSound._play([{ f: 660, t: 0, d: 0.28, gain: 0.12, shimmer: true }]); } // little confirm blip try { window.dispatchEvent(new CustomEvent('msound-changed')); } catch (e) {} }, _init() { if (!MSound.ctx) { try { const AC = window.AudioContext || window.webkitAudioContext; if (AC) MSound.ctx = new AC(); } catch (e) {} } return MSound.ctx; }, _resume() { const c = MSound._init(); if (c && c.state === 'suspended') { try { c.resume(); } catch (e) {} } }, /* Play a set of gentle sine "bell" notes. Each note: {f, t, d, gain, shimmer} */ _play(notes, master = 1) { if (!MSound.isEnabled()) return; const c = MSound._init(); if (!c) return; if (c.state === 'suspended') { try { c.resume(); } catch (e) {} } const t0 = c.currentTime + 0.03; notes.forEach((n) => { const s = t0 + (n.t || 0); const d = n.d || 0.45; const peak = (n.gain || 0.14) * master; const o = c.createOscillator(); const g = c.createGain(); o.type = n.type || 'sine'; o.frequency.value = n.f; g.gain.setValueAtTime(0.0001, s); g.gain.exponentialRampToValueAtTime(peak, s + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, s + d); o.connect(g); g.connect(c.destination); o.start(s); o.stop(s + d + 0.05); if (n.shimmer) { // faint octave above for a bell-like sheen const o2 = c.createOscillator(); const g2 = c.createGain(); o2.type = 'sine'; o2.frequency.value = n.f * 2; g2.gain.setValueAtTime(0.0001, s); g2.gain.exponentialRampToValueAtTime(peak * 0.22, s + 0.02); g2.gain.exponentialRampToValueAtTime(0.0001, s + d * 0.7); o2.connect(g2); g2.connect(c.destination); o2.start(s); o2.stop(s + d); } }); }, // New message, Messages screen not open — a clear rising chime (D5 → A5). messageClosed() { MSound._play([{ f: 587.33, t: 0, d: 0.55, gain: 0.17, shimmer: true }, { f: 880.0, t: 0.13, d: 0.6, gain: 0.14, shimmer: true }]); }, // New message while you're already in Messages — soft, low, single note (Bb4). messageOpen() { MSound._play([{ f: 466.16, t: 0, d: 0.32, gain: 0.07 }]); }, // Any other notification — a distinct softer bell pair (E5 → B5). notification() { MSound._play([{ f: 659.25, t: 0, d: 0.5, gain: 0.15, shimmer: true }, { f: 987.77, t: 0.15, d: 0.55, gain: 0.11, shimmer: true }]); }, }; /* Watches MData for NEW incoming items for the signed-in account and plays the matching sound. Baseline is set on mount so the existing backlog is silent — only things that arrive after you open the portal make a sound. */ function SoundWatcher() { React.useEffect(() => { const unlock = () => MSound._resume(); ['pointerdown', 'keydown', 'touchstart'].forEach((ev) => document.addEventListener(ev, unlock, { passive: true })); let lastMsgTs = 0; const seenNotif = new Set(); let inited = false; const isIncomingMsg = (m, acc, isClient) => { if (m.fromId && m.fromId === acc.id) return false; return isClient ? m.fromRole !== 'client' : m.fromRole === 'client'; }; const check = () => { const acc = window.MAuth && window.MAuth.current(); if (!acc) return; const isClient = acc.role === 'client'; if (isClient && !acc.clientId) return; const msgs = isClient ? window.MData.forClient('messages', acc.clientId) : window.MData.get('messages'); const notes = isClient ? window.MData.forClient('notifications', acc.clientId) : window.MData.teamNotifications(); if (!inited) { msgs.forEach((m) => { if (m.ts > lastMsgTs) lastMsgTs = m.ts; }); notes.forEach((n) => seenNotif.add(n.id)); inited = true; return; } let maxTs = lastMsgTs; let newMsg = false; msgs.forEach((m) => { if (m.ts > lastMsgTs && isIncomingMsg(m, acc, isClient)) newMsg = true; if (m.ts > maxTs) maxTs = m.ts; }); lastMsgTs = maxTs; if (newMsg) { (window.__mpView === 'messages') ? MSound.messageOpen() : MSound.messageClosed(); } let newNote = false; notes.forEach((n) => { if (!seenNotif.has(n.id)) { seenNotif.add(n.id); if (n.type !== 'message') newNote = true; } }); if (newNote) MSound.notification(); }; const onChange = () => check(); window.addEventListener('mdata-changed', onChange); const iv = setInterval(check, 4000); check(); // set baseline immediately return () => { window.removeEventListener('mdata-changed', onChange); clearInterval(iv); ['pointerdown', 'keydown', 'touchstart'].forEach((ev) => document.removeEventListener(ev, unlock)); }; }, []); return null; } Object.assign(window, { MSound, SoundWatcher });