openvibers/openvibe.live Commits

Chat: popout uses the real settings panel; own-message TTS never plays locally

OP OpenVibers committed 27 Aug 2026 · a18bc46a69 · 4 files, +50 −86

The popout's bespoke five-toggle settings panel is gone — its gear now
opens the SHARED chat settings panel (font size, cross-feed, TTS sources,
auto-delete, everything), so settings parity with the streamer page is
structural, and the cross-tab sync + single-speaker TTS rules apply
identically in popouts since they live in chat.js.

Senders also stop hearing their own message's TTS: the speaker button is a
sender-side control (whether your message gets read on the streamer's
end), but every tab also played it locally, so chatters heard themselves
twice — once in their tab, once through the stream audio. tts-audio
broadcasts now carry a stable sender_key, each tab remembers who it chats
as from the auth handshake, and viewer-side playback skips your own
messages (name-match fallback for old payloads). The one place you still
hear yourself is your own channel while live — there you ARE the audience.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 51de4328..eabf858c 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5001,7 +5001,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=71" defer></script>
++<script src="/js/chat.js?v=72" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 4757b6aa..e75de0cc 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -794,6 +794,27 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
})();
function isOwnChannelTtsSpeaker() { return _ownTtsLeader; }
++// Who this tab is chatting as — set by the server's 'auth' confirmation.
++let _myChatIdentity = null;
++
++// A user must NEVER hear their own message's TTS locally. The speaker button under
++// the input is a SENDER-side control (does your message get read out on the
++// streamer's end), not a local monitor — playing it locally meant senders heard
++// every message twice: once in their tab, once through the stream audio. The one
++// exception is the streamer listening to their OWN channel while live (they ARE the
++// audience there) — those paths deliberately don't call this filter.
++function _isOwnTtsMessage(msg) {
++ try {
++ if (msg.sender_key && _myChatIdentity && _myChatIdentity.key) {
++ return msg.sender_key === _myChatIdentity.key;
++ }
++ // Legacy payloads without sender_key: match by name.
++ const name = String(msg.core_username || msg.username || '');
++ if (!name || !_myChatIdentity) return false;
++ return name === _myChatIdentity.name || (!!_myChatIdentity.core && name === _myChatIdentity.core);
++ } catch { return false; }
++}
++
// ── Cross-tab settings sync ──────────────────────────────────────────────────
// chatSettings persist in localStorage; the storage event fires in every OTHER
// tab on save, so a toggle flipped anywhere applies everywhere immediately —
@@@ -2612,6 +2633,13 @@ function handleChatMessage(msg) {
renderChatUsersList(msg.users);
break;
case 'auth':
++ // Remember who WE are in this chat (stable key + display name) so TTS of
++ // our own messages can be skipped locally (see _isOwnTtsMessage).
++ _myChatIdentity = {
++ key: msg.core_username ? `user:${msg.core_username}` : `anon:${msg.username}`,
++ name: String(msg.username || ''),
++ core: msg.core_username || null,
++ };
if (msg.authenticated) {
addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`);
} else {
@@@ -2740,7 +2768,7 @@ function handleChatMessage(msg) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
speakBroadcastTTS(msg.message || msg.text, msg.username);
}
-- } else if (isChatTTSEnabled() && _isTTSEnabledForSource(msg.source_platform)) {
++ } else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
}
break;
@@@ -2749,7 +2777,7 @@ function handleChatMessage(msg) {
// Only route through broadcast audio when on own channel
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && _isTTSEnabledForSource(msg.source_platform)) playBroadcastTTSAudio(msg);
-- } else if (isChatTTSEnabled() && _isTTSEnabledForSource(msg.source_platform)) {
++ } else if (isChatTTSEnabled() && !_isOwnTtsMessage(msg) && _isTTSEnabledForSource(msg.source_platform)) {
playTTSAudio(msg);
}
break;
@@@ -5797,7 +5825,10 @@ function syncTTSToggleButtons() {
/* ── Chat settings panel ──────────────────────────────────────── */
function toggleChatSettings(btn) {
// Find the closest chat container (sidebar, global, offline, broadcast)
-- const container = btn ? btn.closest('.chat-sidebar, .offline-global-chat, .global-chat-main') : null;
++ let container = btn ? btn.closest('.chat-sidebar, .offline-global-chat, .global-chat-main') : null;
++ // Popout window: the gear sits in the page header OUTSIDE the chat column — use
++ // the column, so the popout gets the exact same settings panel as everywhere else.
++ if (!container && document.getElementById('pc-msgs')) container = document.querySelector('.pc-chat-col');
if (!container) return;
let panel = container.querySelector('.chat-settings-panel');
if (panel) {
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 27defed7..29fa9b84 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -130,7 +130,8 @@
<button class="pc-hbtn" id="pc-btn-users" title="Viewer list" onclick="togglePanel('users')">
<i class="fa-solid fa-users"></i>
</button>
-- <button class="pc-hbtn" id="pc-btn-settings" title="Settings" onclick="togglePanel('settings')">
++ <!-- Opens the SHARED chat settings panel (same one as the streamer page) -->
++ <button class="pc-hbtn" id="pc-btn-settings" title="Chat Settings" onclick="toggleChatSettings(this)">
<i class="fa-solid fa-gear"></i>
</button>
</div>
@@@ -204,43 +205,8 @@
</div>
</div>
-- <!-- Settings panel -->
-- <div class="pc-panel" id="pc-panel-settings">
-- <div class="pc-panel-hd">
-- <i class="fa-solid fa-gear"></i> Settings
-- </div>
-- <div class="pc-panel-body">
-- <div class="pc-setting-row">
-- <span class="pc-setting-label">Timestamps</span>
-- <div class="pc-toggle-wrap">
-- <div class="pc-toggle on" id="pc-toggle-ts" onclick="toggleSetting('timestamps')"></div>
-- </div>
-- </div>
-- <div class="pc-setting-row">
-- <span class="pc-setting-label">Sound alerts</span>
-- <div class="pc-toggle-wrap">
-- <div class="pc-toggle" id="pc-toggle-sound" onclick="toggleSetting('sound')"></div>
-- </div>
-- </div>
-- <div class="pc-setting-row">
-- <span class="pc-setting-label">Compact mode</span>
-- <div class="pc-toggle-wrap">
-- <div class="pc-toggle" id="pc-toggle-compact" onclick="toggleSetting('compact')"></div>
-- </div>
-- </div>
-- <!-- TTS on/off lives on the speaker button under the chat input. -->
-- <div class="pc-setting-row">
-- <span class="pc-setting-label">Chat sounds (soundboard &amp; !commands)</span>
-- <div class="pc-toggle-wrap">
-- <div class="pc-toggle" id="pc-toggle-sounds" onclick="toggleSetting('soundsEnabled')"></div>
-- </div>
-- </div>
-- <div class="pc-setting-row" style="flex-direction:column;align-items:stretch;gap:4px">
-- <span class="pc-setting-label">TTS volume</span>
-- <input type="range" id="pc-tts-volume" min="0" max="100" step="5" style="width:100%">
-- </div>
-- </div>
-- </div>
++ <!-- Settings render via the SHARED chat settings panel (toggleChatSettings)
++ inside the chat column — full parity with the streamer page. -->
</div>
<!-- Shared modal + toast containers — showModal('donate'), the [+] channel-upload
@@@ -262,7 +228,7 @@
<script src="/js/vibes.js?v=8"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
-- <script src="/js/chat.js?v=41"></script>
++ <script src="/js/chat.js?v=42"></script>
<script src="/js/chat-autocomplete.js?v=1"></script>
<script>
'use strict';
@@@ -313,38 +279,10 @@
}
syncTitle();
-- // ── Settings panel: delegate entirely to the shared chat.js settings system ──
-- // chat.js already called loadChatSettings() on boot (persisted to openvibe_chat_settings),
-- // so chatSettings is populated before this code runs.
--
-- function syncPopoutSettingsUI() {
-- // Map popout toggle IDs → shared chatSettings keys
-- const mapping = [
-- { toggleId: 'pc-toggle-ts', key: 'showTimestamps' },
-- { toggleId: 'pc-toggle-sound', key: 'soundOnMention' },
-- { toggleId: 'pc-toggle-compact', key: 'compactMode' },
-- { toggleId: 'pc-toggle-sounds', key: 'soundsEnabled' },
-- ];
-- for (const { toggleId, key } of mapping) {
-- const el = document.getElementById(toggleId);
-- if (el) el.classList.toggle('on', !!chatSettings[key]);
-- }
-- const vol = document.getElementById('pc-tts-volume');
-- if (vol) vol.value = chatSettings.ttsVolume ?? 80;
-- // Keep the input-area TTS button in sync (shared styling helper)
-- if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
-- }
--
-- // Popout toggle handler: changes the shared chatSettings and saves/applies them.
-- window.toggleSetting = function(key) {
-- // Map legacy popout key names to shared settings keys
-- const keyMap = { timestamps: 'showTimestamps', sound: 'soundOnMention', compact: 'compactMode' };
-- const settingKey = keyMap[key] || key;
-- chatSettings[settingKey] = !chatSettings[settingKey];
-- syncPopoutSettingsUI();
-- if (typeof applyChatSettings === 'function') applyChatSettings();
-- if (typeof saveChatSettings === 'function') saveChatSettings();
-- };
++ // ── Settings: entirely the shared chat.js settings panel (toggleChatSettings) ──
++ // chat.js already called loadChatSettings() on boot, and the panel renders with
++ // buildSettingsPanelHTML — every setting the streamer page has, kept in sync
++ // across tabs by the shared storage listener.
// Request the viewer list over the shared chat WebSocket. The reply is
// handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.
@@@ -381,17 +319,8 @@
});
function initPopoutChat() {
-- // Sync the popout toggle UI from the shared settings (already loaded by chat.js)
-- syncPopoutSettingsUI();
--
-- // TTS volume slider → shared chatSettings.ttsVolume
-- const vol = document.getElementById('pc-tts-volume');
-- if (vol) {
-- vol.addEventListener('input', () => {
-- chatSettings.ttsVolume = parseInt(vol.value, 10) || 0;
-- if (typeof saveChatSettings === 'function') saveChatSettings();
-- });
-- }
++ // Keep the input-area TTS button state in sync with the shared settings.
++ try { if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons(); } catch { /* */ }
joinTarget();
// Enter-to-send, Shift+Enter newline, and auto-resize all come from chat.js's
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 209834a7..d1f32a66 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1647,6 +1647,10 @@ class ChatServer {
this.broadcastToStream(streamId, {
type: 'tts-audio',
username,
++ // Stable sender identity ("user:<login>" / "anon:<id>") so clients can
++ // skip their OWN message's TTS locally — senders were hearing their
++ // message twice (their tab + the stream audio).
++ sender_key: identityKey || undefined,
message: text,
audio: result.audio,
mimeType: result.mimeType,