This commit is contained in:
2026-05-02 22:18:46 -04:00
parent 2fba7b52e6
commit 464d0b10a1
7 changed files with 353 additions and 4 deletions

View File

@@ -28,6 +28,8 @@ captchas = {} # captcha_id -> {answer, expires}
reconnect_tokens = {} # token -> {username, expires}
session_start = None
trippy_mode = False
schizo_mode = False
pong_mode = False
ALLOWED_CHARS = string.ascii_letters + string.digits + '_-'
@@ -37,7 +39,20 @@ DIAL_CODES = {
'*420#': 'trippy_toggle', # toggles UI trippy mode globally for everyone
'*1337#': 'rainbow_nick_toggle', # toggles the dialer's own rainbow nick
'*101#': 'knock', # plays a knock sound for everyone in the room
'*666#': 'schizo_toggle', # toggles schizo mode (subtle UI shake/wiggle/morph)
'*9059#': 'pong_toggle', # toggles pong mode (webcam tiles bounce around)
'*#06#': 'show_codes', # privately reveals all dial codes to the dialer
}
# Human-readable descriptions, sent privately to the dialer who hits *#06#.
DIAL_CODE_DESCRIPTIONS = [
('*420#', 'Toggle trippy color mode (everyone)'),
('*1337#', 'Toggle rainbow nickname (just you)'),
('*101#', 'Play a knock sound (everyone)'),
('*666#', 'Toggle schizo mode (everyone)'),
('*9059#', 'Toggle pong mode (everyone)'),
('*#06#', 'Show this list (just you)'),
]
DIAL_MAX_LEN = 32
@@ -239,7 +254,7 @@ async def handle_message(client_id: str, data: dict):
:param data: The data from the client
'''
global session_start, trippy_mode
global session_start, trippy_mode, schizo_mode, pong_mode
msg_type = data.get('type')
if msg_type == 'join':
@@ -287,7 +302,9 @@ async def handle_message(client_id: str, data: dict):
'session_start' : session_start,
'max_cameras' : config.MAX_CAMERAS,
'reconnect_token' : reconnect_token,
'trippy_mode' : trippy_mode
'trippy_mode' : trippy_mode,
'schizo_mode' : schizo_mode,
'pong_mode' : pong_mode
})
await broadcast(client_id, {
@@ -351,7 +368,9 @@ async def handle_message(client_id: str, data: dict):
'session_start' : session_start,
'max_cameras' : config.MAX_CAMERAS,
'reconnect_token' : new_token,
'trippy_mode' : trippy_mode
'trippy_mode' : trippy_mode,
'schizo_mode' : schizo_mode,
'pong_mode' : pong_mode
})
await broadcast(client_id, {
@@ -436,6 +455,21 @@ async def handle_message(client_id: str, data: dict):
elif action == 'knock':
logging.info(f'[{client_id}] Knock')
await broadcast_all({'type': 'play_sound', 'sound': 'knock'})
elif action == 'schizo_toggle':
schizo_mode = not schizo_mode
logging.info(f'[{client_id}] Schizo mode -> {schizo_mode}')
await broadcast_all({'type': 'schizo_status', 'enabled': schizo_mode})
elif action == 'pong_toggle':
pong_mode = not pong_mode
logging.info(f'[{client_id}] Pong mode -> {pong_mode}')
await broadcast_all({'type': 'pong_status', 'enabled': pong_mode})
elif action == 'show_codes':
# Private reply to just the dialer - other clients never see the codes.
logging.info(f'[{client_id}] Dial code list requested')
await clients[client_id]['ws'].send_json({
'type' : 'dial_codes_list',
'codes' : [{'code': c, 'desc': d} for (c, d) in DIAL_CODE_DESCRIPTIONS]
})
elif action == 'rainbow_nick_toggle':
# Per-user toggle: only flips the dialer's own nick. Broadcast so every
# other client renders the rainbow effect on this user in their list.

View File

@@ -335,6 +335,21 @@
</div>
</div>
<!-- Dial codes list modal (shown only to the dialer who hits *#06#) -->
<div id="dial-codes-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Dial Codes</h3>
<button id="dial-codes-close" class="modal-close-btn" title="Close">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<ul id="dial-codes-list" class="dial-codes-list"></ul>
</div>
</div>
<!-- Hidden container holding one <audio> element per remote peer. Audio playback flows
through these elements (not AudioContext.destination) so mobile autoplay works
reliably after the user clicks Connect. Kept off-screen, not display:none, so

View File

@@ -330,8 +330,10 @@ function handleSignal(data) {
state.maxCameras = data.max_cameras;
state.reconnectToken = data.reconnect_token || null;
// Adopt server's current trippy state (set by another user before we joined).
// Adopt server's current dial-code modes (set by other users before we joined).
setTrippyMode(!!data.trippy_mode);
setSchizoMode(!!data.schizo_mode);
setPongMode(!!data.pong_mode);
// Save username on successful login
saveUsername(state.username);
@@ -491,10 +493,22 @@ function handleSignal(data) {
setTrippyMode(!!data.enabled);
break;
case 'schizo_status':
setSchizoMode(!!data.enabled);
break;
case 'pong_status':
setPongMode(!!data.enabled);
break;
case 'play_sound':
playSound(data.sound);
break;
case 'dial_codes_list':
showDialCodes(data.codes || []);
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.

View File

@@ -15,6 +15,11 @@ function initDialListeners() {
$('dial-clear')?.addEventListener('click', clearDial);
$('dial-submit')?.addEventListener('click', submitDial);
$('dial-codes-close')?.addEventListener('click', closeDialCodes);
$('dial-codes-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'dial-codes-modal') closeDialCodes();
});
document.querySelectorAll('.dial-key').forEach(btn => {
btn.addEventListener('click', () => pressDialKey(btn.dataset.key));
});
@@ -61,9 +66,164 @@ function submitDial() {
closeDial();
}
// Server reply to *#06# - private to the dialer. Renders the codes into the modal.
function showDialCodes(codes) {
const list = $('dial-codes-list');
if (!list) return;
list.innerHTML = '';
codes.forEach(({ code, desc }) => {
const li = document.createElement('li');
const codeEl = document.createElement('code');
codeEl.textContent = code;
const descEl = document.createElement('span');
descEl.textContent = desc;
li.appendChild(codeEl);
li.appendChild(descEl);
list.appendChild(li);
});
$('dial-codes-modal')?.classList.remove('hidden');
}
function closeDialCodes() {
$('dial-codes-modal')?.classList.add('hidden');
}
// Apply or remove the trippy-mode class on <body>. Server tells us when to flip via
// 'trippy_status' broadcasts; new joiners get the current value in the 'users' message.
function setTrippyMode(enabled) {
state.trippyMode = !!enabled;
document.body.classList.toggle('trippy-mode', state.trippyMode);
}
function setSchizoMode(enabled) {
state.schizoMode = !!enabled;
document.body.classList.toggle('schizo-mode', state.schizoMode);
}
function setPongMode(enabled) {
state.pongMode = !!enabled;
document.body.classList.toggle('pong-mode', state.pongMode);
if (state.pongMode) startPong();
else stopPong();
}
// ---------- Pong mode: webcam tiles bounce around ----------
// Tracks each .video-tile's position+velocity and runs an rAF loop.
// Tiles get position:absolute via CSS; we update transform each frame.
const pong = {
raf: null,
state: new Map(), // tileId -> { x, y, vx, vy, w, h }
container: null
};
function startPong() {
pong.container = document.getElementById('video-grid');
if (!pong.container) return;
pongRefreshTiles();
if (!pong.raf) pong.raf = requestAnimationFrame(pongTick);
}
function stopPong() {
if (pong.raf) cancelAnimationFrame(pong.raf);
pong.raf = null;
document.querySelectorAll('#video-grid .video-tile').forEach(t => {
t.style.transform = '';
t.style.left = '';
t.style.top = '';
});
pong.state.clear();
pong.container = null;
}
// Re-sync the tile map with what's actually in the DOM. Called whenever
// updateVideoGrid rebuilds tiles while pong mode is active.
function pongRefreshTiles() {
if (!pong.container) return;
const tiles = pong.container.querySelectorAll('.video-tile');
const seen = new Set();
const cw = pong.container.clientWidth;
const ch = pong.container.clientHeight;
tiles.forEach(t => {
seen.add(t.id);
if (!pong.state.has(t.id)) {
const w = t.offsetWidth || 240;
const h = t.offsetHeight || 180;
const angle = Math.random() * Math.PI * 2;
const speed = 60 + Math.random() * 40; // px/sec — slow glide
pong.state.set(t.id, {
x: Math.random() * Math.max(1, cw - w),
y: Math.random() * Math.max(1, ch - h),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
w, h
});
} else {
const s = pong.state.get(t.id);
s.w = t.offsetWidth || s.w;
s.h = t.offsetHeight || s.h;
}
});
for (const id of pong.state.keys()) {
if (!seen.has(id)) pong.state.delete(id);
}
}
let pongLastT = 0;
function pongTick(now) {
if (!state.pongMode || !pong.container) {
pong.raf = null;
return;
}
const dt = pongLastT ? Math.min(0.05, (now - pongLastT) / 1000) : 0.016;
pongLastT = now;
const cw = pong.container.clientWidth;
const ch = pong.container.clientHeight;
const items = [];
pong.state.forEach((s, id) => {
s.x += s.vx * dt;
s.y += s.vy * dt;
if (s.x <= 0) { s.x = 0; s.vx = Math.abs(s.vx); }
if (s.x + s.w >= cw) { s.x = cw-s.w; s.vx = -Math.abs(s.vx); }
if (s.y <= 0) { s.y = 0; s.vy = Math.abs(s.vy); }
if (s.y + s.h >= ch) { s.y = ch-s.h; s.vy = -Math.abs(s.vy); }
items.push({ id, s });
});
// Tile-vs-tile collisions (cheap O(n^2), fine for ~10 tiles).
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const a = items[i].s, b = items[j].s;
if (a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y) {
const overlapX = Math.min(a.x + a.w - b.x, b.x + b.w - a.x);
const overlapY = Math.min(a.y + a.h - b.y, b.y + b.h - a.y);
if (overlapX < overlapY) {
const push = overlapX / 2;
if (a.x < b.x) { a.x -= push; b.x += push; }
else { a.x += push; b.x -= push; }
const tmp = a.vx; a.vx = b.vx; b.vx = tmp;
} else {
const push = overlapY / 2;
if (a.y < b.y) { a.y -= push; b.y += push; }
else { a.y += push; b.y -= push; }
const tmp = a.vy; a.vy = b.vy; b.vy = tmp;
}
}
}
}
items.forEach(({ id, s }) => {
const el = document.getElementById(id);
if (el) el.style.transform = `translate(${s.x}px, ${s.y}px)`;
});
pong.raf = requestAnimationFrame(pongTick);
}

View File

@@ -35,6 +35,8 @@ const state = {
configLoaded: false,
defconMode: false, // Auto-mute and hide video for new users
trippyMode: false, // UI hue-shift animation - toggled via server-side dial codes
schizoMode: false, // Subtle UI shake/wiggle - toggled via *666#
pongMode: false, // Webcam tiles bounce around - toggled via *9059#
// Reconnection state
reconnectToken: null,
wsReconnectAttempts: 0,

View File

@@ -143,6 +143,11 @@ function updateVideoGrid() {
attachStreamToTile(user.id, user.stream, user.isLocal);
});
}
// Pong mode tracks tiles by id; grid was just rebuilt so re-sync.
if (state.pongMode && typeof pongRefreshTiles === 'function') {
pongRefreshTiles();
}
}
function createVideoTile(user, isMaximized) {

View File

@@ -1954,3 +1954,122 @@ body.trippy-mode .video-tile video {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* ========== SCHIZO MODE (*666#) ==========
Subtle, slow UI shake + wiggle + skew. Cameras drift independently. */
@keyframes schizo-body {
0% { transform: translate(0, 0) rotate(0deg) skew(0deg, 0deg); }
20% { transform: translate(-2px, 1px) rotate(-0.15deg) skew(0.3deg, -0.2deg); }
40% { transform: translate(1px, -2px) rotate(0.2deg) skew(-0.3deg, 0.2deg); }
60% { transform: translate(2px, 2px) rotate(-0.1deg) skew(0.2deg, 0.3deg); }
80% { transform: translate(-1px, -1px) rotate(0.15deg) skew(-0.4deg, -0.1deg); }
100% { transform: translate(0, 0) rotate(0deg) skew(0deg, 0deg); }
}
@keyframes schizo-tile-a {
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
25% { transform: translate(-3px, 2px) rotate(-0.4deg) scale(1.005); }
50% { transform: translate(2px, -3px) rotate(0.5deg) scale(0.995); }
75% { transform: translate(3px, 3px) rotate(-0.3deg) scale(1.008); }
}
@keyframes schizo-tile-b {
0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); }
20% { transform: translate(2px, -2px) rotate(0.5deg) scale(1.006); }
55% { transform: translate(-3px, 2px) rotate(-0.4deg) scale(0.994); }
80% { transform: translate(-2px, -3px) rotate(0.3deg) scale(1.004); }
}
@keyframes schizo-video {
0%, 100% { transform: scale(1.04) translate(0, 0); filter: hue-rotate(0deg); }
33% { transform: scale(1.06) translate(-4px, 3px); filter: hue-rotate(8deg); }
66% { transform: scale(1.05) translate(3px, -4px); filter: hue-rotate(-6deg); }
}
body.schizo-mode #app {
animation: schizo-body 9s ease-in-out infinite;
}
body.schizo-mode .video-tile {
animation: schizo-tile-a 9s ease-in-out infinite;
}
body.schizo-mode .video-tile:nth-child(2n) {
animation: schizo-tile-b 8s ease-in-out infinite;
animation-delay: -2s;
}
body.schizo-mode .video-tile:nth-child(3n) {
animation: schizo-tile-a 10s ease-in-out infinite;
animation-delay: -4s;
}
body.schizo-mode .video-tile video {
animation: schizo-video 12s ease-in-out infinite;
}
body.schizo-mode .video-tile:nth-child(2n) video {
animation-delay: -3s;
animation-duration: 14s;
}
/* ========== PONG MODE (*9059#) ==========
Tiles get position:absolute and JS animates their transform. */
body.pong-mode #video-grid {
display: block;
position: relative;
overflow: hidden;
}
body.pong-mode .video-tile {
position: absolute;
left: 0;
top: 0;
width: 240px;
height: 180px;
will-change: transform;
transition: none;
}
/* Pong + schizo together: don't fight over transform. Pong wins. */
body.pong-mode.schizo-mode .video-tile,
body.pong-mode.schizo-mode .video-tile video {
animation: none;
}
/* ========== DIAL CODES LIST (*#06#) ========== */
.dial-codes-list {
list-style: none;
margin: 0;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
overflow-y: auto;
}
.dial-codes-list li {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
}
.dial-codes-list code {
font-family: 'JetBrains Mono', monospace;
color: var(--acid);
min-width: 5.5rem;
font-weight: 600;
}
.dial-codes-list span {
color: var(--text-primary);
font-size: 0.85rem;
}