fix(host-control-mode): harden gates, close UX races, debounce toggles, surface desync state

Bis auf peerId-Auth (akzeptiert) alle Audit-Findings behoben:
- Snap-back spielt nicht mehr blind ab, wenn Host-Status unbekannt
- Teardown-Pfade broadcasten jetzt CONTROL_MODE → kein verwaistes Badge/Dialog
- Dialog-Timer bei Ersetzung sauber gecleart
- Server re-synchronisiert Sender bei nicht-Host-Reject
- Popup-Toggle revertiert bei Server-Ablehnung
- Desync-Zweig sendet keine Phantom-ACKs mehr an Host
- Toggle-Debouncing serverseitig (500ms pro Raum)
- Button-Text auf Unlock zurückgesetzt
- Live-Erkennung defensiver, Resync-Retry bei fehlendem Target, Badge-Retry

Neu: Host sieht 'Solo'-Badge für desyncte Gäste via PEER_STATUS-Heartbeat
(16 Sprachen, neuer i18n-Key BADGE_DESYNCED/TOOLTIP_PEER_DESYNCED)

Tests: H-5-Reject-Unicast, M-4-Debounce, Desync-Heartbeat-Relay
This commit is contained in:
KoalaDev
2026-06-26 13:07:17 +02:00
parent 69f8f4cfd7
commit 58af258fd3
20 changed files with 245 additions and 20 deletions
+32 -2
View File
@@ -74,6 +74,10 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
// --- Host Control Mode ---
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
let hostPeerId = null; // peerId of the room host (creator / fallback)
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
// in heartbeats so the host's popup UI can show "Solo" instead of silently
// appearing un-ACK'd.
let hcmDesynced = false;
function amHost() { return !!peerId && hostPeerId === peerId; }
// Room-moving actions a guest may not initiate while in host-only mode.
const HOST_ONLY_GATED_ACTIONS = [
@@ -281,6 +285,7 @@ function createPeerData(raw) {
currentTime: raw.currentTime != null ? raw.currentTime : null,
volume: raw.volume != null ? raw.volume : null,
muted: raw.muted != null ? raw.muted : null,
desynced: raw.desynced === true, // HCM: peer is watching on their own
lastHeartbeat: Date.now()
};
}
@@ -465,6 +470,10 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -915,7 +924,11 @@ function handleServerEvent(event, data) {
// Host Control Mode (receiver-side backstop): in host-only mode, ignore
// room-moving events from any non-host peer. The server already drops these,
// so this covers old/buggy/modified clients that slipped through.
// Defensive: require a known hostPeerId — if the server ever sends host-only
// without a host (state inconsistency), gate-everyone would lock the host
// out of their own room (L-6).
if (controlMode === CONTROL_MODES.HOST_ONLY &&
hostPeerId &&
HOST_ONLY_GATED_ACTIONS.includes(event) &&
data.senderId && data.senderId !== hostPeerId) {
addLog(`Ignored ${event} from non-host ${data.senderId} (host-only)`, 'warn');
@@ -1199,6 +1212,7 @@ function handleServerEvent(event, data) {
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
peer.desynced = data.desynced === true;
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
const ignoreStatus = timeSinceReactive < 300;
@@ -1553,6 +1567,10 @@ function leaveOldRoomIfSwitching(newRoomId) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
hcmDesynced = false;
// Notify content.js/popup so they drop any guest-side HCM state from the
// previous room (badge/dialog/desync) — H-2/H-3.
broadcastControlMode();
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -1689,6 +1707,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'REQUEST_HOST_SYNC') {
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
} else if (message.type === 'HCM_DESYNC_STATE') {
// content.js tells us whether the local user chose to watch on their own.
// Mirrored into heartbeats so the host's UI can show "Solo" instead of
// silently waiting for ACKs that will never come.
hcmDesynced = !!message.desynced;
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
connectIntent = false;
reconnectFailed = false;
@@ -1699,6 +1723,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -1822,7 +1850,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Host Control Mode (sender-side): a guest in host-only mode must not
// drive the room. Don't broadcast; hand the action back to content.js so
// it can snap the local player back (and, in step 4, offer desync).
if (controlMode === CONTROL_MODES.HOST_ONLY && !amHost() &&
// Defensive: require a known hostPeerId (L-6) — otherwise the actual
// host would gate themselves if state ever becomes inconsistent.
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amHost() &&
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
if (sender.tab && sender.tab.id) {
@@ -1974,7 +2004,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
markRoomUseful();
getSettings().then(settings => {
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle, desynced: hcmDesynced };
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
+88 -15
View File
@@ -109,10 +109,12 @@
// really wants to — let them go solo (desync) with a resync escape hatch.
let hcmControlMode = 'everyone'; // mirror of room control mode
let hcmAmHost = false; // are we the host?
let hcmHostPeerId = null; // last known host peerId (room/host identity)
let hcmDesynced = false; // user chose to go solo
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
const HCM_USER_GESTURE_MS = 1000;
const HCM_BUFFERING_GRACE_MS = 1500;
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
@@ -147,6 +149,10 @@
// (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its
// seekable window doesn't start at 0, which we use as the DVR signal.
function hcmIsLive(video) {
// Don't trust duration before metadata has loaded (readyState >= 1) —
// otherwise pre-loaded videos report NaN and get misclassified as live,
// which suppresses the desync dialog (L-2).
if (video.readyState < 1) return false;
if (!Number.isFinite(video.duration)) return true;
try {
const s = video.seekable;
@@ -164,10 +170,12 @@
if (target && Number.isFinite(target.targetTime)) {
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
}
// Adopt the host's play/pause state (default: resume playing).
// Adopt the host's play/pause state — but ONLY if we actually know it.
// Defaulting to PLAY when the state is unknown would auto-resume a paused
// video against the host's real state (H-1).
if (target && target.playbackState === 'paused') {
tryMediaAction(EVENTS.PAUSE);
} else {
} else if (target && target.playbackState === 'playing') {
tryMediaAction(EVENTS.PLAY);
}
reportLog('Host-only: snapped back to host position', 'info');
@@ -181,12 +189,16 @@
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmHost = false;
if (Date.now() < hcmSnapBackCooldownUntil) return; // EC-4 loop guard
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
if (intent === 'live') return; // EC-15: leave the guest alone on live
if (intent === 'involuntary') {
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
// cooldown — the deliberate dialog path below must still go through,
// otherwise a second deliberate pause inside the cooldown window leaves
// the user stuck paused with no UI (M-3).
if (Date.now() < hcmSnapBackCooldownUntil) return;
// Buffering/ads/throttle — silently re-sync, no dialog spam.
hcmSnapBackToHost(target);
return;
@@ -201,6 +213,7 @@
// Shadow DOM so the page's CSS can't restyle or hide our controls.
let hcmDialogHost = null; // shadow host element for the dialog
let hcmBadgeHost = null; // shadow host element for the persistent badge
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
function hcmEl(tag, css, text) {
const el = document.createElement(tag);
@@ -210,6 +223,9 @@
}
function hcmRemoveDialog() {
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
// can't later remove its successor / snap to an outdated target (H-4).
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
}
@@ -234,33 +250,77 @@
hcmDialogHost = host;
let settled = false;
const stay = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmSnapBackToHost(target); };
// Re-query the host's current position on click instead of using the
// potentially stale target captured at HOST_BLOCKED time (M-1).
const stay = () => {
if (settled) return; settled = true; hcmRemoveDialog();
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.target) hcmSnapBackToHost(res.target);
});
};
const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); };
stayBtn.addEventListener('click', stay);
soloBtn.addEventListener('click', solo);
// EC-18: if the user ignores the prompt, default to staying in sync.
setTimeout(() => { if (!settled) stay(); }, 8000);
hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000);
}
function hcmEnterDesync() {
hcmDesynced = true;
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
// Notify background so it can relay our desynced state to the host via
// heartbeats — the host's UI then knows we're not following commands
// instead of appearing silently un-ACK'd.
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
hcmShowBadge();
}
function hcmExitDesync() {
const wasDesynced = hcmDesynced;
hcmDesynced = false;
hcmRemoveBadge();
if (wasDesynced) {
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
}
// Resync: ask background for the host's current position and snap to it.
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.target) hcmSnapBackToHost(res.target);
});
// Retry briefly if the host's state isn't known yet (e.g. they just paused
// and no heartbeat has propagated) — otherwise the user's "Resync" tap is
// a silent no-op and they think they're synced when they aren't (L-3).
let attempts = 0;
const tryResync = () => {
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError || !res || !res.target) {
if (++attempts < 5) setTimeout(tryResync, 250);
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
return;
}
hcmSnapBackToHost(res.target);
});
};
tryResync();
reportLog('Host-only: resynced with the host', 'info');
}
function hcmShowBadge() {
if (hcmBadgeHost || !document.body) return;
if (hcmBadgeHost) return;
if (!document.body) {
// Body not ready yet (very early injection). Defer until DOMReady,
// otherwise the desynced user silently never sees the badge (L-4).
if (!hcmBadgePending) {
hcmBadgePending = true;
const retry = () => {
hcmBadgePending = false;
if (hcmDesynced && !hcmBadgeHost) hcmShowBadge();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', retry, { once: true });
} else {
setTimeout(retry, 50);
}
}
return;
}
const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' });
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
@@ -276,9 +336,16 @@
}
function hcmReset() {
const wasDesynced = hcmDesynced;
hcmDesynced = false;
hcmRemoveDialog();
hcmRemoveBadge();
// If we were desynced, notify background so it stops reporting us as
// desynced in heartbeats (otherwise the host's UI keeps showing the
// stale Solo badge until the next state change).
if (wasDesynced) {
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
}
}
function reportLog(message, level = 'info') {
@@ -724,10 +791,15 @@
// Host Control Mode: room mode/role changed.
if (message.type === 'CONTROL_MODE') {
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmHost = !!message.amHost;
// Leaving host-only, or becoming host, clears any guest-side state.
if (wasGated && !hcmIsGuestGated()) hcmReset();
hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
return true;
}
@@ -752,14 +824,15 @@
let actionCompleted = false;
// Host Control Mode: while watching on our own (desynced), don't apply
// host commands. Still ACK so the host's force-sync doesn't stall on us.
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
// is intentional so the host's UI honestly reflects that we didn't apply
// the command (M-2); sending them would make the host think we're synced.
if (hcmDesynced) {
const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
if (soloIgnored.includes(action)) {
if (action === EVENTS.FORCE_SYNC_PREPARE) {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
} else if (action !== EVENTS.FORCE_SYNC_EXECUTE) {
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }).catch(() => {});
}
return;
}
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts",
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
"BTN_LEAVE_ROOM": "Raum verlassen",
"LABEL_SELECT_VIDEO": "Video auswählen",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "The host controls playback for everyone.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Guest",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored",
"NO_PEERS_CONNECTED": "No peers connected",
"BTN_LEAVE_ROOM": "Leave Room",
"LABEL_SELECT_VIDEO": "Select Video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.",
"BADGE_HOST": "Anfitrión",
"BADGE_GUEST": "Invitado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión",
"NO_PEERS_CONNECTED": "Sin participantes conectados",
"BTN_LEAVE_ROOM": "Salir de la sala",
"LABEL_SELECT_VIDEO": "Seleccionar video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.",
"BADGE_HOST": "Hôte",
"BADGE_GUEST": "Invité",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte",
"NO_PEERS_CONNECTED": "Aucun membre connecté",
"BTN_LEAVE_ROOM": "Quitter le salon",
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Ospite",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host",
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
"BTN_LEAVE_ROOM": "Lascia Stanza",
"LABEL_SELECT_VIDEO": "Seleziona Video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。",
"BADGE_HOST": "ホスト",
"BADGE_GUEST": "ゲスト",
"BADGE_DESYNCED": "ソロ",
"TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています",
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
"BTN_LEAVE_ROOM": "ルームを退室",
"LABEL_SELECT_VIDEO": "ビデオを選択",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.",
"BADGE_HOST": "호스트",
"BADGE_GUEST": "게스트",
"BADGE_DESYNCED": "단독",
"TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다",
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
"BTN_LEAVE_ROOM": "방 나가기",
"LABEL_SELECT_VIDEO": "비디오 선택",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host",
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
"BTN_LEAVE_ROOM": "Kamer verlaten",
"LABEL_SELECT_VIDEO": "Selecteer video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gość",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta",
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
"BTN_LEAVE_ROOM": "Opuść pokój",
"LABEL_SELECT_VIDEO": "Wybierz wideo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião",
"NO_PEERS_CONNECTED": "Sem participantes conectados",
"BTN_LEAVE_ROOM": "Sair da sala",
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião",
"NO_PEERS_CONNECTED": "Nenhum participante ligado",
"BTN_LEAVE_ROOM": "Sair da Sala",
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.",
"BADGE_HOST": "Ведущий",
"BADGE_GUEST": "Гость",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего",
"NO_PEERS_CONNECTED": "Нет подключенных участников",
"BTN_LEAVE_ROOM": "Выйти из комнаты",
"LABEL_SELECT_VIDEO": "Выбрать видео",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.",
"BADGE_HOST": "Sunucu",
"BADGE_GUEST": "Misafir",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar",
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
"LABEL_SELECT_VIDEO": "Video Seç",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.",
"BADGE_HOST": "Ведучий",
"BADGE_GUEST": "Гість",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого",
"NO_PEERS_CONNECTED": "Немає підключених однорангових пристроїв",
"BTN_LEAVE_ROOM": "Вийти з кімнати",
"LABEL_SELECT_VIDEO": "Виберіть Відео",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。",
"BADGE_HOST": "主持人",
"BADGE_GUEST": "访客",
"BADGE_DESYNCED": "单独",
"TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令",
"NO_PEERS_CONNECTED": "没有对等点连接",
"BTN_LEAVE_ROOM": "离开房间",
"LABEL_SELECT_VIDEO": "选择视频",
+19
View File
@@ -346,6 +346,15 @@ function setRemoteControlsLocked(locked) {
btn.style.cursor = locked ? 'not-allowed' : '';
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
});
// When unlocking, also restore the default labels. The action handlers leave
// the text in a transitional state ("Playing..." / "Pausing...") and the 2.5s
// safety reset skips the refresh while we were guest-locked, so without this
// the button can be re-enabled with stale text after the host disables
// host-only (L-1).
if (!locked) {
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play';
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
}
}
if (elements.hostControlToggle) {
@@ -650,6 +659,16 @@ function updatePeerList(peers) {
header.appendChild(you);
}
// Host Control Mode: show "Solo" badge for peers watching on their own.
if (typeof p === 'object' && p.desynced) {
const solo = document.createElement('span');
solo.style.cssText = 'font-size:10px; color:#fff; background:#b45309; padding:2px 6px; border-radius:6px; font-weight:600;';
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
solo.textContent = soloText;
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
header.appendChild(solo);
}
peerItem.appendChild(header);
// Media Info