This commit is contained in:
2026-05-03 16:42:21 -04:00
parent a2a7bad70c
commit ae689d6c6c
6 changed files with 170 additions and 8 deletions

View File

@@ -45,6 +45,7 @@ DIAL_CODES = {
'*000#': 'reset_all', # turns off every mode/effect for everyone
'*73#': 'record_open', # opens the dialer's record popup (10s max)
'*74#': 'play_recording', # broadcasts the dialer's last recording to everyone
'*87#': 'breakout_toggle', # toggles dialer in/out of the private breakout room
'*#06#': 'show_codes', # privately reveals all dial codes to the dialer
}
@@ -59,6 +60,7 @@ DIAL_CODE_DESCRIPTIONS = [
('*000#', 'Reset everything to normal (everyone)'),
('*73#', 'Record up to 10s of the chat (everyone you hear)'),
('*74#', 'Play your recording for everyone in the chat'),
('*87#', 'Join/leave the breakout room (private side convo)'),
('*#06#', 'Show this list (just you)'),
]
@@ -237,7 +239,7 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
await ws.prepare(request)
client_id = str(uuid.uuid4())[:8]
clients[client_id] = {'ws': ws, 'username': None, 'cam_on': False, 'mic_on': True, 'screen_on': False, 'rainbow_nick': False, 'ghost': False, 'fed': False}
clients[client_id] = {'ws': ws, 'username': None, 'cam_on': False, 'mic_on': True, 'screen_on': False, 'rainbow_nick': False, 'ghost': False, 'fed': False, 'breakout': False}
logging.info(f'[{client_id}] Connected')
@@ -300,7 +302,7 @@ async def handle_message(client_id: str, data: dict):
reconnect_tokens[reconnect_token] = {'username': username, 'expires': time.time() + 3600}
users = [
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False)}
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False)}
for cid, c in clients.items()
if c['username'] and cid != client_id
]
@@ -366,7 +368,7 @@ async def handle_message(client_id: str, data: dict):
reconnect_tokens[new_token] = {'username': username, 'expires': time.time() + 3600}
users = [
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False)}
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False)}
for cid, c in clients.items()
if c['username'] and cid != client_id
]
@@ -508,8 +510,21 @@ async def handle_message(client_id: str, data: dict):
for c in clients.values():
c['rainbow_nick'] = False
c['ghost'] = False
c['breakout'] = False
logging.info(f'[{client_id}] Reset all modes')
await broadcast_all({'type': 'reset_all'})
elif action == 'breakout_toggle':
# Per-user toggle. Audio gating is handled client-side: each client mutes
# the sender track + receiver audio for any peer whose breakout flag
# doesn't match their own. Server just tracks state and fans out.
current = clients[client_id].get('breakout', False)
clients[client_id]['breakout'] = not current
logging.info(f'[{client_id}] Breakout -> {not current}')
await broadcast_all({
'type' : 'breakout_status',
'id' : client_id,
'breakout' : not current
})
elif action == 'ghost_toggle':
current = clients[client_id].get('ghost', False)
clients[client_id]['ghost'] = not current

View File

