825160ee46
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>
189 lines
6.5 KiB
TypeScript
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,
|
|
);
|
|
}
|