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;