This commit is contained in:
parent
af46da77a7
commit
168ae4150e
4 changed files with 186 additions and 6 deletions
|
|
@ -27,12 +27,12 @@ const state = {
|
||||||
deepFilterModule: null,
|
deepFilterModule: null,
|
||||||
viewMode: 'chat', // 'chat' or 'video'
|
viewMode: 'chat', // 'chat' or 'video'
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
|
peerGainNodes: new Map(), // userId -> GainNode
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
chatWs: null,
|
|
||||||
lastMessageId: null,
|
|
||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
idleUsers: new Set(),
|
idleUsers: new Set(),
|
||||||
|
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Desktop Backend Configuration ---
|
// --- Desktop Backend Configuration ---
|
||||||
|
|
@ -411,7 +411,48 @@ function renderChannels() {
|
||||||
const pRow = document.createElement("div");
|
const pRow = document.createElement("div");
|
||||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||||
pRow.style.padding = "2px 8px";
|
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);
|
pList.appendChild(pRow);
|
||||||
}
|
}
|
||||||
el.voiceChannelList.appendChild(pList);
|
el.voiceChannelList.appendChild(pList);
|
||||||
|
|
@ -1047,7 +1088,6 @@ function ensurePeerConnection(peerId) {
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
}
|
}
|
||||||
audio.muted = false;
|
audio.muted = false;
|
||||||
audio.volume = 1;
|
|
||||||
audio.srcObject = new MediaStream([event.track]);
|
audio.srcObject = new MediaStream([event.track]);
|
||||||
const tryPlay = () => {
|
const tryPlay = () => {
|
||||||
const playPromise = audio.play();
|
const playPromise = audio.play();
|
||||||
|
|
@ -1060,6 +1100,28 @@ function ensurePeerConnection(peerId) {
|
||||||
audio.onloadedmetadata = tryPlay;
|
audio.onloadedmetadata = tryPlay;
|
||||||
audio.oncanplay = tryPlay;
|
audio.oncanplay = tryPlay;
|
||||||
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") {
|
} else if (event.track.kind === "video") {
|
||||||
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
||||||
renderVideo(peerId, peer.display_name, stream, event.track.id);
|
renderVideo(peerId, peer.display_name, stream, event.track.id);
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,33 @@ select {
|
||||||
vertical-align: text-bottom;
|
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 {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@ const state = {
|
||||||
deepFilterModule: null,
|
deepFilterModule: null,
|
||||||
viewMode: 'chat', // 'chat' or 'video'
|
viewMode: 'chat', // 'chat' or 'video'
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
|
peerGainNodes: new Map(), // userId -> GainNode
|
||||||
},
|
},
|
||||||
voicePresencePollId: null,
|
voicePresencePollId: null,
|
||||||
chatWs: null,
|
chatWs: null,
|
||||||
lastMessageId: null,
|
lastMessageId: null,
|
||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
idleUsers: 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");
|
const pRow = document.createElement("div");
|
||||||
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
||||||
pRow.style.padding = "2px 8px";
|
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);
|
pList.appendChild(pRow);
|
||||||
}
|
}
|
||||||
el.voiceChannelList.appendChild(pList);
|
el.voiceChannelList.appendChild(pList);
|
||||||
|
|
@ -987,7 +1030,6 @@ function ensurePeerConnection(peerId) {
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
}
|
}
|
||||||
audio.muted = false;
|
audio.muted = false;
|
||||||
audio.volume = 1;
|
|
||||||
audio.srcObject = new MediaStream([event.track]);
|
audio.srcObject = new MediaStream([event.track]);
|
||||||
const tryPlay = () => {
|
const tryPlay = () => {
|
||||||
const playPromise = audio.play();
|
const playPromise = audio.play();
|
||||||
|
|
@ -1000,6 +1042,28 @@ function ensurePeerConnection(peerId) {
|
||||||
audio.onloadedmetadata = tryPlay;
|
audio.onloadedmetadata = tryPlay;
|
||||||
audio.oncanplay = tryPlay;
|
audio.oncanplay = tryPlay;
|
||||||
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") {
|
} else if (event.track.kind === "video") {
|
||||||
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
||||||
renderVideo(peerId, peer.display_name, stream, event.track.id);
|
renderVideo(peerId, peer.display_name, stream, event.track.id);
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,33 @@ select {
|
||||||
vertical-align: text-bottom;
|
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 {
|
.user-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue