fixing audio issues

This commit is contained in:
2026-04-30 18:33:22 -04:00
parent 259a1ea831
commit c783f2aa02
7 changed files with 350 additions and 11 deletions

View File

@@ -27,9 +27,18 @@ clients = {} # client_id -> {ws, username, cam_on, mic_on, screen_on}
captchas = {} # captcha_id -> {answer, expires}
reconnect_tokens = {} # token -> {username, expires}
session_start = None
trippy_mode = False
ALLOWED_CHARS = string.ascii_letters + string.digits + '_-'
# Hidden dial codes - kept server-side so the client JS bundle never reveals them.
# Sequence the user types on the in-app dialpad maps to a server action.
DIAL_CODES = {
'*4201#': 'trippy_on',
'*4202#': 'trippy_off',
}
DIAL_MAX_LEN = 32
def generate_captcha():
'''Generate a random captcha question and answer'''
@@ -276,7 +285,8 @@ async def handle_message(client_id: str, data: dict):
'you' : client_id,
'session_start' : session_start,
'max_cameras' : config.MAX_CAMERAS,
'reconnect_token' : reconnect_token
'reconnect_token' : reconnect_token,
'trippy_mode' : trippy_mode
})
await broadcast(client_id, {
@@ -339,7 +349,8 @@ async def handle_message(client_id: str, data: dict):
'you' : client_id,
'session_start' : session_start,
'max_cameras' : config.MAX_CAMERAS,
'reconnect_token' : new_token
'reconnect_token' : new_token,
'trippy_mode' : trippy_mode
})
await broadcast(client_id, {
@@ -409,6 +420,24 @@ 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 == '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
# ignored - we deliberately don't tell the user whether anything happened.
global trippy_mode
sequence = (data.get('sequence') or '').strip()
if len(sequence) > DIAL_MAX_LEN:
return
action = DIAL_CODES.get(sequence)
if action == 'trippy_on' and not trippy_mode:
trippy_mode = True
logging.info(f'[{client_id}] Trippy mode ENABLED')
await broadcast_all({'type': 'trippy_status', 'enabled': True})
elif action == 'trippy_off' and trippy_mode:
trippy_mode = False
logging.info(f'[{client_id}] Trippy mode DISABLED')
await broadcast_all({'type': 'trippy_status', 'enabled': False})
async def broadcast(sender_id: str, message: dict):
'''

View File

@@ -147,6 +147,12 @@
</div>
<div id="users-list"></div>
<div class="sidebar-footer">
<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"/>
</svg>
<span>DIAL</span>
</button>
<button id="defcon-btn" class="defcon-btn" title="DEFCON Mode: Auto-mute and hide video for new users">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
@@ -302,6 +308,39 @@
</div>
</div>
<!-- Dial Modal -->
<div id="dial-modal" class="modal hidden">
<div class="modal-content dial-modal">
<div class="modal-header">
<h3>Dial</h3>
<button id="dial-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 id="dial-display" class="dial-display">&nbsp;</div>
<div class="dial-keypad">
<button class="dial-key" data-key="1">1</button>
<button class="dial-key" data-key="2">2</button>
<button class="dial-key" data-key="3">3</button>
<button class="dial-key" data-key="4">4</button>
<button class="dial-key" data-key="5">5</button>
<button class="dial-key" data-key="6">6</button>
<button class="dial-key" data-key="7">7</button>
<button class="dial-key" data-key="8">8</button>
<button class="dial-key" data-key="9">9</button>
<button class="dial-key" data-key="*">&#42;</button>
<button class="dial-key" data-key="0">0</button>
<button class="dial-key" data-key="#">#</button>
</div>
<div class="dial-actions">
<button id="dial-clear" class="btn-secondary">Clear</button>
<button id="dial-submit" class="btn-primary">Dial</button>
</div>
</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
@@ -327,6 +366,7 @@
<script src="/static/js/webrtc.js"></script>
<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/client.js"></script>
</body>
</html>

View File

@@ -34,6 +34,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// IRC event listeners (from irc.js)
initIrcListeners();
// Dial keypad listeners (from dial.js)
initDialListeners();
window.addEventListener('resize', () => {
if (!state.maximizedPeer) updateVideoGrid();
});
@@ -327,6 +330,9 @@ 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).
setTrippyMode(!!data.trippy_mode);
// Save username on successful login
saveUsername(state.username);
@@ -474,6 +480,10 @@ function handleSignal(data) {
updateUI();
break;
case 'trippy_status':
setTrippyMode(!!data.enabled);
break;
}
}
@@ -630,6 +640,7 @@ function handlePageLeave() {
}
}
clearPersistentLocalVideos();
state.localStream?.getTracks().forEach(t => t.stop());
state.screenStream?.getTracks().forEach(t => t.stop());
Object.keys(state.peers).forEach(id => {

69
static/js/dial.js Normal file
View File

@@ -0,0 +1,69 @@
// HardChats - Dial keypad
// Requires: state, $, send from state.js
let dialSequence = '';
const DIAL_MAX_LEN = 32; // matches server-side cap
function initDialListeners() {
const dialBtn = $('dial-btn');
if (dialBtn) {
dialBtn.addEventListener('click', openDial);
dialBtn.addEventListener('touchend', (e) => { e.preventDefault(); openDial(); });
}
$('dial-close')?.addEventListener('click', closeDial);
$('dial-clear')?.addEventListener('click', clearDial);
$('dial-submit')?.addEventListener('click', submitDial);
document.querySelectorAll('.dial-key').forEach(btn => {
btn.addEventListener('click', () => pressDialKey(btn.dataset.key));
});
// Click outside the modal content to close.
$('dial-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'dial-modal') closeDial();
});
}
function openDial() {
$('dial-modal').classList.remove('hidden');
dialSequence = '';
updateDialDisplay();
}
function closeDial() {
$('dial-modal').classList.add('hidden');
dialSequence = '';
updateDialDisplay();
}
function pressDialKey(key) {
if (!key) return;
if (dialSequence.length >= DIAL_MAX_LEN) return;
dialSequence += key;
updateDialDisplay();
}
function clearDial() {
dialSequence = '';
updateDialDisplay();
}
function updateDialDisplay() {
const display = $('dial-display');
if (display) display.textContent = dialSequence || ' ';
}
function submitDial() {
if (!dialSequence) return;
send({ type: 'dial', sequence: dialSequence });
// We never tell the user whether the code was valid - server stays opaque.
closeDial();
}
// 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);
}

View File

@@ -34,6 +34,7 @@ const state = {
maxCameras: 10,
configLoaded: false,
defconMode: false, // Auto-mute and hide video for new users
trippyMode: false, // UI hue-shift animation - toggled via server-side dial codes
// Reconnection state
reconnectToken: null,
wsReconnectAttempts: 0,

View File

@@ -1,6 +1,19 @@
// HardChats - UI Rendering (Video Grid, Users List, Volume)
// Requires: state, $, escapeHtml from state.js
// One persistent <video> element per local-tile id ('local' for camera, 'local-screen'
// for screen share). updateVideoGrid rebuilds the tile DOM via innerHTML on every UI
// change (including hide-cam and fullscreen toggles); on mobile, repeatedly destroying
// the <video> element bound to the camera hardware causes the local preview to freeze
// on the last frame. Reusing one stable element per local source keeps the hardware
// pipeline intact across re-renders.
const persistentLocalVideos = new Map();
function clearPersistentLocalVideos() {
persistentLocalVideos.forEach(v => { v.srcObject = null; });
persistentLocalVideos.clear();
}
function updateUI() {
updateUsersList();
updateVideoGrid();
@@ -146,14 +159,35 @@ function createVideoTile(user, isMaximized) {
function attachStreamToTile(id, stream, isLocal) {
setTimeout(() => {
const tile = $(`tile-${id}`);
if (tile && stream) {
const video = tile.querySelector('video');
if (video) {
video.srcObject = stream;
// Always mute video element - audio is handled via Web Audio API GainNode
if (!tile || !stream) return;
const placeholder = tile.querySelector('video');
if (!placeholder) return;
if (isLocal) {
// Reuse one persistent video element per local source. The placeholder rendered
// by createVideoTile gets replaced with the persistent one - the persistent
// element's binding to the camera survives DOM moves but not destruction.
let video = persistentLocalVideos.get(id);
if (!video) {
video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = true;
video.play().catch(() => { });
persistentLocalVideos.set(id, video);
}
placeholder.replaceWith(video);
if (video.srcObject !== stream) {
video.srcObject = stream;
}
// Always re-call play() - removal from DOM during the previous innerHTML
// clear can leave the element paused.
video.play().catch(() => {});
} else {
// Remote: stream comes off the network, no hardware tie-in, so the simple
// destroy/recreate path is fine.
placeholder.srcObject = stream;
placeholder.muted = true;
placeholder.play().catch(() => {});
}
}, 0);
}

View File

@@ -1768,18 +1768,173 @@ button:focus-visible, input:focus-visible {
}
.defcon-btn,
.debug-btn {
.debug-btn,
.dial-btn {
padding: 12px 16px;
font-size: 12px;
/* Ensure touch events work */
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* Ensure buttons are fully clickable */
.defcon-btn:active,
.debug-btn:active {
.debug-btn:active,
.dial-btn:active {
transform: scale(0.98);
opacity: 0.9;
}
}
/* ========== DIAL BUTTON + MODAL ========== */
.dial-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
margin-bottom: 8px;
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;
}
.dial-btn svg {
width: 18px;
height: 18px;
}
.dial-btn:hover {
background: rgba(0, 255, 136, 0.08);
border-color: var(--acid);
color: var(--acid);
}
.dial-modal {
max-width: 320px;
padding: 16px;
}
.dial-display {
background: #000;
color: var(--acid);
font-family: monospace;
font-size: 1.6rem;
padding: 12px 16px;
text-align: center;
border: 1px solid var(--border);
border-radius: 6px;
min-height: 1.6em;
margin: 12px 0;
letter-spacing: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 6px rgba(0, 255, 136, 0.5);
}
.dial-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.dial-key {
aspect-ratio: 1;
font-size: 1.6rem;
font-weight: 600;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: background 0.1s, transform 0.05s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
.dial-key:hover {
background: rgba(0, 255, 136, 0.1);
border-color: var(--acid);
}
.dial-key:active {
background: rgba(0, 255, 136, 0.2);
transform: scale(0.95);
}
.dial-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.dial-actions button {
flex: 1;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--border);
transition: all 0.15s;
}
.dial-actions .btn-secondary {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.dial-actions .btn-secondary:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.dial-actions .btn-primary {
background: var(--acid);
color: #000;
border-color: var(--acid);
}
.dial-actions .btn-primary:hover {
filter: brightness(1.1);
}
/* ========== TRIPPY MODE ==========
* Slow hue/saturation cycle on the entire body. Videos counter-animate to keep
* camera/screen content looking normal - hue-rotate is additive, saturate is
* multiplicative, so applying the inverse on <video> cancels the body's effect
* just for those elements. The two animations have identical duration + timing
* so they stay phase-locked. */
@keyframes trippy-hue {
0% { filter: hue-rotate(0deg) saturate(1.15); }
25% { filter: hue-rotate(90deg) saturate(1.25); }
50% { filter: hue-rotate(180deg) saturate(1.15); }
75% { filter: hue-rotate(270deg) saturate(1.25); }
100% { filter: hue-rotate(360deg) saturate(1.15); }
}
@keyframes trippy-hue-counter {
0% { filter: hue-rotate(0deg) saturate(0.870); }
25% { filter: hue-rotate(-90deg) saturate(0.800); }
50% { filter: hue-rotate(-180deg) saturate(0.870); }
75% { filter: hue-rotate(-270deg) saturate(0.800); }
100% { filter: hue-rotate(-360deg) saturate(0.870); }
}
body.trippy-mode {
animation: trippy-hue 30s linear infinite;
}
body.trippy-mode video {
animation: trippy-hue-counter 30s linear infinite;
}