diff --git a/extension/background.js b/extension/background.js index f8e3f94..33bfd28 100644 --- a/extension/background.js +++ b/extension/background.js @@ -132,8 +132,14 @@ chrome.runtime.onConnect.addListener((port) => { } }); +let _persistLastSeqTimer = null; function _persistLastSeq() { - if (storageInitialized) chrome.storage.session.set({ lastSeqBySender }); + if (!storageInitialized) return; + if (_persistLastSeqTimer) clearTimeout(_persistLastSeqTimer); + _persistLastSeqTimer = setTimeout(() => { + _persistLastSeqTimer = null; + chrome.storage.session.set({ lastSeqBySender }); + }, 500); } // --- Boot Sequence Lock --- diff --git a/extension/locales/de.json b/extension/locales/de.json index cefe399..efdee88 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut", "REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.", "REGEN_ID_OTHER_ISSUE": "Anderes Problem? Öffne ein GitHub Issue", + "TOAST_ID_REGENERATED": "Identität neu generiert — Verbindung wird wiederhergestellt…", "LABEL_CONN_STATUS": "Verbindungsstatus", "LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus", "CONN_STATUS_DISCONNECTED": "Getrennt", diff --git a/extension/locales/es.json b/extension/locales/es.json index 8fe004e..7e72723 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte", "REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.", "REGEN_ID_OTHER_ISSUE": "¿Tienes otro problema? Abre un reporte en GitHub", + "TOAST_ID_REGENERATED": "Identidad regenerada — reconectando…", "LABEL_CONN_STATUS": "Estado de la conexión", "LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket", "CONN_STATUS_DISCONNECTED": "Desconectado", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index b51dca7..b550c2e 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter", "REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.", "REGEN_ID_OTHER_ISSUE": "Autre problème? Ouvrez un Issue GitHub", + "TOAST_ID_REGENERATED": "Identité régénérée — reconnexion…", "LABEL_CONN_STATUS": "Statut de la connexion", "LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket", "CONN_STATUS_DISCONNECTED": "Déconnecté", diff --git a/extension/locales/it.json b/extension/locales/it.json index 3f8160d..21996c7 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Genera un nuovo ID interno e riconnettiti", "REGEN_ID_DESC": "Usa questa opzione se riscontri errori di 'Identità Duplicata'.", "REGEN_ID_OTHER_ISSUE": "Hai altri problemi? Apri una segnalazione su GitHub", + "TOAST_ID_REGENERATED": "Identità rigenerata — riconnessione…", "LABEL_CONN_STATUS": "Stato Connessione", "LABEL_CONN_STATUS_TOOLTIP": "Stato attuale della connessione", "CONN_STATUS_DISCONNECTED": "Disconnesso", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 3b15360..cc3ab9d 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "内部IDを再生成して再接続します", "REGEN_ID_DESC": "「Duplicate Identity」(ID重複)エラーが表示される場合に使用します。", "REGEN_ID_OTHER_ISSUE": "他の問題?GitHub Issueを開く", + "TOAST_ID_REGENERATED": "IDを再生成しました — 再接続中…", "LABEL_CONN_STATUS": "接続状態", "LABEL_CONN_STATUS_TOOLTIP": "現在のWebSocket接続状態", "CONN_STATUS_DISCONNECTED": "切断されました", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index 7063238..ea573a5 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "내부 ID를 재생성하고 다시 연결합니다", "REGEN_ID_DESC": "\"중복된 ID\" 오류가 발생할 경우에 사용하세요.", "REGEN_ID_OTHER_ISSUE": "다른 문제? GitHub Issue 열기", + "TOAST_ID_REGENERATED": "ID가 재생성되었습니다 — 다시 연결 중…", "LABEL_CONN_STATUS": "연결 상태", "LABEL_CONN_STATUS_TOOLTIP": "현재 WebSocket 연결 상태", "CONN_STATUS_DISCONNECTED": "연결 끊김", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index edfc97c..6a07ca2 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Genereer uw interne ID opnieuw en maak opnieuw verbinding", "REGEN_ID_DESC": "Gebruik dit als u fouten ziet over 'Duplicate Identity'.", "REGEN_ID_OTHER_ISSUE": "Ander probleem? Open een GitHub Issue", + "TOAST_ID_REGENERATED": "Identiteit opnieuw gegenereerd — opnieuw verbinden…", "LABEL_CONN_STATUS": "Verbindingsstatus", "LABEL_CONN_STATUS_TOOLTIP": "Huidige WebSocket-verbindingsstatus", "CONN_STATUS_DISCONNECTED": "Verbinding verbroken", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index 0196a94..2cc04be 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Wygeneruj ponownie swój identyfikator i połącz się ponownie", "REGEN_ID_DESC": "Użyj tego, jeśli widzisz błędy o zduplikowanej tożsamości.", "REGEN_ID_OTHER_ISSUE": "Inny problem? Otwórz Issue na GitHub", + "TOAST_ID_REGENERATED": "Tożsamość wygenerowana ponownie — ponowne łączenie…", "LABEL_CONN_STATUS": "Status połączenia", "LABEL_CONN_STATUS_TOOLTIP": "Bieżący stan połączenia WebSocket", "CONN_STATUS_DISCONNECTED": "Rozłączono", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 4c64d6e..87ab3bc 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Gerar um novo ID interno e conectar novamente", "REGEN_ID_DESC": "Use esta opção se aparecer o erro de 'Identidade duplicada'.", "REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub", + "TOAST_ID_REGENERATED": "Identidade regenerada — reconectando…", "LABEL_CONN_STATUS": "Status da conexão", "LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket", "CONN_STATUS_DISCONNECTED": "Desconectado", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 949bf54..4b7fbe3 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Regerar o seu ID interno e voltar a ligar", "REGEN_ID_DESC": "Use isto se encontrar erros de 'Identidade Duplicada'.", "REGEN_ID_OTHER_ISSUE": "Outro problema? Abra um relatório no GitHub", + "TOAST_ID_REGENERATED": "Identidade regenerada — a voltar a ligar…", "LABEL_CONN_STATUS": "Estado da Ligação", "LABEL_CONN_STATUS_TOOLTIP": "Estado atual da ligação", "CONN_STATUS_DISCONNECTED": "Desligado", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 0ad6f57..06e1dfd 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться", "REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.", "REGEN_ID_OTHER_ISSUE": "Другая проблема? Откройте Issue на GitHub", + "TOAST_ID_REGENERATED": "Идентичность перегенерирована — переподключение…", "LABEL_CONN_STATUS": "Статус подключения", "LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером", "CONN_STATUS_DISCONNECTED": "Отключено", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index c3a2616..a6caddb 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Dahili kimliğinizi yeniden oluşturun ve tekrar bağlanın", "REGEN_ID_DESC": "\"Duplicate Identity\" (Mükerrer Kimlik) hataları görüyorsanız bunu kullanın.", "REGEN_ID_OTHER_ISSUE": "Başka bir sorun? GitHub Issue açın", + "TOAST_ID_REGENERATED": "Kimlik yeniden oluşturuldu — yeniden bağlanılıyor…", "LABEL_CONN_STATUS": "Bağlantı Durumu", "LABEL_CONN_STATUS_TOOLTIP": "Mevcut WebSocket bağlantı durumu", "CONN_STATUS_DISCONNECTED": "Bağlantı Kesildi", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 551e5a3..6f4466f 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "Повторно згенеруйте свій внутрішній ідентифікатор і повторно підключіться", "REGEN_ID_DESC": "Використовуйте це, якщо ви бачите помилку \"Дублікати ідентифікатора\".", "REGEN_ID_OTHER_ISSUE": "Інша проблема? Відкрийте випуск GitHub", + "TOAST_ID_REGENERATED": "Ідентичність перегенеровано — повторне підключення…", "LABEL_CONN_STATUS": "Статус підключення", "LABEL_CONN_STATUS_TOOLTIP": "Поточний стан підключення WebSocket", "CONN_STATUS_DISCONNECTED": "Відключено", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index 7e07428..26247fc 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -109,6 +109,7 @@ "BTN_REGEN_ID_TOOLTIP": "重新生成您的内部 ID 并重新连接", "REGEN_ID_DESC": "如果您看到“重复身份”错误,请使用此选项。", "REGEN_ID_OTHER_ISSUE": "其他问题?打开 GitHub 问题", + "TOAST_ID_REGENERATED": "身份已重新生成 — 正在重新连接…", "LABEL_CONN_STATUS": "连接状态", "LABEL_CONN_STATUS_TOOLTIP": "当前WebSocket连接状态", "CONN_STATUS_DISCONNECTED": "已断开连接", diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 7e7a0de..5c1186c 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -245,6 +245,9 @@ try { await w(ho,'force_sync_prepare'); await w(hg,'force_sync_prepare'); ho._m.length = hc._m.length = hg._m.length = 0; // owner demotes the co-host mid-flight — the EXECUTE must still go through. + // Wait out the per-room role-change debounce (M-4) so this demote broadcasts: + // in real usage the host can't promote, run a force-sync, and demote inside 500ms. + await new Promise(r => setTimeout(r, 550)); s(ho,'set_peer_role',{peerId:'cohost',controller:false}); await w(hc,'control_mode'); ho._m.length = hc._m.length = hg._m.length = 0; diff --git a/server/index.js b/server/index.js index b3966e0..3a117f8 100644 --- a/server/index.js +++ b/server/index.js @@ -411,6 +411,7 @@ io.on('connection', (socket) => { hostPeerId: peerId, controlMode: CONTROL_MODES.EVERYONE, lastControlModeChangeAt: 0, // M-4: per-room debounce for control-mode toggles + lastRoleChangeAt: 0, // M-4: per-room debounce for role promote/demote // Co-Host: peers allowed to drive in 'host-only'. Always includes the owner. controllers: new Set([peerId]), // H-1: peerId of the in-flight force-sync initiator. Lets a demoted @@ -585,10 +586,10 @@ io.on('connection', (socket) => { room.peerData.set(socket.id, { ...existing, username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username, - tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle, - mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle, + tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle), + mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle), playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState, - currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime, + currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime), volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume, muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted, desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false), @@ -599,12 +600,12 @@ io.on('connection', (socket) => { const relayPayload = { senderId: mapping.peerId, seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER), - currentTime: clampNum(data.currentTime, 0, 86400), + currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400), targetTime: clampNum(data.targetTime, 0, 86400), playbackState: validState(data.playbackState), username: clamp(data.username, 30), - tabTitle: clamp(data.tabTitle, 100), - mediaTitle: clamp(data.mediaTitle, 100), + tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100), + mediaTitle: data.mediaTitle === null ? null : clamp(data.mediaTitle, 100), volume: clampNum(data.volume, 0, 1), muted: validBool(data.muted), desynced: validBool(data.desynced), @@ -742,6 +743,13 @@ io.on('connection', (socket) => { const targetPresent = Array.from(room.peerData.values()).some(d => d.peerId === targetPeerId); if (!targetPresent) return; + const now = Date.now(); + if (now - (room.lastRoleChangeAt || 0) < CONTROL_MODE_MIN_INTERVAL_MS) { + log('ROOM', `Role change debounced in ${mapping.roomId.substring(0, 3)}***`); + socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + return; + } + if (makeController) { if (room.controllers.has(targetPeerId)) return; // no-op room.controllers.add(targetPeerId); @@ -749,7 +757,8 @@ io.on('connection', (socket) => { if (!room.controllers.has(targetPeerId)) return; // no-op room.controllers.delete(targetPeerId); } - room.lastActivity = Date.now(); + room.lastRoleChangeAt = now; + room.lastActivity = now; io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Peer ${targetPeerId} ${makeController ? 'promoted to' : 'demoted from'} controller in ${mapping.roomId.substring(0, 3)}***`); });