Files
ChatApp/apps/desktop/src/components/ParticipantVolumeMenu.tsx
T
byGalax 8f9b823d69 feat(call): per-participant volume up to 200% via WebAudio gain
HTMLMediaElement.volume caps at 1.0, so boosting a quiet peer past
100% needs an explicit GainNode in the output chain. New
remoteAudioPipelines module owns one AudioContext + GainNode per
remote audio track; attachTrack / detachTrack now create and tear
down the pipeline alongside the LiveKit element.

Once a track is on the WebAudio path its direct output is diverted
(createMediaElementSource semantics), so audio.muted / volume can't
drive output anymore. Deafen, watch-state, manual screen-share mute
and per-user volume are collapsed into one effective-gain formula
that gets recomputed on every state flip — the effect subscribes to
both participantVolumes and screenShareVolumes for live slider drags.

Slider ranges updated to 0–200% across:
- ParticipantVolumeMenu (per-user right-click menu)
- ParticipantsPopover (in-call participant list)
- ScreenShareContextMenu (per-share right-click)

Values above 100% render the percentage in amber as a soft hint that
clipping is possible. Clamp in both volume stores extended to [0, 2]
so persisted values survive.

setAudioOutputDevice now additionally routes via
AudioContext.setSinkId (Chrome 115+) for the WebAudio graph; the
HTMLAudioElement.setSinkId fallback stays for older runtimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:09:45 +02:00

99 lines
2.8 KiB
TypeScript

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 96;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Re-sync from store in case another menu instance changed the same user.
useEffect(() => subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(userId));
}), [userId]);
// Outside click + Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-volume-menu]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
return createPortal(
<div
data-volume-menu
role="dialog"
aria-label={'Lautstärke ' + displayName}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span>
<span
className={
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
}
>
{Math.round(volume * 100)}%
</span>
</div>
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
mics — the amber count-up hints at that without a verbose warning. */}
<input
type="range"
min={0}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(userId, v);
}}
aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent"
/>
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
<span>0%</span>
<span className="tabular-nums">100%</span>
<span>200%</span>
</div>
</div>,
document.body,
);
}