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:
@@ -1,6 +1,20 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||
import {
|
||||
CrownIcon,
|
||||
HeadphonesOffIcon,
|
||||
LockIcon,
|
||||
MicOffIcon,
|
||||
PinIcon,
|
||||
WifiLowIcon,
|
||||
WifiOffIcon,
|
||||
} from './icons';
|
||||
|
||||
/** Discord-style connection-quality badge state. Matches LiveKit's
|
||||
* ConnectionQuality enum values so consumers can pass the value through
|
||||
* without conversion. `unknown` and `excellent`/`good` render nothing — the
|
||||
* badge only surfaces when the user actually has degraded media. */
|
||||
export type TileConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
@@ -43,16 +57,29 @@ export interface ParticipantTileProps {
|
||||
avatarUrl: string | null;
|
||||
me: boolean;
|
||||
muted: boolean;
|
||||
/** Local-only: true when THIS user has muted everyone else via the deafen
|
||||
* toggle. Remote deafen state is not propagated, so only the own tile
|
||||
* ever carries a truthy value. */
|
||||
/** True when the participant has deafened. Propagated from peers via the
|
||||
* LiveKit data channel, so remote tiles surface the headphones-off badge
|
||||
* the same way the local tile does. */
|
||||
deafened: boolean;
|
||||
/** Discord-style: true if this participant is the call host (initiator).
|
||||
* Drives the crown badge. Hidden in 1:1 calls — set false there. */
|
||||
isHost?: boolean;
|
||||
/** True iff the user has pinned this tile via right-click → "Anpinnen".
|
||||
* Renders a small pin badge top-left and locks the focus mode to this
|
||||
* tile regardless of who is currently speaking. */
|
||||
pinned?: boolean;
|
||||
/** Discord-style "LIVE" pill — true iff this participant is currently
|
||||
* publishing a screen share. Shown on the user tile so observers know
|
||||
* the share-tile next to it belongs to this person. */
|
||||
streaming?: boolean;
|
||||
speaking: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||
* true. When null the tile falls back to the avatar placeholder. */
|
||||
videoTrack?: MediaStreamTrack | null;
|
||||
/** SFU-measured connection quality. Only `poor` and `lost` show a badge. */
|
||||
connectionQuality?: TileConnectionQuality;
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -68,6 +95,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
speaking,
|
||||
video,
|
||||
e2ee,
|
||||
connectionQuality,
|
||||
isHost = false,
|
||||
pinned = false,
|
||||
streaming = false,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
@@ -75,14 +106,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||
// tiles get the border (the avatar is hidden behind the stream so the
|
||||
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||
// pulse rendered inside AudioContent.
|
||||
const videoSpeaking = speaking && video;
|
||||
const borderClass = videoSpeaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
// Discord-style: full tile border switches to emerald the whole time the
|
||||
// user is speaking. Same treatment for audio + video tiles so the visual
|
||||
// is consistent regardless of camera state. The inner-glow span on top of
|
||||
// it adds a subtle inset highlight that reads even when the underlying
|
||||
// tile is bright (e.g. a video stream).
|
||||
const borderClass = speaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.35)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
: 'border-line hover:border-accent';
|
||||
@@ -92,7 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
borderClass +
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
@@ -104,19 +134,55 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||
it above the video element. */}
|
||||
{videoSpeaking && (
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[12px] shadow-[inset_0_0_18px_rgba(34,197,94,0.45)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<ConnectionQualityBadge quality={connectionQuality} small={small} />
|
||||
|
||||
{pinned && (
|
||||
<span
|
||||
aria-label="Angepinnt"
|
||||
title="Angepinnt"
|
||||
className={
|
||||
'pointer-events-none absolute left-2 top-2 z-20 flex items-center justify-center rounded-md bg-accent text-accent-fg ' +
|
||||
(small ? 'h-5 w-5' : 'h-6 w-6')
|
||||
}
|
||||
>
|
||||
<PinIcon className={small ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{streaming && (
|
||||
<span
|
||||
aria-label="Streamt gerade"
|
||||
title="Streamt gerade"
|
||||
className={
|
||||
'pointer-events-none absolute z-20 flex items-center gap-1 rounded-md bg-rose-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white shadow ' +
|
||||
(pinned ? 'left-9 top-2' : 'left-2 top-2')
|
||||
}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white motion-safe:animate-live-dot" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Reserve enough vertical space so the chip's height stays constant
|
||||
whether or not the muted/deafened badges are present. Without
|
||||
`min-h-5` on the right slot the chip grows by ~4px the moment a
|
||||
badge appears, which makes the dock visibly jiggle when somebody
|
||||
mutes mid-call. */}
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex min-h-[32px] items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||
{isHost && (
|
||||
<CrownIcon
|
||||
aria-label="Anrufgründer"
|
||||
className="h-3 w-3 shrink-0 text-amber-300"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{me ? ' (du)' : ''}
|
||||
@@ -131,7 +197,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex min-h-5 shrink-0 items-center gap-1">
|
||||
{muted && (
|
||||
<span
|
||||
aria-label="Mikro stumm"
|
||||
@@ -156,11 +222,45 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Discord-style: hidden when excellent/good/unknown, amber when poor,
|
||||
* red with a struck-through Wifi when lost. Anchored top-right above the
|
||||
* speaking-glow so it stays readable on a bright video stream. */
|
||||
function ConnectionQualityBadge({
|
||||
quality,
|
||||
small,
|
||||
}: {
|
||||
quality: TileConnectionQuality | undefined;
|
||||
small: boolean;
|
||||
}) {
|
||||
if (!quality || quality === 'excellent' || quality === 'good' || quality === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
const isLost = quality === 'lost';
|
||||
const Icon = isLost ? WifiOffIcon : WifiLowIcon;
|
||||
const label = isLost ? 'Verbindung verloren' : 'Schlechte Verbindung';
|
||||
const tone = isLost
|
||||
? 'bg-rose-500/85 text-white'
|
||||
: 'bg-amber-400/90 text-amber-950 dark:text-amber-950';
|
||||
const sz = small ? 'h-5 w-5' : 'h-6 w-6';
|
||||
const iconSz = small ? 'h-3 w-3' : 'h-3.5 w-3.5';
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'pointer-events-none absolute right-2 top-2 z-20 flex items-center justify-center rounded-md ' +
|
||||
sz + ' ' + tone
|
||||
}
|
||||
>
|
||||
<Icon className={iconSz} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioContent({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
speaking,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
const key = colorKeyFor(userId);
|
||||
@@ -174,12 +274,6 @@ function AudioContent({
|
||||
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||
}
|
||||
>
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||
/>
|
||||
)}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
|
||||
Reference in New Issue
Block a user