This commit is contained in:
2026-05-03 03:15:32 -04:00
parent 3712aae3fe
commit 496748d961
8 changed files with 513 additions and 4 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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
View 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);
}

View File

@@ -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,

View File

@@ -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>' : ''}

View File

@@ -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;

View File

@@ -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;
}