This commit is contained in:
2026-05-05 22:43:50 +00:00
parent 63dd1006b1
commit 0e369c9e27
5 changed files with 98 additions and 13 deletions

View File

@@ -217,7 +217,7 @@
</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group desktop-only-setting">
<label for="speaker-select">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
@@ -276,6 +276,17 @@
<span class="toggle-slider"></span>
</button>
</div>
<div class="settings-toggle mobile-only-setting">
<label>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
Speaker Mode <span class="settings-hint">(loud)</span>
</label>
<button id="toggle-speakermode" class="toggle-btn" data-enabled="true">
<span class="toggle-slider"></span>
</button>
</div>
</div>
<button id="settings-apply" class="btn-primary">Apply Changes</button>

View File

@@ -430,10 +430,12 @@ function updateSettingsToggles() {
const notifToggle = $('toggle-notifications');
const soundToggle = $('toggle-sounds');
const lowBwToggle = $('toggle-lowbandwidth');
const speakerToggle = $('toggle-speakermode');
if (notifToggle) notifToggle.dataset.enabled = state.settings.notifications;
if (soundToggle) soundToggle.dataset.enabled = state.settings.sounds;
if (lowBwToggle) lowBwToggle.dataset.enabled = state.settings.lowBandwidth;
if (speakerToggle) speakerToggle.dataset.enabled = state.settings.speakerMode !== false;
}
// Toggle button click handler
@@ -482,6 +484,10 @@ document.addEventListener('DOMContentLoaded', () => {
$('toggle-notifications')?.addEventListener('click', () => handleToggleClick('toggle-notifications', 'notifications'));
$('toggle-sounds')?.addEventListener('click', () => handleToggleClick('toggle-sounds', 'sounds'));
$('toggle-lowbandwidth')?.addEventListener('click', () => handleToggleClick('toggle-lowbandwidth', 'lowBandwidth'));
$('toggle-speakermode')?.addEventListener('click', () => {
handleToggleClick('toggle-speakermode', 'speakerMode');
if (typeof rebuildAudioSinksForSpeakerMode === 'function') rebuildAudioSinksForSpeakerMode();
});
// Close modal when clicking outside
$('settings-modal')?.addEventListener('click', (e) => {

View File

@@ -48,7 +48,12 @@ const state = {
settings: {
notifications: true,
sounds: true,
lowBandwidth: false
lowBandwidth: false,
// Mobile audio routing. true = remote audio plays through a hidden <video>
// element, which iOS classifies as media playback (loudspeaker). false = plays
// through <audio>, which under an active mic becomes communication category
// (earpiece). Desktop browsers route the same regardless of element tag.
speakerMode: true
},
// IRC state
irc: {

View File

@@ -49,10 +49,16 @@ function startAudioPlaybackPrimer() {
gain.connect(dest);
source.start();
const primer = document.createElement('audio');
// Tag matches the per-peer sink (see setupPeerAudio). Using <video> here too
// keeps the entire audio session in the same iOS category so the first peer
// audio element doesn't accidentally re-classify it.
const useVideo = state.settings?.speakerMode !== false;
const primer = document.createElement(useVideo ? 'video' : 'audio');
primer.autoplay = true;
primer.playsInline = true;
primer.muted = false;
primer.id = 'audio-playback-primer';
primer.dataset.audioMode = useVideo ? 'video' : 'audio';
primer.srcObject = dest.stream;
const container = document.getElementById('peer-audio-container') || document.body;
container.appendChild(primer);
@@ -609,14 +615,20 @@ async function flushPendingCandidates(peerId) {
// Build (or rebuild) the audio path for a peer:
//
// stream -> <audio autoplay playsinline>.srcObject (output: speakers)
// stream -> MediaStreamSource -> AnalyserNode (speaking indicator only)
// stream -> <video|audio autoplay playsinline>.srcObject (output: speaker/earpiece)
// stream -> MediaStreamSource -> AnalyserNode (speaking indicator only)
//
// Audio output is the canonical WebRTC pattern: <audio>.srcObject = remoteStream
// Audio output is the canonical WebRTC pattern: media-element.srcObject = remoteStream
// directly. Mobile browsers (Android Chrome, iOS Safari) treat WebRTC remote streams
// as real network media for autoplay and audio-session purposes, so playback starts
// reliably as long as the page has had any prior user activation (the Connect tap).
//
// Tag depends on settings.speakerMode (default true). On iOS, a <video> element
// playing a WebRTC stream gets the media audio-session category (loudspeaker), while
// <audio> playing the same stream while a mic capture is active gets the communication
// category (earpiece - quiet). Toggling speakerMode tears down each peer's element
// and recreates it with the new tag.
//
// The previous attempt routed audio through a Web Audio GainNode + MediaStreamDestination
// to support a 0-150% volume slider. That Web-Audio-derived stream is not classified as
// "real" media on mobile - the audio session never activated, and audio stayed silent
@@ -648,15 +660,26 @@ function setupPeerAudio(peerId, stream) {
tapPeerToRecordingMixer(peerId, peer.audioSource);
}
// Per-peer hidden <audio> element. Created once, reused across renegotiations.
// Per-peer hidden sink element. Created once, reused across renegotiations.
// Tag depends on speakerMode: <video> routes through media category on iOS
// (loudspeaker), <audio> routes through communication category (earpiece).
// If the mode flipped at runtime, the old element is recreated with the new tag.
const desiredMode = state.settings?.speakerMode !== false ? 'video' : 'audio';
if (peer.audioElement && peer.audioElement.dataset.audioMode !== desiredMode) {
try { peer.audioElement.srcObject = null; } catch (e) {}
try { peer.audioElement.remove(); } catch (e) {}
peer.audioElement = null;
}
if (!peer.audioElement) {
const audioEl = document.createElement('audio');
audioEl.autoplay = true;
audioEl.playsInline = true;
audioEl.id = `peer-audio-${peerId}`;
const sinkEl = document.createElement(desiredMode);
sinkEl.autoplay = true;
sinkEl.playsInline = true;
sinkEl.muted = false;
sinkEl.dataset.audioMode = desiredMode;
sinkEl.id = `peer-audio-${peerId}`;
const container = document.getElementById('peer-audio-container') || document.body;
container.appendChild(audioEl);
peer.audioElement = audioEl;
container.appendChild(sinkEl);
peer.audioElement = sinkEl;
}
// Update srcObject if the stream identity changed (renegotiation can hand us a
// fresh MediaStream). Calling .play() again is harmless on the same stream.
@@ -703,6 +726,28 @@ function setupPeerAudio(peerId, stream) {
}
}
// Recreate the playback primer + every peer's audio sink so the new speakerMode
// (audio vs video tag) takes effect. Called from the settings toggle handler.
function rebuildAudioSinksForSpeakerMode() {
if (state.audioPrimer) {
try { state.audioPrimer.srcObject = null; } catch (e) {}
try { state.audioPrimer.remove(); } catch (e) {}
state.audioPrimer = null;
}
if (state.audioPrimerSource) {
try { state.audioPrimerSource.stop(); } catch (e) {}
state.audioPrimerSource = null;
}
startAudioPlaybackPrimer();
for (const [peerId, peer] of Object.entries(state.peers)) {
const stream = peer.audioElement?.srcObject || peer.stream;
if (stream && stream.getAudioTracks && stream.getAudioTracks().length > 0) {
setupPeerAudio(peerId, stream);
}
}
}
// Tear down a peer's audio path and remove its <audio> element. Safe to call multiple
// times; safe to call on partially-constructed peers.
function teardownPeerAudio(peerId) {

View File

@@ -1472,13 +1472,31 @@ body {
.settings-preview {
max-height: 100px;
}
/* Mobile-only settings rows (e.g. Speaker Mode toggle) */
.mobile-only-setting { display: flex; }
/* Speaker device picker is useless on mobile - setSinkId isn't supported and
enumerateDevices returns nothing meaningful. Hide it; the Speaker Mode toggle
replaces it. */
.desktop-only-setting { display: none; }
}
/* Default visibility outside the mobile breakpoint. */
.mobile-only-setting { display: none; }
@media (max-width: 480px) {
.video-tile { border-radius: 8px; }
.video-tile .username { font-size: 0.75rem; padding: 0.5rem 0.75rem; }
}
/* Subtle hint text inside settings labels (e.g. "(loud)" next to Speaker Mode). */
.settings-hint {
font-weight: 400;
color: var(--text-muted);
margin-left: 0.25rem;
font-size: 0.85em;
}
/* ========== FOCUS STATES ========== */
button:focus-visible, input:focus-visible {
outline: 2px solid var(--acid);