feat(co-host): popup UI — promote/demote, controller badges, controller-aware lock

- updateHostControlUI takes a state object now; role badge shows Host / Controller
  / Guest; remote-control lock uses amController (a co-host can drive, so their
  buttons aren't locked).
- Peer list: Host/Controller badges and, for the owner, a per-peer Give control /
  Revoke button (SET_PEER_ROLE). Shown only when the feature is active and the room
  is host-only or the viewer is the owner — no role noise for guests in normal rooms.
  Hidden entirely against a relay without the co-host capability.
- i18n: BADGE_CONTROLLER, BTN_GIVE_CONTROL, BTN_REVOKE_CONTROL across all 15 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-28 03:45:00 +02:00
parent 7c65fe80cb
commit 1ec2396c41
16 changed files with 110 additions and 20 deletions
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_CONTROLLER": "Controller",
"BTN_GIVE_CONTROL": "Steuerung geben",
"BTN_REVOKE_CONTROL": "Entziehen",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts",
"HCM_DIALOG_TITLE": "KoalaSync · Der Host steuert diesen Raum",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "The host controls playback for everyone.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Guest",
"BADGE_CONTROLLER": "Controller",
"BTN_GIVE_CONTROL": "Give control",
"BTN_REVOKE_CONTROL": "Revoke",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored",
"HCM_DIALOG_TITLE": "KoalaSync · Host controls this room",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.",
"BADGE_HOST": "Anfitrión",
"BADGE_GUEST": "Invitado",
"BADGE_CONTROLLER": "Controlador",
"BTN_GIVE_CONTROL": "Dar control",
"BTN_REVOKE_CONTROL": "Quitar",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión",
"HCM_DIALOG_TITLE": "KoalaSync · El anfitrión controla esta sala",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.",
"BADGE_HOST": "Hôte",
"BADGE_GUEST": "Invité",
"BADGE_CONTROLLER": "Contrôleur",
"BTN_GIVE_CONTROL": "Donner le contrôle",
"BTN_REVOKE_CONTROL": "Retirer",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte",
"HCM_DIALOG_TITLE": "KoalaSync · L'hôte contrôle ce salon",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Ospite",
"BADGE_CONTROLLER": "Controller",
"BTN_GIVE_CONTROL": "Dai il controllo",
"BTN_REVOKE_CONTROL": "Revoca",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host",
"HCM_DIALOG_TITLE": "KoalaSync · L'host controlla questa stanza",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。",
"BADGE_HOST": "ホスト",
"BADGE_GUEST": "ゲスト",
"BADGE_CONTROLLER": "操作権あり",
"BTN_GIVE_CONTROL": "操作権を付与",
"BTN_REVOKE_CONTROL": "取り消す",
"BADGE_DESYNCED": "ソロ",
"TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています",
"HCM_DIALOG_TITLE": "KoalaSync · ホストがこのルームを操作中",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.",
"BADGE_HOST": "호스트",
"BADGE_GUEST": "게스트",
"BADGE_CONTROLLER": "제어자",
"BTN_GIVE_CONTROL": "제어 권한 부여",
"BTN_REVOKE_CONTROL": "취소",
"BADGE_DESYNCED": "단독",
"TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다",
"HCM_DIALOG_TITLE": "KoalaSync · 호스트가 이 방을 제어합니다",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_CONTROLLER": "Bediener",
"BTN_GIVE_CONTROL": "Bediening geven",
"BTN_REVOKE_CONTROL": "Intrekken",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host",
"HCM_DIALOG_TITLE": "KoalaSync · De host bedient deze kamer",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gość",
"BADGE_CONTROLLER": "Sterujący",
"BTN_GIVE_CONTROL": "Daj sterowanie",
"BTN_REVOKE_CONTROL": "Odbierz",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta",
"HCM_DIALOG_TITLE": "KoalaSync · Host steruje tym pokojem",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_CONTROLLER": "Controlador",
"BTN_GIVE_CONTROL": "Dar controle",
"BTN_REVOKE_CONTROL": "Revogar",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião",
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_CONTROLLER": "Controlador",
"BTN_GIVE_CONTROL": "Dar controlo",
"BTN_REVOKE_CONTROL": "Revogar",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião",
"HCM_DIALOG_TITLE": "KoalaSync · O anfitrião controla esta sala",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.",
"BADGE_HOST": "Ведущий",
"BADGE_GUEST": "Гость",
"BADGE_CONTROLLER": "Управляющий",
"BTN_GIVE_CONTROL": "Дать управление",
"BTN_REVOKE_CONTROL": "Отозвать",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего",
"HCM_DIALOG_TITLE": "KoalaSync · Комнатой управляет ведущий",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.",
"BADGE_HOST": "Sunucu",
"BADGE_GUEST": "Misafir",
"BADGE_CONTROLLER": "Kontrolcü",
"BTN_GIVE_CONTROL": "Kontrol ver",
"BTN_REVOKE_CONTROL": "Geri al",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar",
"HCM_DIALOG_TITLE": "KoalaSync · Bu odayı sunucu kontrol ediyor",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.",
"BADGE_HOST": "Ведучий",
"BADGE_GUEST": "Гість",
"BADGE_CONTROLLER": "Керуючий",
"BTN_GIVE_CONTROL": "Дати керування",
"BTN_REVOKE_CONTROL": "Відкликати",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого",
"HCM_DIALOG_TITLE": "KoalaSync · Кімнатою керує ведучий",
+3
View File
@@ -50,6 +50,9 @@
"NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。",
"BADGE_HOST": "主持人",
"BADGE_GUEST": "访客",
"BADGE_CONTROLLER": "控制者",
"BTN_GIVE_CONTROL": "授予控制权",
"BTN_REVOKE_CONTROL": "撤销",
"BADGE_DESYNCED": "单独",
"TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令",
"HCM_DIALOG_TITLE": "KoalaSync · 主持人控制本房间",
+65 -20
View File
@@ -249,7 +249,7 @@ async function init() {
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, res.status === 'connected');
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: res.status === 'connected' });
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// If user has a room configured but background is not connected (disconnected or idle),
@@ -313,19 +313,37 @@ function toggleUIState(inRoom) {
// True when we're a guest in a host-only room → remote-control buttons are locked.
let hcmGuestLocked = false;
function updateHostControlUI(controlMode, amHost, hostControlSupported, inRoom) {
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
let hcmAmOwner = false;
let hcmControllers = [];
let hcmCoHostSupported = false;
let hcmOwnerPeerId = null;
let hcmRoomIsHostOnly = false;
function updateHostControlUI(state) {
const s = state || {};
const controlMode = s.controlMode;
const amHost = !!s.amHost;
const amController = !!s.amController;
const hostOnly = controlMode === 'host-only';
// Stash for the peer-list renderer, then force it to re-render with new roles.
hcmAmOwner = amHost;
hcmControllers = Array.isArray(s.controllers) ? s.controllers : [];
hcmCoHostSupported = !!s.coHostSupported;
hcmOwnerPeerId = s.hostPeerId || null;
hcmRoomIsHostOnly = hostOnly;
lastPeersJson = '';
if (activePeers) updatePeerList(activePeers);
const card = elements.hostControlCard;
if (!card) return;
const hostOnly = controlMode === 'host-only';
// Explicit capability advertised by the relay in ROOM_DATA. Against an older
// relay it's false/absent → the feature is unavailable, so hide the card
// entirely instead of showing a misleading "Guest".
const serverSupportsHostControl = !!hostControlSupported;
// Only show the card when it's actually meaningful:
// - host: always (so they can enable/disable host-only)
// - guest: only while host-only is active (explains why they can't control)
// A guest in a normal "everyone" room sees nothing — no confusing noise.
const show = inRoom && serverSupportsHostControl && (amHost || hostOnly);
// Explicit capability advertised by the relay. Against an older relay it's
// false/absent → feature unavailable, so hide the card instead of misleading UI.
const serverSupportsHostControl = !!s.hostControlSupported;
// Show the card only when meaningful: owner always (to manage); a non-controller
// guest only while host-only is active. A guest in a normal room sees nothing.
const show = s.inRoom && serverSupportsHostControl && (amHost || hostOnly);
if (!show) {
card.style.display = 'none';
hcmGuestLocked = false;
@@ -334,16 +352,18 @@ function updateHostControlUI(controlMode, amHost, hostControlSupported, inRoom)
}
card.style.display = 'block';
if (elements.hostRoleBadge) {
elements.hostRoleBadge.textContent = amHost ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_GUEST') || 'Guest');
elements.hostRoleBadge.style.background = amHost ? 'var(--accent)' : 'var(--text-muted)';
const role = amHost ? (getMessage('BADGE_HOST') || 'Host')
: amController ? (getMessage('BADGE_CONTROLLER') || 'Controller')
: (getMessage('BADGE_GUEST') || 'Guest');
elements.hostRoleBadge.textContent = role;
elements.hostRoleBadge.style.background = (amHost || amController) ? 'var(--accent)' : 'var(--text-muted)';
}
if (elements.hostControlToggleRow) elements.hostControlToggleRow.style.display = amHost ? 'flex' : 'none';
if (elements.hostControlToggle) elements.hostControlToggle.checked = hostOnly;
if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amHost && hostOnly) ? 'block' : 'none';
if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amController && hostOnly) ? 'block' : 'none';
// A guest in host-only mode can't drive the room → lock the remote controls so
// clicks don't silently get gated (and leave the button stuck).
hcmGuestLocked = (!amHost && hostOnly);
// Only a non-controller is locked out of the remote controls.
hcmGuestLocked = (!amController && hostOnly);
setRemoteControlsLocked(hcmGuestLocked);
}
@@ -676,6 +696,31 @@ function updatePeerList(peers) {
header.appendChild(solo);
}
// Co-Host: role badges + the owner's promote/demote control. Shown when the
// feature is active and either the room is host-only or we're the owner
// (so a guest in a normal room sees no role noise).
const showRoles = !!hcmOwnerPeerId && (hcmRoomIsHostOnly || hcmAmOwner);
if (showRoles) {
const isOwner = pId === hcmOwnerPeerId;
const isController = !isOwner && hcmControllers.includes(pId);
if (isOwner || isController) {
const roleBadge = document.createElement('span');
roleBadge.style.cssText = 'font-size:10px; color:#fff; background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:600;';
roleBadge.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
header.appendChild(roleBadge);
}
// Owner can promote/demote any non-owner peer.
if (hcmAmOwner && hcmCoHostSupported && !isOwner) {
const btn = document.createElement('button');
btn.style.cssText = 'font-size:10px; padding:2px 8px; border-radius:6px; cursor:pointer; border:1px solid var(--accent); background:transparent; color:var(--accent); white-space:nowrap;';
btn.textContent = isController ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
btn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'SET_PEER_ROLE', peerId: pId, controller: !isController }, () => {});
});
header.appendChild(btn);
}
}
peerItem.appendChild(header);
// Media Info
@@ -1743,7 +1788,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONTROL_MODE') {
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
updateHostControlUI(msg.controlMode, msg.amHost, msg.hostControlSupported, inRoom);
updateHostControlUI({ controlMode: msg.controlMode, amHost: msg.amHost, amController: msg.amController, controllers: msg.controllers, hostPeerId: msg.hostPeerId, hostControlSupported: msg.hostControlSupported, coHostSupported: msg.coHostSupported, inRoom });
} else if (msg.type === 'CONNECTION_STATUS') {
if (msg.status === 'connected' || msg.status === 'disconnected') {
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
@@ -1761,7 +1806,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, true);
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: true });
});
}
if (msg.status === 'disconnected') {