updated
This commit is contained in:
21
server.py
21
server.py
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ? `
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user