From afbaa8f7d1de96defa491523d3ff85ea7050bdd4 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 28 Feb 2026 13:43:03 +0100 Subject: [PATCH 1/6] fix volume slider --- static/shared/app-core.js | 110 ++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/static/shared/app-core.js b/static/shared/app-core.js index aaaa0b4..deee4bc 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -576,50 +576,103 @@ function renderChannels() { pList.style.paddingLeft = "24px"; for (const p of participants) { const pRow = document.createElement("div"); - pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; + pRow.className = `channel-row voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`; + pRow.style.display = "block"; pRow.style.padding = "2px 8px"; pRow.style.flexWrap = "wrap"; - let sliderHtml = ''; - if (p.user_id !== state.me.id) { - const vol = state.userVolumes.get(p.user_id) ?? 1.0; - const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); - sliderHtml = ` -
- - - ${Math.round(vol * 100)}% -
- `; + const topRow = document.createElement("div"); + topRow.style.display = "flex"; + topRow.style.alignItems = "center"; + topRow.style.gap = "8px"; + topRow.style.width = "100%"; + + const avatar = document.createElement("div"); + avatar.className = "avatar"; + avatar.style.width = "20px"; + avatar.style.height = "20px"; + avatar.style.fontSize = "10px"; + avatar.textContent = shortName(p.display_name); + topRow.appendChild(avatar); + + const name = document.createElement("span"); + name.style.flex = "1"; + name.style.overflow = "hidden"; + name.style.textOverflow = "ellipsis"; + name.textContent = p.display_name; + topRow.appendChild(name); + + const isRemoteParticipant = p.user_id !== state.me.id; + if (isRemoteParticipant) { + const volumeToggle = document.createElement("button"); + volumeToggle.type = "button"; + volumeToggle.title = "Toggle volume slider"; + volumeToggle.style.display = "grid"; + volumeToggle.style.placeItems = "center"; + volumeToggle.style.width = "20px"; + volumeToggle.style.height = "20px"; + volumeToggle.style.color = "var(--text-muted)"; + volumeToggle.innerHTML = ''; + topRow.appendChild(volumeToggle); + + volumeToggle.addEventListener('click', (e) => { + toggleVolumeSlider(e); + }); } - pRow.innerHTML = ` -
-
${shortName(p.display_name)}
- ${escapeHtml(p.display_name)} - ${p.is_muted ? '' : ''} -
- ${sliderHtml} - `; + if (p.is_muted) { + const muteIcon = document.createElement("i"); + muteIcon.setAttribute("data-lucide", "mic-off"); + muteIcon.className = "voice-muted-icon"; + topRow.appendChild(muteIcon); + } + + pRow.appendChild(topRow); + + const volumeControl = document.createElement("div"); + volumeControl.className = `user-volume-control ${state.voice.visibleVolumeSliders.has(p.user_id) ? 'show-volume' : ''}`; + volumeControl.dataset.userId = p.user_id; + + if (isRemoteParticipant) { + const volumeIcon = document.createElement("i"); + volumeIcon.setAttribute("data-lucide", "volume-2"); + volumeIcon.style.width = "12px"; + volumeIcon.style.height = "12px"; + volumeIcon.style.opacity = "0.6"; + volumeControl.appendChild(volumeIcon); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "2"; + slider.step = "0.1"; + slider.value = String(state.userVolumes.get(p.user_id) ?? 1.0); + slider.className = "volume-slider"; + volumeControl.appendChild(slider); + + const volumePct = document.createElement("span"); + volumePct.className = "vol-pct"; + volumePct.textContent = `${Math.round((state.userVolumes.get(p.user_id) ?? 1.0) * 100)}%`; + volumeControl.appendChild(volumePct); - 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)}%`; + volumePct.textContent = `${Math.round(val * 100)}%`; const gainNode = state.voice.peerGainNodes.get(p.user_id); - if (gainNode) { + if (gainNode && state.voice.audioContext) { 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()); + + volumeControl.addEventListener('click', (e) => e.stopPropagation()); + volumeControl.addEventListener('mousedown', (e) => e.stopPropagation()); + pRow.appendChild(volumeControl); } const toggleVolumeSlider = (e) => { - if (p.user_id === state.me.id) return; + if (!isRemoteParticipant) return; e.preventDefault(); e.stopPropagation(); if (state.voice.visibleVolumeSliders.has(p.user_id)) { @@ -633,6 +686,9 @@ function renderChannels() { // Electron can swallow contextmenu events on some platforms; use // right-button mousedown as a reliable fallback for slider toggle. pRow.addEventListener('contextmenu', toggleVolumeSlider); + pRow.addEventListener('auxclick', (e) => { + if (e.button === 2) toggleVolumeSlider(e); + }); pRow.addEventListener('mousedown', (e) => { if (e.button === 2) toggleVolumeSlider(e); }); From 6dbd5e77c4370792f68f16bd67a4504d1eb6c35c Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 28 Feb 2026 13:44:57 +0100 Subject: [PATCH 2/6] fix desktop backend url --- main.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index 1baa6e2..cbc011f 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,7 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); +const packageJson = require('./package.json'); const updateState = { status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error @@ -24,6 +25,22 @@ function isNewerVersionAvailable(info) { let updateCheckInProgress = false; +function resolveBackendUrl() { + const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim(); + if (configuredUrl) { + return configuredUrl.replace(/\/$/, ''); + } + + const packagedFallback = typeof packageJson.homepage === 'string' + ? packageJson.homepage.trim() + : ''; + if (app.isPackaged && packagedFallback) { + return packagedFallback.replace(/\/$/, ''); + } + + return 'http://localhost:3000'; +} + async function runUpdateCheck(reason = 'manual') { if (updateCheckInProgress) { console.log(`Update check skipped (${reason}): another check is already in progress`); @@ -56,8 +73,9 @@ async function runUpdateCheck(reason = 'manual') { function createWindow() { const sess = session.fromPartition('persist:chattz'); - const backendUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || 'http://localhost:3000').replace(/\/$/, ''); + const backendUrl = resolveBackendUrl(); const backendOrigin = new URL(backendUrl).origin; + console.log(`Desktop backend URL: ${backendUrl}`); const win = new BrowserWindow({ width: 1200, From 76097f3294294c4e40363e77b91eff888eec7a26 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 28 Feb 2026 14:46:38 +0100 Subject: [PATCH 3/6] fix crash --- main.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index cbc011f..80f428c 100644 --- a/main.js +++ b/main.js @@ -41,6 +41,13 @@ function resolveBackendUrl() { return 'http://localhost:3000'; } +function webContentsOrigin(webContents) { + if (!webContents || typeof webContents.getURL !== 'function') { + return null; + } + return safeOrigin(webContents.getURL()); +} + async function runUpdateCheck(reason = 'manual') { if (updateCheckInProgress) { console.log(`Update check skipped (${reason}): another check is already in progress`); @@ -93,13 +100,13 @@ function createWindow() { win.setMenuBarVisibility(true); sess.setPermissionCheckHandler((webContents, permission) => { - const origin = safeOrigin(webContents.getURL()); + const origin = webContentsOrigin(webContents); if (origin !== backendOrigin) return false; return permission === 'media' || permission === 'clipboard-write'; }); sess.setPermissionRequestHandler((webContents, permission, callback) => { - const origin = safeOrigin(webContents.getURL()); + const origin = webContentsOrigin(webContents); if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) { callback(true); } else { From c9592a9cb011b58c4c384b8a480b8c1897c57463 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 28 Feb 2026 15:05:09 +0100 Subject: [PATCH 4/6] fix updater --- main.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index 80f428c..f759b3f 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,7 @@ const path = require('path'); const packageJson = require('./package.json'); const updateState = { - status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error + status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error info: null, error: null, }; @@ -24,6 +24,7 @@ function isNewerVersionAvailable(info) { } let updateCheckInProgress = false; +let installInProgress = false; function resolveBackendUrl() { const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim(); @@ -49,6 +50,10 @@ function webContentsOrigin(webContents) { } async function runUpdateCheck(reason = 'manual') { + if (installInProgress) { + console.log(`Update check skipped (${reason}): install already in progress`); + return; + } if (updateCheckInProgress) { console.log(`Update check skipped (${reason}): another check is already in progress`); return; @@ -78,6 +83,47 @@ async function runUpdateCheck(reason = 'manual') { } } +function installDownloadedUpdate() { + if (installInProgress) { + console.log('Update install already in progress'); + return; + } + + installInProgress = true; + updateState.status = 'installing'; + updateState.error = null; + broadcastUpdateState(); + + const wins = BrowserWindow.getAllWindows(); + for (const win of wins) { + if (win.isDestroyed()) continue; + try { + win.removeAllListeners('close'); + win.destroy(); + } catch (err) { + console.warn('Failed to destroy window before update install', err); + } + } + + setImmediate(() => { + try { + autoUpdater.quitAndInstall(false, true); + } catch (err) { + installInProgress = false; + updateState.status = 'error'; + updateState.error = err && err.message ? err.message : String(err); + broadcastUpdateState(); + console.error('quitAndInstall failed', err); + } + }); + + setTimeout(() => { + if (installInProgress) { + console.warn('Update install is still waiting for app shutdown'); + } + }, 15000); +} + function createWindow() { const sess = session.fromPartition('persist:chattz'); const backendUrl = resolveBackendUrl(); @@ -209,6 +255,7 @@ app.whenReady().then(() => { }); ipcMain.on('download-update', () => { + if (installInProgress) return; updateState.status = 'downloading'; updateState.error = null; broadcastUpdateState(); @@ -216,7 +263,7 @@ app.whenReady().then(() => { }); ipcMain.on('quit-and-install', () => { - autoUpdater.quitAndInstall(); + installDownloadedUpdate(); }); // Check once on startup From 9d7f658fc0b0d4a2b3af61a259323de75df55a09 Mon Sep 17 00:00:00 2001 From: pavel Date: Sat, 28 Feb 2026 15:15:42 +0100 Subject: [PATCH 5/6] periodic update check --- main.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/main.js b/main.js index f759b3f..030c413 100644 --- a/main.js +++ b/main.js @@ -2,6 +2,7 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = req const { autoUpdater } = require('electron-updater'); const path = require('path'); const packageJson = require('./package.json'); +const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000; const updateState = { status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error @@ -25,6 +26,7 @@ function isNewerVersionAvailable(info) { let updateCheckInProgress = false; let installInProgress = false; +let periodicUpdateTimer = null; function resolveBackendUrl() { const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim(); @@ -124,6 +126,16 @@ function installDownloadedUpdate() { }, 15000); } +function startPeriodicUpdateChecks() { + if (periodicUpdateTimer || !app.isPackaged) { + return; + } + + periodicUpdateTimer = setInterval(() => { + void runUpdateCheck('periodic'); + }, PERIODIC_UPDATE_CHECK_INTERVAL_MS); +} + function createWindow() { const sess = session.fromPartition('persist:chattz'); const backendUrl = resolveBackendUrl(); @@ -270,6 +282,7 @@ app.whenReady().then(() => { setTimeout(() => { void runUpdateCheck('startup'); }, 5000); + startPeriodicUpdateChecks(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -284,6 +297,13 @@ app.on('window-all-closed', () => { } }); +app.on('before-quit', () => { + if (periodicUpdateTimer) { + clearInterval(periodicUpdateTimer); + periodicUpdateTimer = null; + } +}); + function safeOrigin(value) { try { return new URL(value).origin; From 39eb7c77ad9d5eed16588baf1a806ad586f2732f Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Mar 2026 22:41:54 +0100 Subject: [PATCH 6/6] fix: volume --- static/shared/app-core.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/static/shared/app-core.js b/static/shared/app-core.js index deee4bc..8531b48 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -29,6 +29,7 @@ const state = { viewMode: 'chat', // 'chat' or 'video' iceServers: [{ urls: "stun:stun.l.google.com:19302" }], peerGainNodes: new Map(), // userId -> GainNode + peerAudioNodes: new Map(), // userId -> { sourceNode, compressorNode, gainNode } visibleVolumeSliders: new Set(), // userIds whose sliders are visible }, voicePresencePollId: null, @@ -1039,7 +1040,24 @@ function shouldInitiateOffer(peerId) { return state.me.id > peerId; } +function disconnectPeerAudioNodes(peerId) { + const nodes = state.voice.peerAudioNodes.get(peerId); + if (!nodes) return; + + for (const node of [nodes.sourceNode, nodes.compressorNode, nodes.gainNode]) { + try { + node?.disconnect(); + } catch { } + } + + state.voice.peerAudioNodes.delete(peerId); + state.voice.peerGainNodes.delete(peerId); +} + function stopAndClearAudioPipeline() { + for (const peerId of state.voice.peerAudioNodes.keys()) { + disconnectPeerAudioNodes(peerId); + } if (state.voice.deepFilterProcessor) { state.voice.deepFilterProcessor.destroy(); state.voice.deepFilterProcessor = null; @@ -1265,7 +1283,7 @@ async function createLocalVoiceStream() { sampleRate: 48000, echoCancellation: true, noiseSuppression: false, - autoGainControl: false, + autoGainControl: true, }, video: false, }; @@ -1388,16 +1406,27 @@ function ensurePeerConnection(peerId) { const ctx = state.voice.audioContext; if (ctx.state === 'suspended') ctx.resume(); + disconnectPeerAudioNodes(peerId); + const sourceNode = ctx.createMediaStreamSource(audio.srcObject); + const compressorNode = ctx.createDynamicsCompressor(); const gainNode = ctx.createGain(); + compressorNode.threshold.setValueAtTime(-24, ctx.currentTime); + compressorNode.knee.setValueAtTime(18, ctx.currentTime); + compressorNode.ratio.setValueAtTime(4, ctx.currentTime); + compressorNode.attack.setValueAtTime(0.003, ctx.currentTime); + compressorNode.release.setValueAtTime(0.25, ctx.currentTime); + const currentVolume = state.userVolumes.get(peerId) ?? 1.0; gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); - sourceNode.connect(gainNode); + sourceNode.connect(compressorNode); + compressorNode.connect(gainNode); gainNode.connect(ctx.destination); state.voice.peerGainNodes.set(peerId, gainNode); + state.voice.peerAudioNodes.set(peerId, { sourceNode, compressorNode, gainNode }); // Mute the original element as we play through Web Audio destination audio.volume = 0; @@ -1582,6 +1611,7 @@ async function joinVoice() { pc.close(); state.voice.peerConnections.delete(msg.user_id); } + disconnectPeerAudioNodes(msg.user_id); document.getElementById(`audio-${msg.user_id}`)?.remove(); } else if (msg.type === "signal") { await handleSignal(msg.from_user_id, msg.kind, msg.data);