updated
This commit is contained in:
60
server.py
60
server.py
@@ -42,6 +42,9 @@ DIAL_CODES = {
|
||||
'*666#': 'schizo_toggle', # toggles schizo mode (subtle UI shake/wiggle/morph)
|
||||
'*9059#': 'pong_toggle', # toggles pong mode (webcam tiles bounce around)
|
||||
'*401#': 'ghost_toggle', # hides the dialer's nick from everyone's user list
|
||||
'*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
|
||||
'*#06#': 'show_codes', # privately reveals all dial codes to the dialer
|
||||
}
|
||||
|
||||
@@ -53,8 +56,13 @@ DIAL_CODE_DESCRIPTIONS = [
|
||||
('*666#', 'Toggle schizo mode (everyone)'),
|
||||
('*9059#', 'Toggle pong mode (everyone)'),
|
||||
('*401#', 'Toggle ghost mode (hide your nick)'),
|
||||
('*000#', 'Reset everything to normal (everyone)'),
|
||||
('*73#', 'Record up to 10s of chat audio (just you)'),
|
||||
('*74#', 'Broadcast your recording to everyone'),
|
||||
('*#06#', 'Show this list (just you)'),
|
||||
]
|
||||
|
||||
MAX_RECORDING_BYTES = 512 * 1024 # ~500KB cap, plenty for 10s of opus at low bitrate
|
||||
DIAL_MAX_LEN = 32
|
||||
|
||||
|
||||
@@ -229,7 +237,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}
|
||||
clients[client_id] = {'ws': ws, 'username': None, 'cam_on': False, 'mic_on': True, 'screen_on': False, 'rainbow_nick': False, 'ghost': False, 'fed': False}
|
||||
|
||||
logging.info(f'[{client_id}] Connected')
|
||||
|
||||
@@ -292,7 +300,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)}
|
||||
{'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)}
|
||||
for cid, c in clients.items()
|
||||
if c['username'] and cid != client_id
|
||||
]
|
||||
@@ -358,7 +366,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)}
|
||||
{'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)}
|
||||
for cid, c in clients.items()
|
||||
if c['username'] and cid != client_id
|
||||
]
|
||||
@@ -442,6 +450,32 @@ async def handle_message(client_id: str, data: dict):
|
||||
# Explicit leave message for immediate cleanup (triggered on tab close)
|
||||
await cleanup(client_id)
|
||||
|
||||
elif msg_type == 'broadcast_recording':
|
||||
# Dialer is uploading their *73# recording so we can fan it out via *74#.
|
||||
# Audio is base64-encoded opus/webm. Cap size so a misbehaving client can't
|
||||
# flood the channel.
|
||||
audio = data.get('audio') or ''
|
||||
mime = data.get('mime') or 'audio/webm'
|
||||
if not isinstance(audio, str) or len(audio) > MAX_RECORDING_BYTES:
|
||||
logging.warning(f'[{client_id}] Recording rejected (size or type)')
|
||||
return
|
||||
logging.info(f'[{client_id}] Broadcasting recording ({len(audio)} bytes)')
|
||||
await broadcast_all({'type': 'play_recording', 'audio': audio, 'mime': mime})
|
||||
|
||||
elif msg_type == 'fed_self_tag':
|
||||
# Prank button: the dialer thinks they're recording, but in reality everyone
|
||||
# else gets a FED tag on their nick. Sticky for the rest of the session.
|
||||
if clients[client_id].get('fed'):
|
||||
return
|
||||
clients[client_id]['fed'] = True
|
||||
logging.info(f'[{client_id}] Tagged as FED')
|
||||
# Broadcast to everyone EXCEPT the dialer - they should never know.
|
||||
await broadcast(client_id, {
|
||||
'type' : 'fed_status',
|
||||
'id' : client_id,
|
||||
'fed' : True
|
||||
})
|
||||
|
||||
elif msg_type == 'dial':
|
||||
# In-app dialpad. Sequences are matched against DIAL_CODES server-side so the
|
||||
# valid codes are never visible to clients. Unknown sequences are silently
|
||||
@@ -465,6 +499,17 @@ async def handle_message(client_id: str, data: dict):
|
||||
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 == 'reset_all':
|
||||
# Wipes every per-user effect and global mode. Server state is reset so
|
||||
# future joiners don't inherit stale flags.
|
||||
trippy_mode = False
|
||||
schizo_mode = False
|
||||
pong_mode = False
|
||||
for c in clients.values():
|
||||
c['rainbow_nick'] = False
|
||||
c['ghost'] = False
|
||||
logging.info(f'[{client_id}] Reset all modes')
|
||||
await broadcast_all({'type': 'reset_all'})
|
||||
elif action == 'ghost_toggle':
|
||||
current = clients[client_id].get('ghost', False)
|
||||
clients[client_id]['ghost'] = not current
|
||||
@@ -481,6 +526,15 @@ async def handle_message(client_id: str, data: dict):
|
||||
'type' : 'dial_codes_list',
|
||||
'codes' : [{'code': c, 'desc': d} for (c, d) in DIAL_CODE_DESCRIPTIONS]
|
||||
})
|
||||
elif action == 'record_open':
|
||||
# Private trigger - only the dialer's UI opens the record popup.
|
||||
logging.info(f'[{client_id}] Record popup open')
|
||||
await clients[client_id]['ws'].send_json({'type': 'record_popup_open'})
|
||||
elif action == 'play_recording':
|
||||
# Ask the dialer's client to upload its last recording. We then broadcast
|
||||
# the audio to everyone via 'broadcast_recording' below.
|
||||
logging.info(f'[{client_id}] Play recording requested')
|
||||
await clients[client_id]['ws'].send_json({'type': 'request_broadcast_recording'})
|
||||
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.
|
||||
|
||||
@@ -141,6 +141,12 @@
|
||||
</div>
|
||||
<div id="users-list"></div>
|
||||
<div class="sidebar-footer">
|
||||
<button id="record-call-btn" class="record-call-btn" title="Record this call">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="6"/>
|
||||
</svg>
|
||||
<span>RECORD CALL</span>
|
||||
</button>
|
||||
<button id="dial-btn" class="dial-btn" title="Dial">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/>
|
||||
@@ -335,6 +341,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording popup (opened via *73#) -->
|
||||
<div id="record-modal" class="modal hidden">
|
||||
<div class="modal-content record-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Record</h3>
|
||||
<button id="record-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>
|
||||
<div class="record-status" id="record-status">Ready</div>
|
||||
<div class="record-progress"><div class="record-progress-fill" id="record-progress-fill"></div></div>
|
||||
<div class="record-actions">
|
||||
<button id="record-start" class="btn-primary record-btn">RECORD</button>
|
||||
<button id="record-stop" class="btn-secondary record-btn hidden">STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fake "recording" indicator shown only to the user who hit RECORD CALL -->
|
||||
<div id="fed-fake-rec" class="fed-fake-rec hidden">
|
||||
<span class="fed-fake-dot"></span>
|
||||
<span class="fed-fake-label">REC</span>
|
||||
<span class="fed-fake-time" id="fed-fake-time">00:00</span>
|
||||
</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">
|
||||
@@ -376,6 +409,7 @@
|
||||
<script src="/static/js/media.js"></script>
|
||||
<script src="/static/js/irc.js"></script>
|
||||
<script src="/static/js/dial.js"></script>
|
||||
<script src="/static/js/recording.js"></script>
|
||||
<script src="/static/js/client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Dial keypad listeners (from dial.js)
|
||||
initDialListeners();
|
||||
initRecordingListeners();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (!state.maximizedPeer) updateVideoGrid();
|
||||
@@ -346,7 +347,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, speaking: false };
|
||||
state.users['local'] = { username: state.username, camOn: state.camEnabled, micOn: state.micEnabled, screenOn: state.screenEnabled, rainbowNick: false, ghost: false, fed: false, speaking: false };
|
||||
}
|
||||
|
||||
data.users.forEach(user => {
|
||||
@@ -357,6 +358,7 @@ function handleSignal(data) {
|
||||
screenOn: user.screen_on || false,
|
||||
rainbowNick: !!user.rainbow_nick,
|
||||
ghost: !!user.ghost,
|
||||
fed: !!user.fed,
|
||||
speaking: false
|
||||
};
|
||||
createPeerConnection(user.id, user.username, true);
|
||||
@@ -506,10 +508,40 @@ function handleSignal(data) {
|
||||
playSound(data.sound);
|
||||
break;
|
||||
|
||||
case 'reset_all':
|
||||
setTrippyMode(false);
|
||||
setSchizoMode(false);
|
||||
setPongMode(false);
|
||||
Object.values(state.users).forEach(u => {
|
||||
if (!u) return;
|
||||
u.rainbowNick = false;
|
||||
u.ghost = false;
|
||||
});
|
||||
updateUsersList();
|
||||
break;
|
||||
|
||||
case 'dial_codes_list':
|
||||
showDialCodes(data.codes || []);
|
||||
break;
|
||||
|
||||
case 'record_popup_open':
|
||||
openRecordModal();
|
||||
break;
|
||||
|
||||
case 'request_broadcast_recording':
|
||||
uploadRecording();
|
||||
break;
|
||||
|
||||
case 'play_recording':
|
||||
playBroadcastRecording(data.audio, data.mime);
|
||||
break;
|
||||
|
||||
case 'fed_status':
|
||||
// We never receive this for our own id (server filters).
|
||||
if (state.users[data.id]) state.users[data.id].fed = !!data.fed;
|
||||
updateUsersList();
|
||||
break;
|
||||
|
||||
case 'ghost_status':
|
||||
if (data.id === state.myId) {
|
||||
if (state.users['local']) state.users['local'].ghost = !!data.ghost;
|
||||
|
||||
242
static/js/recording.js
Normal file
242
static/js/recording.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// HardChats - *73# / *74# recording + RECORD CALL prank
|
||||
// Requires: state, $, send, getAudioCtx (from webrtc.js)
|
||||
|
||||
const RECORDING_MAX_MS = 10000; // 10-second cap
|
||||
|
||||
const recordingMixer = {
|
||||
dest: null, // MediaStreamAudioDestinationNode (the mix bus)
|
||||
connections: new Map() // peerId -> AudioNode tap (for cleanup if ever needed)
|
||||
};
|
||||
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let recordedBlob = null;
|
||||
let recordedMime = 'audio/webm';
|
||||
let recordTimerInterval = null;
|
||||
let recordTimeoutId = null;
|
||||
let recordStartedAt = 0;
|
||||
|
||||
// Lazily build the destination node - we need an AudioContext, which only exists
|
||||
// after the user has connected. Returns null if the AudioContext isn't ready yet.
|
||||
function getRecordingMixerDest() {
|
||||
if (recordingMixer.dest) return recordingMixer.dest;
|
||||
const ctx = (typeof getAudioCtx === 'function') ? getAudioCtx() : null;
|
||||
if (!ctx) return null;
|
||||
recordingMixer.dest = ctx.createMediaStreamDestination();
|
||||
return recordingMixer.dest;
|
||||
}
|
||||
|
||||
// Called by webrtc.js whenever a peer's audioSource node has just been created.
|
||||
// Connects that source into the recording mix so MediaRecorder picks it up.
|
||||
function tapPeerToRecordingMixer(peerId, audioSource) {
|
||||
if (!audioSource) return;
|
||||
const dest = getRecordingMixerDest();
|
||||
if (!dest) return;
|
||||
try {
|
||||
audioSource.connect(dest);
|
||||
recordingMixer.connections.set(peerId, audioSource);
|
||||
} catch (e) {
|
||||
console.warn('[Record] tap failed for', peerId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// teardownPeerAudio in webrtc.js calls audioSource.disconnect() which removes
|
||||
// every connection at once, so we just need to drop our bookkeeping here.
|
||||
function untapPeerFromRecordingMixer(peerId) {
|
||||
recordingMixer.connections.delete(peerId);
|
||||
}
|
||||
|
||||
// ---------- Record popup (*73#) ----------
|
||||
|
||||
function initRecordingListeners() {
|
||||
$('record-close')?.addEventListener('click', closeRecordModal);
|
||||
$('record-start')?.addEventListener('click', startRecording);
|
||||
$('record-stop')?.addEventListener('click', stopRecording);
|
||||
$('record-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'record-modal') closeRecordModal();
|
||||
});
|
||||
|
||||
$('record-call-btn')?.addEventListener('click', triggerFedFakeRecording);
|
||||
}
|
||||
|
||||
function openRecordModal() {
|
||||
$('record-modal')?.classList.remove('hidden');
|
||||
resetRecordUI();
|
||||
}
|
||||
|
||||
function closeRecordModal() {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
try { mediaRecorder.stop(); } catch (e) {}
|
||||
}
|
||||
$('record-modal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function resetRecordUI() {
|
||||
const status = $('record-status');
|
||||
const fill = $('record-progress-fill');
|
||||
const startBtn = $('record-start');
|
||||
const stopBtn = $('record-stop');
|
||||
if (status) status.textContent = recordedBlob ? 'Recording saved. Dial *74# to play.' : 'Ready';
|
||||
if (fill) fill.style.width = '0%';
|
||||
startBtn?.classList.remove('hidden');
|
||||
stopBtn?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function pickRecorderMime() {
|
||||
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];
|
||||
for (const m of candidates) {
|
||||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(m)) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
const dest = getRecordingMixerDest();
|
||||
if (!dest) {
|
||||
setRecordStatus('Audio not ready - join the call first.');
|
||||
return;
|
||||
}
|
||||
if (recordingMixer.connections.size === 0) {
|
||||
setRecordStatus('Nobody else is in the call to record.');
|
||||
return;
|
||||
}
|
||||
|
||||
recordedChunks = [];
|
||||
const mime = pickRecorderMime();
|
||||
try {
|
||||
mediaRecorder = mime
|
||||
? new MediaRecorder(dest.stream, { mimeType: mime })
|
||||
: new MediaRecorder(dest.stream);
|
||||
recordedMime = mediaRecorder.mimeType || mime || 'audio/webm';
|
||||
} catch (e) {
|
||||
console.error('[Record] MediaRecorder failed:', e);
|
||||
setRecordStatus('Recording not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
clearInterval(recordTimerInterval);
|
||||
clearTimeout(recordTimeoutId);
|
||||
recordTimerInterval = null;
|
||||
recordTimeoutId = null;
|
||||
recordedBlob = recordedChunks.length ? new Blob(recordedChunks, { type: recordedMime }) : null;
|
||||
|
||||
const startBtn = $('record-start');
|
||||
const stopBtn = $('record-stop');
|
||||
startBtn?.classList.remove('hidden');
|
||||
stopBtn?.classList.add('hidden');
|
||||
setRecordStatus(recordedBlob ? 'Saved. Dial *74# to play.' : 'No audio captured.');
|
||||
const fill = $('record-progress-fill');
|
||||
if (fill) fill.style.width = '100%';
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
recordStartedAt = performance.now();
|
||||
|
||||
$('record-start')?.classList.add('hidden');
|
||||
$('record-stop')?.classList.remove('hidden');
|
||||
setRecordStatus('Recording...');
|
||||
|
||||
// Drive the progress bar.
|
||||
recordTimerInterval = setInterval(() => {
|
||||
const elapsed = performance.now() - recordStartedAt;
|
||||
const pct = Math.min(100, (elapsed / RECORDING_MAX_MS) * 100);
|
||||
const fill = $('record-progress-fill');
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
}, 50);
|
||||
|
||||
// Auto-stop at 10s.
|
||||
recordTimeoutId = setTimeout(() => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
try { mediaRecorder.stop(); } catch (e) {}
|
||||
}
|
||||
}, RECORDING_MAX_MS);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
try { mediaRecorder.stop(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function setRecordStatus(text) {
|
||||
const el = $('record-status');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
// Server asked us to upload our last recording so it can fan out via *74#.
|
||||
async function uploadRecording() {
|
||||
if (!recordedBlob) return; // nothing to send; *74# is a no-op silently
|
||||
try {
|
||||
const buf = await recordedBlob.arrayBuffer();
|
||||
const b64 = arrayBufferToBase64(buf);
|
||||
send({ type: 'broadcast_recording', audio: b64, mime: recordedMime });
|
||||
} catch (e) {
|
||||
console.error('[Record] upload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function base64ToBlob(b64, mime) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime || 'audio/webm' });
|
||||
}
|
||||
|
||||
// Server broadcast - everyone (including the original recorder) hears it.
|
||||
function playBroadcastRecording(audioB64, mime) {
|
||||
if (!audioB64) return;
|
||||
if (!state.settings.sounds) return; // respect the existing sound-effects gate
|
||||
try {
|
||||
const blob = base64ToBlob(audioB64, mime);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = new Audio(url);
|
||||
a.onended = () => URL.revokeObjectURL(url);
|
||||
a.play().catch(e => console.warn('[Record] playback rejected:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.error('[Record] playback failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- RECORD CALL prank button ----------
|
||||
// Dialer thinks they're recording. They send fed_self_tag once; from then on
|
||||
// every other client sees a FED tag next to their nick. Locally we show a fake
|
||||
// "REC" indicator so they're convinced it's real.
|
||||
|
||||
function triggerFedFakeRecording() {
|
||||
if (state.fedFakeActive) return;
|
||||
state.fedFakeActive = true;
|
||||
send({ type: 'fed_self_tag' });
|
||||
startFedFakeIndicator();
|
||||
const btn = $('record-call-btn');
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
btn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function startFedFakeIndicator() {
|
||||
const el = $('fed-fake-rec');
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
const start = performance.now();
|
||||
setInterval(() => {
|
||||
const elapsed = Math.floor((performance.now() - start) / 1000);
|
||||
const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
||||
const s = String(elapsed % 60).padStart(2, '0');
|
||||
const t = $('fed-fake-time');
|
||||
if (t) t.textContent = `${m}:${s}`;
|
||||
}, 1000);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const state = {
|
||||
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#
|
||||
fedFakeActive: false, // RECORD CALL prank button was pressed (locally only)
|
||||
// Reconnection state
|
||||
reconnectToken: null,
|
||||
wsReconnectAttempts: 0,
|
||||
|
||||
@@ -275,6 +275,7 @@ function updateUsersList() {
|
||||
<div class="user-info">
|
||||
${user.isLocal ? '' : getNetworkQualityHTML(user.id)}
|
||||
<span class="user-name${user.rainbowNick ? ' rainbow-nick' : ''}">${escapeHtml(user.username)}</span>
|
||||
${user.fed ? '<span class="fed-tag" title="Recording the call">FED</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>' : ''}
|
||||
${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>' : ''}
|
||||
|
||||
@@ -637,6 +637,11 @@ function setupPeerAudio(peerId, stream) {
|
||||
peer.audioSource = ctx.createMediaStreamSource(stream);
|
||||
peer.audioSource.connect(peer.analyser);
|
||||
|
||||
// Tap into the recording mix bus (*73#). Same source feeds analyser AND mixer.
|
||||
if (typeof tapPeerToRecordingMixer === 'function') {
|
||||
tapPeerToRecordingMixer(peerId, peer.audioSource);
|
||||
}
|
||||
|
||||
// Per-peer hidden <audio> element. Created once, reused across renegotiations.
|
||||
if (!peer.audioElement) {
|
||||
const audioEl = document.createElement('audio');
|
||||
@@ -698,6 +703,9 @@ function teardownPeerAudio(peerId) {
|
||||
try { peer.audioSource.disconnect(); } catch (e) {}
|
||||
peer.audioSource = null;
|
||||
}
|
||||
if (typeof untapPeerFromRecordingMixer === 'function') {
|
||||
untapPeerFromRecordingMixer(peerId);
|
||||
}
|
||||
if (peer.analyser) {
|
||||
try { peer.analyser.disconnect(); } catch (e) {}
|
||||
peer.analyser = null;
|
||||
|
||||
137
static/style.css
137
static/style.css
@@ -2177,3 +2177,140 @@ body.pong-mode.schizo-mode .video-tile video {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ========== RECORD CALL prank button ========== */
|
||||
|
||||
.record-call-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.record-call-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.record-call-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.record-call-btn.active {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.record-call-btn.active svg {
|
||||
animation: rec-blink 1s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes rec-blink {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0.25; }
|
||||
}
|
||||
|
||||
/* ========== Record modal (*73#) ========== */
|
||||
|
||||
.modal-content.record-modal {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.record-progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #ef4444;
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ========== FED tag (visible to everyone EXCEPT the user themself) ========== */
|
||||
|
||||
.fed-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 3px;
|
||||
color: #ef4444;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
vertical-align: middle;
|
||||
animation: rec-blink 1.4s steps(2) infinite;
|
||||
}
|
||||
|
||||
/* ========== Fake recording overlay (only the dialer sees this) ========== */
|
||||
|
||||
.fed-fake-rec {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(20, 0, 0, 0.85);
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 6px;
|
||||
color: #ef4444;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fed-fake-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: rec-blink 1s steps(2) infinite;
|
||||
}
|
||||
|
||||
.fed-fake-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user