8f9b823d69
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>
99 lines
2.8 KiB
TypeScript
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,
|
|
);
|
|
}
|