diff --git a/desktop/app.js b/desktop/app.js
index 336b336..575529f 100644
--- a/desktop/app.js
+++ b/desktop/app.js
@@ -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 = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}${p.is_muted ? '' : ''}`;
+ pRow.style.flexWrap = "wrap";
+
+ let sliderHtml = '';
+ if (p.user_id !== state.me.id) {
+ const vol = state.userVolumes.get(p.user_id) ?? 1.0;
+ sliderHtml = `
+
+
+
+ ${Math.round(vol * 100)}%
+
+ `;
+ }
+
+ pRow.innerHTML = `
+
+
${shortName(p.display_name)}
+
${escapeHtml(p.display_name)}
+ ${p.is_muted ? '
' : ''}
+
+ ${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);
diff --git a/desktop/styles.css b/desktop/styles.css
index f2dbdbb..70cca56 100644
--- a/desktop/styles.css
+++ b/desktop/styles.css
@@ -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;
diff --git a/static/app.js b/static/app.js
index c226014..24e12b1 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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 = `${shortName(p.display_name)}
${escapeHtml(p.display_name)}${p.is_muted ? '' : ''}`;
+ pRow.style.flexWrap = "wrap";
+
+ let sliderHtml = '';
+ if (p.user_id !== state.me.id) {
+ const vol = state.userVolumes.get(p.user_id) ?? 1.0;
+ sliderHtml = `
+
+
+
+ ${Math.round(vol * 100)}%
+
+ `;
+ }
+
+ pRow.innerHTML = `
+
+
${shortName(p.display_name)}
+
${escapeHtml(p.display_name)}
+ ${p.is_muted ? '
' : ''}
+
+ ${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);
diff --git a/static/styles.css b/static/styles.css
index f12cd12..59fd145 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -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;