updated
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user