Files
ChatApp/apps/desktop/src/components/ParticipantVolumeMenu.tsx
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

189 lines
6.5 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
import {
AtIcon,
MicIcon,
MicOffIcon,
PinIcon,
PinOffIcon,
} from './icons';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
/** Discord-style pin toggle row at the top of the menu. When undefined,
* the row is hidden — used for tiles that don't make sense to pin. */
pinned?: boolean;
onTogglePin?: () => void;
/** Hide the volume slider — for self-tiles where the slider would adjust
* the local user's own playback gain (which we don't expose). Defaults to
* true so existing call sites keep their behaviour. */
renderVolume?: boolean;
/** Discord-parity: open this user's profile card from the menu. When
* undefined the row is hidden (e.g. for self-tiles where it'd just open
* your own profile). */
onShowProfile?: () => void;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 138;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
pinned,
onTogglePin,
renderVolume = true,
onShowProfile,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Remembers the volume before "Für mich stummschalten" so unmute restores
// it instead of snapping back to 100%.
const preMuteRef = useRef<number | null>(volume > 0 ? volume : null);
// 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>
{renderVolume && (
<span
className={
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
}
>
{Math.round(volume * 100)}%
</span>
)}
</div>
{onShowProfile && (
<button
type="button"
onClick={() => {
onShowProfile();
onClose();
}}
className="mb-1.5 flex w-full cursor-pointer items-center gap-2 rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-xs font-medium text-fg transition hover:border-accent hover:bg-accent/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<AtIcon className="h-3.5 w-3.5" />
<span>Profil anzeigen</span>
</button>
)}
{renderVolume && (
<button
type="button"
onClick={() => {
// Discord-parity "Mute for me": toggle local playback gain.
// Storing the pre-mute level lets unmute restore the user's
// chosen volume (e.g. 130% → 0 → 130%) instead of snapping to 100.
if (volume === 0) {
const restored = preMuteRef.current ?? 1;
setVolume(restored);
setParticipantVolume(userId, restored);
} else {
preMuteRef.current = volume;
setVolume(0);
setParticipantVolume(userId, 0);
}
}}
className={
'mb-1.5 flex w-full cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(volume === 0
? 'border-rose-500/40 bg-rose-500/15 text-rose-600 hover:bg-rose-500/20 dark:text-rose-300'
: 'border-line bg-surface-3 text-fg hover:border-accent hover:bg-accent/10')
}
>
{volume === 0 ? (
<MicIcon className="h-3.5 w-3.5" />
) : (
<MicOffIcon className="h-3.5 w-3.5" />
)}
<span>{volume === 0 ? 'Lokalen Ton aktivieren' : 'Für mich stummschalten'}</span>
</button>
)}
{onTogglePin && (
<button
type="button"
onClick={() => {
onTogglePin();
onClose();
}}
className="mb-2 flex w-full cursor-pointer items-center gap-2 rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-xs font-medium text-fg transition hover:border-accent hover:bg-accent/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{pinned ? <PinOffIcon className="h-3.5 w-3.5" /> : <PinIcon className="h-3.5 w-3.5" />}
<span>{pinned ? 'Anpinnen aufheben' : 'Anpinnen'}</span>
</button>
)}
{renderVolume && (
<>
{/* 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,
);
}