openvibers/openvibe.live Commits
Chat: popout uses the real settings panel; own-message TTS never plays locally
Changes
@@@ -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>@@@ -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 channelif (_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) {@@@ -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 & !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@@@ -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,
@@ -5001,7 +5001,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" 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/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
<script src="/js/controls.js?v=8" defer></script>
@@ -794,6 +794,27 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
@@ -794,6 +794,27 @@ let _ownTtsLeader = !(typeof navigator !== 'undefined' && 'locks' in navigator);
})();
})();
function isOwnChannelTtsSpeaker() { return _ownTtsLeader; }
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 ──────────────────────────────────────────────────
// ── Cross-tab settings sync ──────────────────────────────────────────────────
// chatSettings persist in localStorage; the storage event fires in every OTHER
// chatSettings persist in localStorage; the storage event fires in every OTHER
// tab on save, so a toggle flipped anywhere applies everywhere immediately —
// tab on save, so a toggle flipped anywhere applies everywhere immediately —
@@ -2612,6 +2633,13 @@ function handleChatMessage(msg) {
@@ -2612,6 +2633,13 @@ function handleChatMessage(msg) {
renderChatUsersList(msg.users);
renderChatUsersList(msg.users);
break;
break;
case 'auth':
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) {
if (msg.authenticated) {
addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`);
addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`);
} else {
} else {
@@ -2740,7 +2768,7 @@ function handleChatMessage(msg) {
@@ -2740,7 +2768,7 @@ function handleChatMessage(msg) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && typeof speakBroadcastTTS === 'function' && _isTTSEnabledForSource(msg.source_platform)) {
speakBroadcastTTS(msg.message || msg.text, msg.username);
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);
speakTTS(msg.message || msg.text, msg.voiceFX, msg.username);
}
}
break;
break;
@@ -2749,7 +2777,7 @@ function handleChatMessage(msg) {
@@ -2749,7 +2777,7 @@ function handleChatMessage(msg) {
// Only route through broadcast audio when on own channel
// Only route through broadcast audio when on own channel
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (_isViewingOwnBroadcastChat() && typeof playBroadcastTTSAudio === 'function') {
if (isOwnChannelTtsSpeaker() && isChatTTSEnabled({ streaming: true }) && _isTTSEnabledForSource(msg.source_platform)) playBroadcastTTSAudio(msg);
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);
playTTSAudio(msg);
}
}
break;
break;
@@ -5797,7 +5825,10 @@ function syncTTSToggleButtons() {
@@ -5797,7 +5825,10 @@ function syncTTSToggleButtons() {
/* ── Chat settings panel ──────────────────────────────────────── */
/* ── Chat settings panel ──────────────────────────────────────── */
function toggleChatSettings(btn) {
function toggleChatSettings(btn) {
// Find the closest chat container (sidebar, global, offline, broadcast)
// 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;
if (!container) return;
let panel = container.querySelector('.chat-settings-panel');
let panel = container.querySelector('.chat-settings-panel');
if (panel) {
if (panel) {
@@ -130,7 +130,8 @@
@@ -130,7 +130,8 @@
<button class="pc-hbtn" id="pc-btn-users" title="Viewer list" onclick="togglePanel('users')">
<button class="pc-hbtn" id="pc-btn-users" title="Viewer list" onclick="togglePanel('users')">
<i class="fa-solid fa-users"></i>
<i class="fa-solid fa-users"></i>
</button>
</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>
<i class="fa-solid fa-gear"></i>
</button>
</button>
</div>
</div>
@@ -204,43 +205,8 @@
@@ -204,43 +205,8 @@
</div>
</div>
</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 & !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>
</div>
<!-- Shared modal + toast containers — showModal('donate'), the [+] channel-upload
<!-- Shared modal + toast containers — showModal('donate'), the [+] channel-upload
@@ -262,7 +228,7 @@
@@ -262,7 +228,7 @@
<script src="/js/vibes.js?v=8"></script>
<script src="/js/vibes.js?v=8"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></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 src="/js/chat-autocomplete.js?v=1"></script>
<script>
<script>
'use strict';
'use strict';
@@ -313,38 +279,10 @@
@@ -313,38 +279,10 @@
}
}
syncTitle();
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
// Request the viewer list over the shared chat WebSocket. The reply is
// handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.
// handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.
@@ -381,17 +319,8 @@
@@ -381,17 +319,8 @@
});
});
function initPopoutChat() {
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();
joinTarget();
// Enter-to-send, Shift+Enter newline, and auto-resize all come from chat.js's
// Enter-to-send, Shift+Enter newline, and auto-resize all come from chat.js's
@@ -1647,6 +1647,10 @@ class ChatServer {
@@ -1647,6 +1647,10 @@ class ChatServer {
this.broadcastToStream(streamId, {
this.broadcastToStream(streamId, {
type: 'tts-audio',
type: 'tts-audio',
username,
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,
message: text,
audio: result.audio,
audio: result.audio,
mimeType: result.mimeType,
mimeType: result.mimeType,