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>
171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import {
|
|
getScreenShareVolume,
|
|
setScreenShareVolume,
|
|
subscribeScreenShareVolumes,
|
|
} from '../lib/screenShareVolumes';
|
|
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
|
|
|
interface Props {
|
|
/** Participant whose screen share the user right-clicked. */
|
|
userId: string;
|
|
displayName: string;
|
|
/** Whether the share has an audio track published. Controls whether the
|
|
* volume / mute rows render — without audio those would be no-ops. */
|
|
hasAudio: boolean;
|
|
x: number;
|
|
y: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const MENU_W = 260;
|
|
const MENU_H_WITH_AUDIO = 200;
|
|
const MENU_H_NO_AUDIO = 96;
|
|
|
|
// Context menu surfaced on right-click of a remote screen share. Mirrors
|
|
// Discord's stream menu: volume slider, audio mute toggle (independent of
|
|
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
|
|
// which both un-subscribes locally and dismisses the tile from the grid.
|
|
export function ScreenShareContextMenu({
|
|
userId,
|
|
displayName,
|
|
hasAudio,
|
|
x,
|
|
y,
|
|
onClose,
|
|
}: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const {
|
|
dismissShare,
|
|
screenShareAudioMutedIds,
|
|
setScreenShareAudioMuted,
|
|
} = useCall();
|
|
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
|
|
|
|
useEffect(
|
|
() =>
|
|
subscribeScreenShareVolumes(() => {
|
|
setVolume(getScreenShareVolume(userId));
|
|
}),
|
|
[userId],
|
|
);
|
|
|
|
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-share-menu]')) return;
|
|
onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
window.addEventListener('mousedown', onDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey);
|
|
window.removeEventListener('mousedown', onDown);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const muted = screenShareAudioMutedIds.has(userId);
|
|
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
|
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
|
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
|
|
|
|
return createPortal(
|
|
<div
|
|
data-share-menu
|
|
role="menu"
|
|
aria-label={t('app:call.share_menu_title', {
|
|
defaultValue: 'Bildschirmfreigabe von {{name}}',
|
|
name: displayName,
|
|
})}
|
|
style={{ left, top, width: MENU_W }}
|
|
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
|
|
>
|
|
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
|
|
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
|
<span className="truncate">
|
|
{t('app:call.share_menu_owner', {
|
|
defaultValue: 'Bildschirmfreigabe · {{name}}',
|
|
name: displayName,
|
|
})}
|
|
</span>
|
|
</header>
|
|
|
|
{hasAudio && (
|
|
<>
|
|
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="font-medium text-fg">
|
|
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
|
</span>
|
|
<span
|
|
className={
|
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
|
}
|
|
>
|
|
{Math.round(volume * 100)}%
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={2}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={(e) => {
|
|
const v = Number(e.target.value);
|
|
setVolume(v);
|
|
setScreenShareVolume(userId, v);
|
|
}}
|
|
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
|
className="w-full accent-accent"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-fg-muted">
|
|
<span>0%</span>
|
|
<span>100%</span>
|
|
<span>200%</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setScreenShareAudioMuted(userId, !muted)}
|
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
|
|
>
|
|
{muted ? (
|
|
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
|
|
) : (
|
|
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
|
|
)}
|
|
<span className="flex-1">
|
|
{muted
|
|
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
|
|
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
|
|
</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
dismissShare(userId);
|
|
onClose();
|
|
}}
|
|
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
|
|
>
|
|
<PhoneOffIcon className="h-4 w-4" />
|
|
<span>
|
|
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
|
</span>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|