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>
194 lines
6.6 KiB
TypeScript
194 lines
6.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 { EyeOffIcon, 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;
|
|
// Heights bumped to fit the new "Tile ausblenden" row (38px each).
|
|
const MENU_H_WITH_AUDIO = 240;
|
|
const MENU_H_NO_AUDIO = 134;
|
|
|
|
// 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,
|
|
stopWatchingShare,
|
|
watchingShareUserIds,
|
|
screenShareAudioMutedIds,
|
|
setScreenShareAudioMuted,
|
|
} = useCall();
|
|
const watching = watchingShareUserIds.has(userId);
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
{/* Discord-parity: "Stop watching" only ends the local subscription;
|
|
the tile stays so the user can re-click to watch again. The full
|
|
dismiss path (kill the tile until the sharer restarts) is the
|
|
second row below. */}
|
|
{watching && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
stopWatchingShare(userId);
|
|
onClose();
|
|
}}
|
|
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"
|
|
>
|
|
<PhoneOffIcon className="h-4 w-4 text-fg-muted" />
|
|
<span>
|
|
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
|
</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"
|
|
>
|
|
<EyeOffIcon className="h-4 w-4" />
|
|
<span>
|
|
{t('app:call.dismiss_tile', { defaultValue: 'Stream-Tile ausblenden' })}
|
|
</span>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|