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>
This commit is contained in:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+352 -37
View File
@@ -1,6 +1,6 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,16 +15,25 @@ import {
listSounds,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
import { ScreenSourcePicker } from './ScreenSourcePicker';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
import { UserProfilePopover } from './UserProfilePopover';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
@@ -54,6 +63,19 @@ interface Tile {
sharing: boolean;
// True iff this is a remote user's screen-share tile.
remoteSharing: boolean;
// LiveKit-measured connection quality for this participant. Drives the
// Discord-style Wifi badge on user-tiles. Undefined for screen-tiles.
connectionQuality?: TileConnectionQuality;
// Discord-style host marker — only true for the call initiator's tile in
// group calls. Drives the crown.
isHost: boolean;
// True iff the user pinned this tile via right-click → "Anpinnen".
// Mirrors the value of CallContext.focusedId for this tile id.
pinned: boolean;
// True iff this user-tile's owner is currently publishing a screen share.
// Drives the LIVE pill on the avatar tile so the share-tile next to it
// visually belongs to the same person. Always false on screen-kind tiles.
streaming: boolean;
}
export function InCallPanel({ conversation }: Props) {
@@ -73,7 +95,6 @@ export function InCallPanel({ conversation }: Props) {
callMode,
focusedId,
toggleMute,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
@@ -84,19 +105,65 @@ export function InCallPanel({ conversation }: Props) {
clearMicError,
retryMic,
dismissedShareUserIds,
connectionQualities,
callHostId,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [pickerOpen, setPickerOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
// Discord-style screen-share configure modal. Opens when the user clicks
// the Share button while not yet sharing — collects FPS/resolution/audio
// before triggering the OS source picker.
const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
// controls bar can show an "active" state without polling. Captions
// broadcasting is wired in CallContext via useLiveCaptions; this only
// tracks the toggle state for the button.
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
() => getLiveCaptionsSettings().enabled,
);
useEffect(
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
[],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
if (
(e.ctrlKey || e.metaKey) &&
e.shiftKey &&
!e.altKey &&
e.code === 'KeyS'
) {
e.preventDefault();
setStatsOverlayOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
{
userId: string;
displayName: string;
x: number;
y: number;
tileId: string;
self: boolean;
} | null
>(null);
const [shareMenu, setShareMenu] = useState<
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
>(null);
// Discord-style profile popover triggered from the right-click context menu
// on a participant tile. Anchored at the same coords as the volume menu.
const [profileMenu, setProfileMenu] = useState<
{ userId: string; x: number; y: number } | null
>(null);
// Soundboard-count so the in-call bar only surfaces the music button when
// the user actually has something to play. Matches Discord's "hide soundboard
// when empty" behaviour — no point dangling a button that opens to a blank
@@ -149,18 +216,22 @@ export function InCallPanel({ conversation }: Props) {
// watching). Self-tiles get no menu — no volume to control, and you can
// stop your own share from the control bar.
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return;
e.preventDefault();
if (tile.kind === 'user') {
// Self-tile gets the pin row only — there's no remote volume to control.
setShareMenu(null);
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
tileId: tile.id,
self: tile.self,
});
return;
}
// Self screen-tiles: same — pin row is still useful, share menu is not.
if (tile.self) return;
// Screen-tile. Check whether the participant has a published screen-share
// audio track so the menu can hide the volume/mute rows when there's
// nothing to control.
@@ -212,6 +283,12 @@ export function InCallPanel({ conversation }: Props) {
.map((s) => s.participantId)
.filter((id) => !dismissedShareUserIds.has(id)),
),
connectionQualities,
// Discord-style host crown — only show in group calls (>2 members).
// 1:1s have no concept of "host" so the crown stays hidden.
hostUserId: conversation.members.length > 2 ? callHostId : null,
// Pin marker — focusedId === tile.id means the user explicitly pinned.
pinnedTileId: focusedId,
});
// Duration keeps ticking during reconnecting so the user sees the call is
@@ -254,12 +331,15 @@ export function InCallPanel({ conversation }: Props) {
if (isScreenSharing) {
void stopScreenShare();
} else {
setPickerOpen(true);
// Discord-parity: open the configure-then-share modal instead of
// jumping straight into the OS picker. Modal calls
// startScreenShare with the chosen overrides on confirm.
setSharePickerOpen(true);
}
}}
onShareContextMenu={(e) => {
e.preventDefault();
if (!isScreenSharing) setPickerOpen(true);
if (!isScreenSharing) setSharePickerOpen(true);
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
@@ -275,6 +355,15 @@ export function InCallPanel({ conversation }: Props) {
soundboardOpen,
}
: {})}
// Live-Captions only when SpeechRecognition is available in the
// runtime — Firefox lacks it, would just show a dead button.
{...(isLiveCaptionsSupported()
? {
onToggleCaptions: () =>
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
captionsOn: captionsEnabled,
}
: {})}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -358,6 +447,25 @@ export function InCallPanel({ conversation }: Props) {
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
// We're already in fullscreen here — toggling focusedId is enough,
// no mode-switch needed.
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
// Self-tiles get no slider — the volume control would adjust the
// remote volume of someone the local user isn't hearing.
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
? {}
: {
onShowProfile: () =>
setProfileMenu({
userId: volumeMenu.userId,
x: volumeMenu.x,
y: volumeMenu.y,
}),
})}
onClose={() => setVolumeMenu(null)}
/>
)}
@@ -381,6 +489,28 @@ export function InCallPanel({ conversation }: Props) {
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
{profileMenu && (
<UserProfilePopover
userId={profileMenu.userId}
profile={
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
}
x={profileMenu.x}
y={profileMenu.y}
onClose={() => setProfileMenu(null)}
/>
)}
{sharePickerOpen && (
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
)}
{statsOverlayOpen && room && (
<CallStatsOverlay
room={room}
members={conversation.members}
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</>
);
}
@@ -444,6 +574,14 @@ export function InCallPanel({ conversation }: Props) {
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
// Discord-style toggle: clicking the already-focused tile
// collapses back to grid; clicking another tile swaps focus;
// clicking any tile in grid mode focuses it.
if (callMode === 'focus' && focusedId === id) {
setFocusedId(null);
setCallMode('grid');
return;
}
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
@@ -455,24 +593,45 @@ export function InCallPanel({ conversation }: Props) {
<PttHint />
<ScreenSourcePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
const isPinned = focusedId === volumeMenu.tileId;
setFocusedId(isPinned ? null : volumeMenu.tileId);
if (!isPinned && callMode === 'grid') setCallMode('focus');
}}
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
? {}
: {
onShowProfile: () =>
setProfileMenu({
userId: volumeMenu.userId,
x: volumeMenu.x,
y: volumeMenu.y,
}),
})}
onClose={() => setVolumeMenu(null)}
/>
)}
{profileMenu && (
<UserProfilePopover
userId={profileMenu.userId}
profile={
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
}
x={profileMenu.x}
y={profileMenu.y}
onClose={() => setProfileMenu(null)}
/>
)}
{shareMenu && (
<ScreenShareContextMenu
userId={shareMenu.userId}
@@ -495,6 +654,20 @@ export function InCallPanel({ conversation }: Props) {
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
{sharePickerOpen && (
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
)}
{statsOverlayOpen && room && (
<CallStatsOverlay
room={room}
members={conversation.members}
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section>
);
}
@@ -528,6 +701,13 @@ interface BuildArgs {
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
connectionQualities: Record<string, ConnectionQuality>;
/** Host userId (call initiator). Null in re-joined calls and 1:1 calls.
* Used to mark exactly one user's tile with the crown. */
hostUserId: string | null;
/** Tile id (e.g. "user:abc") the user has pinned via right-click. Null
* while no pin is active or while auto-tracking the active speaker. */
pinnedTileId: string | null;
}
function cameraTrackFor(
@@ -554,6 +734,9 @@ function buildTiles({
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
connectionQualities,
hostUserId,
pinnedTileId,
}: BuildArgs): Tile[] {
const remoteById = new Map<string, RemoteParticipant>();
for (const p of remoteParticipants) {
@@ -567,6 +750,7 @@ function buildTiles({
// device / permission denied) the toggle stays false while the real
// state is "muted". Combine both so the badge always matches reality.
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined;
out.push({
kind: 'user',
id: 'user:' + myId,
@@ -580,6 +764,10 @@ function buildTiles({
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
...(myQuality ? { connectionQuality: myQuality } : {}),
isHost: hostUserId === myId,
pinned: pinnedTileId === 'user:' + myId,
streaming: isScreenSharing,
});
if (isScreenSharing) {
out.push({
@@ -595,6 +783,9 @@ function buildTiles({
videoTrack: null,
sharing: true,
remoteSharing: false,
isHost: false,
pinned: pinnedTileId === 'screen:' + myId,
streaming: false,
});
}
}
@@ -602,6 +793,7 @@ function buildTiles({
if (m.userId === myId) continue;
const rp = remoteById.get(m.userId);
if (!rp) continue;
const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined;
out.push({
kind: 'user',
id: 'user:' + m.userId,
@@ -619,6 +811,10 @@ function buildTiles({
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
...(peerQuality ? { connectionQuality: peerQuality } : {}),
isHost: hostUserId === m.userId,
pinned: pinnedTileId === 'user:' + m.userId,
streaming: remoteSharerIds.has(m.userId),
});
if (remoteSharerIds.has(m.userId)) {
out.push({
@@ -634,6 +830,9 @@ function buildTiles({
videoTrack: null,
sharing: false,
remoteSharing: true,
isHost: false,
pinned: pinnedTileId === 'screen:' + m.userId,
streaming: false,
});
}
}
@@ -757,25 +956,7 @@ function TileRender({
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
// Local screenshare preview — we don't mirror a copy of the outgoing
// stream. Render a labelled placeholder card so the user knows their
// share is live without double-encoding the stream.
return (
<div
onClick={onClick}
className={
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
(onClick ? 'cursor-pointer' : '')
}
>
<div className="flex flex-col items-center gap-2 p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wider">
Live
</div>
<div className="text-sm">{tile.displayName}</div>
</div>
</div>
);
return <LocalSharePreview displayName={tile.displayName} {...(onClick ? { onClick } : {})} />;
}
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
@@ -806,6 +987,10 @@ function TileRender({
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
isHost={tile.isHost}
pinned={tile.pinned}
streaming={tile.streaming}
{...(tile.connectionQuality ? { connectionQuality: tile.connectionQuality } : {})}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
@@ -837,6 +1022,9 @@ function CallStage({
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})}
/>
</div>
{others.length > 0 && (
@@ -897,12 +1085,14 @@ function FocusedTile({
activeSpeakers,
remoteScreenShares,
conversationMembers,
onContextMenu,
}: {
tile: Tile;
e2ee: boolean;
activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void;
}) {
return (
<div className="h-full [&>div]:h-full">
@@ -913,6 +1103,7 @@ function FocusedTile({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
{...(onContextMenu ? { onContextMenu } : {})}
/>
</div>
);
@@ -1268,6 +1459,130 @@ function MicErrorBanner({
);
}
// Preview card for the local sharer's screen-tile. Shows a "Live" label and,
// when the share carries a ScreenShareAudio track, a small overlay button to
// mute that outgoing audio publication without tearing down the share. The
// mute state lives in CallContext so the toggle survives re-renders and
// resets cleanly when the share ends.
function LocalSharePreview({
displayName,
onClick,
}: {
displayName: string;
onClick?: () => void;
}) {
const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall();
const [hasShareAudio, setHasShareAudio] = useState(false);
useEffect(() => {
if (!room) {
setHasShareAudio(false);
return;
}
const compute = () => {
const lp = room.localParticipant;
let found = false;
for (const pub of lp.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio) {
found = true;
break;
}
}
setHasShareAudio(found);
};
compute();
const onPub = () => compute();
room.on(RoomEvent.LocalTrackPublished, onPub);
room.on(RoomEvent.LocalTrackUnpublished, onPub);
return () => {
room.off(RoomEvent.LocalTrackPublished, onPub);
room.off(RoomEvent.LocalTrackUnpublished, onPub);
};
}, [room]);
return (
<div
onClick={onClick}
className={
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
(onClick ? 'cursor-pointer' : '')
}
>
<div className="flex flex-col items-center gap-2 p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wider">Live</div>
<div className="text-sm">{displayName}</div>
</div>
{hasShareAudio && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleOutgoingShareAudioMute();
}}
aria-pressed={outgoingShareAudioMuted}
aria-label={
outgoingShareAudioMuted
? 'Sound der Übertragung wieder senden'
: 'Sound der Übertragung stumm'
}
title={
outgoingShareAudioMuted
? 'Sound der Übertragung wieder senden'
: 'Sound der Übertragung stumm'
}
className={
'absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border backdrop-blur-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(outgoingShareAudioMuted
? 'border-rose-500/60 bg-rose-500/20 text-rose-600 hover:bg-rose-500/30 dark:text-rose-300'
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-200')
}
>
{outgoingShareAudioMuted ? <SpeakerOffIconInline /> : <SpeakerOnIconInline />}
</button>
)}
</div>
);
}
function SpeakerOnIconInline() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);
}
function SpeakerOffIconInline() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
);
}
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
function MicOffIconInline() {
return (