volume
All checks were successful
/ upload (release) Successful in 3m21s

This commit is contained in:
pavel 2026-02-26 23:06:40 +01:00
commit 168ae4150e
4 changed files with 186 additions and 6 deletions

View file

@ -27,12 +27,12 @@ const state = {
deepFilterModule: null,
viewMode: 'chat', // 'chat' or 'video'
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerGainNodes: new Map(), // userId -> GainNode
},
voicePresencePollId: null,
chatWs: null,
lastMessageId: null,
onlineUsers: new Set(),
idleUsers: new Set(),
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
};
// --- Desktop Backend Configuration ---
@ -411,7 +411,48 @@ function renderChannels() {
const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}`;
pRow.style.flexWrap = "wrap";
let sliderHtml = '';
if (p.user_id !== state.me.id) {
const vol = state.userVolumes.get(p.user_id) ?? 1.0;
sliderHtml = `
<div class="user-volume-control" style="width: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; margin-top: 2px;">
<i data-lucide="volume-2" style="width: 12px; height: 12px; opacity: 0.6;"></i>
<input type="range" min="0" max="2" step="0.1" value="${vol}"
class="volume-slider"
data-user-id="${p.user_id}"
style="flex: 1; height: 4px; cursor: pointer;">
<span class="vol-pct" style="font-size: 10px; opacity: 0.6; min-width: 25px;">${Math.round(vol * 100)}%</span>
</div>
`;
}
pRow.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span>
${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}
</div>
${sliderHtml}
`;
const slider = pRow.querySelector('.volume-slider');
if (slider) {
slider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state.userVolumes.set(p.user_id, val);
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`;
const gainNode = state.voice.peerGainNodes.get(p.user_id);
if (gainNode) {
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
}
});
// Stop propagation to prevent joining channel again when clicking slider
slider.addEventListener('click', (e) => e.stopPropagation());
}
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
@ -1047,7 +1088,6 @@ function ensurePeerConnection(peerId) {
document.body.appendChild(audio);
}
audio.muted = false;
audio.volume = 1;
audio.srcObject = new MediaStream([event.track]);
const tryPlay = () => {
const playPromise = audio.play();
@ -1060,6 +1100,28 @@ function ensurePeerConnection(peerId) {
audio.onloadedmetadata = tryPlay;
audio.oncanplay = tryPlay;
tryPlay();
// Web Audio Pipeline for Volume Boosting
if (!state.voice.audioContext) {
state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = state.voice.audioContext;
if (ctx.state === 'suspended') ctx.resume();
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
const gainNode = ctx.createGain();
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
sourceNode.connect(gainNode);
gainNode.connect(ctx.destination);
state.voice.peerGainNodes.set(peerId, gainNode);
// Mute the original element as we play through Web Audio destination
audio.volume = 0;
audio.muted = true;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
renderVideo(peerId, peer.display_name, stream, event.track.id);

View file

@ -563,6 +563,33 @@ select {
vertical-align: text-bottom;
}
.volume-slider {
-webkit-appearance: none;
background: var(--bg-darker);
border-radius: 2px;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.volume-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.user-info {
flex: 1;
min-width: 0;

View file

@ -27,12 +27,14 @@ const state = {
deepFilterModule: null,
viewMode: 'chat', // 'chat' or 'video'
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerGainNodes: new Map(), // userId -> GainNode
},
voicePresencePollId: null,
chatWs: null,
lastMessageId: null,
onlineUsers: new Set(),
idleUsers: new Set(),
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
};
@ -364,7 +366,48 @@ function renderChannels() {
const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}`;
pRow.style.flexWrap = "wrap";
let sliderHtml = '';
if (p.user_id !== state.me.id) {
const vol = state.userVolumes.get(p.user_id) ?? 1.0;
sliderHtml = `
<div class="user-volume-control" style="width: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; margin-top: 2px;">
<i data-lucide="volume-2" style="width: 12px; height: 12px; opacity: 0.6;"></i>
<input type="range" min="0" max="2" step="0.1" value="${vol}"
class="volume-slider"
data-user-id="${p.user_id}"
style="flex: 1; height: 4px; cursor: pointer;">
<span class="vol-pct" style="font-size: 10px; opacity: 0.6; min-width: 25px;">${Math.round(vol * 100)}%</span>
</div>
`;
}
pRow.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; width: 100%;">
<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(p.display_name)}</span>
${p.is_muted ? '<i data-lucide="mic-off" class="voice-muted-icon"></i>' : ''}
</div>
${sliderHtml}
`;
const slider = pRow.querySelector('.volume-slider');
if (slider) {
slider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state.userVolumes.set(p.user_id, val);
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`;
const gainNode = state.voice.peerGainNodes.get(p.user_id);
if (gainNode) {
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
}
});
// Stop propagation to prevent joining channel again when clicking slider
slider.addEventListener('click', (e) => e.stopPropagation());
}
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
@ -987,7 +1030,6 @@ function ensurePeerConnection(peerId) {
document.body.appendChild(audio);
}
audio.muted = false;
audio.volume = 1;
audio.srcObject = new MediaStream([event.track]);
const tryPlay = () => {
const playPromise = audio.play();
@ -1000,6 +1042,28 @@ function ensurePeerConnection(peerId) {
audio.onloadedmetadata = tryPlay;
audio.oncanplay = tryPlay;
tryPlay();
// Web Audio Pipeline for Volume Boosting
if (!state.voice.audioContext) {
state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = state.voice.audioContext;
if (ctx.state === 'suspended') ctx.resume();
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
const gainNode = ctx.createGain();
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
sourceNode.connect(gainNode);
gainNode.connect(ctx.destination);
state.voice.peerGainNodes.set(peerId, gainNode);
// Mute the original element as we play through Web Audio destination
audio.volume = 0;
audio.muted = true;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
renderVideo(peerId, peer.display_name, stream, event.track.id);

View file

@ -584,6 +584,33 @@ select {
vertical-align: text-bottom;
}
.volume-slider {
-webkit-appearance: none;
background: var(--bg-darker);
border-radius: 2px;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.volume-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--brand);
cursor: pointer;
box-shadow: 0 0 4px var(--brand-glow);
}
.user-info {
flex: 1;
min-width: 0;