fixing audio issues
This commit is contained in:
33
server.py
33
server.py
@@ -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):
|
||||
'''
|
||||
|
||||
@@ -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"> </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="*">*</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>
|
||||
|
||||
@@ -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
69
static/js/dial.js
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
161
static/style.css
161
static/style.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user