@@ -362,7 +362,7 @@ function handleSignal(data) {
// Preserve local state on reconnect, or initialize
if (!state.users['local']) {
state.users['local'] = { username: state.username, camOn: state.camEnabled, micOn: state.micEnabled, screenOn: state.screenEnabled, rainbowNick: false, ghost: false, fed: false, speaking: false };
state.users['local'] = { username: state.username, camOn: state.camEnabled, micOn: state.micEnabled, screenOn: state.screenEnabled, rainbowNick: false, ghost: false, fed: false, breakout: false, speaking: false };
}
data.users.forEach(user => {
@@ -374,6 +374,7 @@ function handleSignal(data) {
rainbowNick: !!user.rainbow_nick,
ghost: !!user.ghost,
fed: !!user.fed,
breakout: !!user.breakout,
speaking: false
};
createPeerConnection(user.id, user.username, true);
@@ -410,6 +411,7 @@ function handleSignal(data) {
camOn: data.cam_on || false,
micOn: data.mic_on !== false,
screenOn: data.screen_on || false,
breakout: false,
speaking: false
};
console.log('[Signal] user_joined:', data.id, 'micOn:', data.mic_on, 'state:', state.users[data.id]);
@@ -531,7 +533,9 @@ function handleSignal(data) {
if (!u) return;
u.rainbowNick = false;
u.ghost = false;
u.breakout = false;
});
applyBreakoutGatingAll();
updateUsersList();
break;
@@ -566,6 +570,10 @@ function handleSignal(data) {
updateUsersList();
break;
case 'breakout_status':
handleBreakoutStatus(data.id, !!data.breakout);
break;
case 'nick_status':
// Per-user rainbow nick toggle. Server tells us when ANY user (including
// us) flips theirs - we just mirror it into local state and re-render.
@@ -580,6 +588,70 @@ function handleSignal(data) {
}
}
// ========== BREAKOUT ROOM (*87#) ==========
//
// Per-user flag gates audio in both directions: if your breakout matches a peer's,
// you can hear each other. If not, both sides mute the relevant sender track and
// <audio> element. No video/screenshare while in breakout - we force-disable yours
// when you enter. Anyone in main lobby sees breakout users as muted (their audio
// gating already produces silence on this end). Not a security boundary - a
// determined client could re-enable everything locally.
function handleBreakoutStatus(userId, inBreakout) {
const isMe = userId === state.myId;
if (isMe) {
if (state.users['local']) state.users['local'].breakout = inBreakout;
// Entering breakout: kill your own video/screen feeds since breakout is
// voice-only. Toggling out doesn't auto-restore anything - they re-enable
// with the regular cam/screen buttons.
if (inBreakout) {
if (state.camEnabled && typeof toggleCam === 'function') toggleCam();
if (state.screenEnabled && typeof toggleScreen === 'function') toggleScreen();
}
} else if (state.users[userId]) {
state.users[userId].breakout = inBreakout;
}
applyBreakoutGatingAll();
updateUI();
}
// Recomputes per-peer audio gating against the local breakout flag.
function applyBreakoutGatingAll() {
Object.keys(state.peers).forEach(applyBreakoutGatingForPeer);
}
function applyBreakoutGatingForPeer(peerId) {
const peer = state.peers[peerId];
if (!peer) return;
const myBreakout = !!state.users['local']?.breakout;
const theirBreakout = !!state.users[peerId]?.breakout;
const canHear = myBreakout === theirBreakout;
// Outgoing: when our breakout state doesn't match this peer, swap the audio
// sender's track for null so they receive silence. track.enabled would mute the
// mic for every peer because the underlying MediaStreamTrack is shared. This
// way the regular mic on/off (toggleMic) still works for matching peers.
const localAudioTrack = state.localStream?.getAudioTracks()[0] || null;
const targetTrack = canHear ? localAudioTrack : null;
if (peer.audioSender) {
// replaceTrack is transparent (no renegotiation needed). Avoid redundant calls.
if (peer.audioSender.track !== targetTrack) {
peer.audioSender.replaceTrack(targetTrack).catch(e => {
console.warn(`[Breakout] replaceTrack failed for ${peerId}:`, e?.message || e);
});
}
}
// Incoming: mute their <audio> element on our side.
if (peer.audioElement) {
peer.audioElement.muted = !canHear || !state.volumeEnabled;
}
peer.breakoutMuted = !canHear;
}
// ========== TIMER ==========
function startTimer() {

View File

@@ -17,6 +17,12 @@ function toggleMic() {
state.users['local'].micOn = state.micEnabled;
console.log('[Mic] Sending mic_status:', state.micEnabled, 'WebSocket state:', state.ws?.readyState);
send({ type: 'mic_status', enabled: state.micEnabled });
// Re-apply breakout gating - the per-sender track flips above might not match
// breakout state (we may have just enabled the local audio track for peers we
// shouldn't be transmitting to).
if (typeof applyBreakoutGatingAll === 'function') applyBreakoutGatingAll();
updateUI();
}
@@ -27,6 +33,12 @@ async function toggleCam() {
return;
}
// Breakout room is voice-only - block enabling cam.
if (!state.camEnabled && state.users['local']?.breakout) {
console.log('[Camera] Disabled in breakout room');
return;
}
if (!state.camEnabled) {
try {
const videoStream = await navigator.mediaDevices.getUserMedia({
@@ -93,6 +105,12 @@ async function toggleCam() {
}
async function toggleScreen() {
// Breakout room is voice-only - block enabling screen share.
if (!state.screenEnabled && state.users['local']?.breakout) {
console.log('[Screen] Disabled in breakout room');
return;
}
if (!state.screenEnabled) {
try {
// Request screen capture
@@ -178,6 +196,9 @@ function toggleVolume() {
if (peer.audioElement) peer.audioElement.muted = !state.volumeEnabled;
});
// Don't undo breakout-room muting when global unmute is hit.
if (typeof applyBreakoutGatingAll === 'function') applyBreakoutGatingAll();
$('volume-btn').classList.toggle('active', state.volumeEnabled);
$('volume-btn').classList.toggle('muted', !state.volumeEnabled);

View File

@@ -55,8 +55,13 @@ function updateVideoGrid() {
});
}
// Hide video tiles for users in a different breakout state - they're voice-only
// and isolated from us, no reason to show their cam/screen.
const myBreakout = !!state.users['local']?.breakout;
// Add remote users' cameras and screens
Object.entries(state.peers).forEach(([id, peer]) => {
if (!!state.users[id]?.breakout !== myBreakout) return;
if (state.users[id]?.camOn && peer.stream && !peer.videoOff) {
// Check if stream has video tracks - could be camera or screen
const videoTracks = peer.stream.getVideoTracks();
@@ -252,11 +257,18 @@ function updateUsersList() {
count.textContent = allUsers.length;
const myBreakout = !!state.users['local']?.breakout;
list.innerHTML = allUsers.map(user => {
const peer = state.peers[user.id];
const volume = peer?.volume ?? 100;
const isMuted = volume === 0;
const isVideoOff = peer?.videoOff;
// Breakout-isolated: this user is in a different room than us. They show
// greyed out, and their mic indicator looks muted to us regardless of their
// real mic state (we can't hear them anyway).
const breakoutIsolated = !user.isLocal && (!!user.breakout !== myBreakout);
const inBreakout = !!user.breakout;
// Volume icon changes based on level
let volumeIcon;
@@ -270,17 +282,22 @@ function updateUsersList() {
volumeIcon = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>';
}
// Mic appears muted to us if either they actually muted, OR we can't hear them
// because they're in a different breakout room from ours.
const micLooksMuted = user.micOn === false || breakoutIsolated;
return `
<div class="user-item ${user.speaking ? 'speaking' : ''} ${user.isLocal ? 'local' : ''}" data-id="${user.id}">
<div class="user-item ${user.speaking && !breakoutIsolated ? 'speaking' : ''} ${user.isLocal ? 'local' : ''} ${breakoutIsolated ? 'breakout-isolated' : ''} ${inBreakout ? 'in-breakout' : ''}" data-id="${user.id}">
<div class="user-info">
${user.isLocal ? '' : getNetworkQualityHTML(user.id)}
<span class="user-name${user.rainbowNick ? ' rainbow-nick' : ''}">${escapeHtml(user.username)}</span>
${user.fed && !state.fedFakeActive ? '<span class="fed-tag" title="Recording the call">FED</span>' : ''}
${inBreakout ? '<span class="breakout-tag" title="In breakout room">BR</span>' : ''}
<div class="user-indicators">
${user.micOn === false ? '<svg class="indicator mic-muted" viewBox="0 0 24 24" fill="currentColor" title="Muted"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>' : ''}
${micLooksMuted ? '<svg class="indicator mic-muted" viewBox="0 0 24 24" fill="currentColor" title="Muted"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>' : ''}
${user.camOn ? '<svg class="indicator cam-on" viewBox="0 0 24 24" fill="currentColor" title="Camera On"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>' : ''}
${user.screenOn ? '<svg class="indicator screen-on" viewBox="0 0 24 24" fill="currentColor" title="Sharing Screen"><path d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg>' : ''}
${user.speaking && user.micOn !== false ? '<svg class="indicator mic-active" viewBox="0 0 24 24" fill="currentColor" title="Speaking"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>' : ''}
${user.speaking && user.micOn !== false && !breakoutIsolated ? '<svg class="indicator mic-active" viewBox="0 0 24 24" fill="currentColor" title="Speaking"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>' : ''}
</div>
</div>
${user.isLocal ? `

View File

@@ -262,7 +262,13 @@ async function createPeerConnection(peerId, username, initiator) {
ignoreOffer: false
};
state.localStream.getTracks().forEach(track => pc.addTrack(track, state.localStream));
state.localStream.getTracks().forEach(track => {
const sender = pc.addTrack(track, state.localStream);
// Stash the audio sender so breakout gating can call replaceTrack(null) on
// just this peer when we shouldn't be transmitting to them. track.enabled
// would mute for everyone (shared track ref).
if (track.kind === 'audio') state.peers[peerId].audioSender = sender;
});
// If screen is currently being shared, add the screen track for this new peer
if (state.screenEnabled && state.screenStream) {
@@ -666,6 +672,12 @@ function setupPeerAudio(peerId, stream) {
peer.audioElement.volume = Math.min(1.0, (peer.volume ?? 100) / 100);
peer.audioElement.muted = !state.volumeEnabled;
// Reapply breakout-room gating - if this peer is in a different room than us,
// their audio element gets muted on top of the regular volume rules.
if (typeof applyBreakoutGatingForPeer === 'function') {
applyBreakoutGatingForPeer(peerId);
}
// Speaking-indicator loop. Started once, kept alive until teardownPeerAudio.
if (!peer.speakingLoopActive) {
peer.speakingLoopActive = true;

View File

@@ -2352,3 +2352,28 @@ body.pong-mode.schizo-mode .video-tile video {
font-variant-numeric: tabular-nums;
color: #fff;
}
/* ========== Breakout room (*87#) ========== */
/* Users you can't hear (different breakout room than you) - name + indicators dimmed. */
.user-item.breakout-isolated .user-name,
.user-item.breakout-isolated .user-indicators {
opacity: 0.45;
filter: grayscale(0.6);
}
/* Small "BR" pill on anyone currently in the breakout room. Visible to everyone. */
.breakout-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
background: rgba(56, 189, 248, 0.15);
border: 1px solid #38bdf8;
border-radius: 3px;
color: #38bdf8;
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
vertical-align: middle;
}