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:
@@ -0,0 +1,183 @@
|
||||
import { ConnectionQuality, type Room } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type ParticipantStats,
|
||||
makeSampleCache,
|
||||
sampleStats,
|
||||
} from '../lib/callStats';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
members: { userId: string; profile?: { displayName?: string | null } | null }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
const QUALITY_LABEL: Record<ConnectionQuality, string> = {
|
||||
[ConnectionQuality.Excellent]: 'Sehr gut',
|
||||
[ConnectionQuality.Good]: 'Gut',
|
||||
[ConnectionQuality.Poor]: 'Schlecht',
|
||||
[ConnectionQuality.Lost]: 'Verloren',
|
||||
[ConnectionQuality.Unknown]: 'Unbekannt',
|
||||
};
|
||||
|
||||
const QUALITY_TONE: Record<ConnectionQuality, string> = {
|
||||
[ConnectionQuality.Excellent]: 'text-emerald-400',
|
||||
[ConnectionQuality.Good]: 'text-emerald-400',
|
||||
[ConnectionQuality.Poor]: 'text-amber-400',
|
||||
[ConnectionQuality.Lost]: 'text-rose-400',
|
||||
[ConnectionQuality.Unknown]: 'text-fg-muted',
|
||||
};
|
||||
|
||||
/**
|
||||
* Discord-style debug overlay (Ctrl+Shift+S). Polls WebRTC getStats() every
|
||||
* 1.5s and renders bitrate/loss/jitter/RTT per participant + audio + video.
|
||||
* Pin to corner; designed to stay readable on top of any video stream.
|
||||
*/
|
||||
export function CallStatsOverlay({ room, members, onClose }: Props) {
|
||||
const { connectionQualities } = useCall();
|
||||
const [stats, setStats] = useState<ParticipantStats[]>([]);
|
||||
const cacheRef = useRef(makeSampleCache());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const next = await sampleStats(room, cacheRef.current);
|
||||
if (!cancelled) setStats(next);
|
||||
} catch {
|
||||
/* ignore — getStats can throw mid-reconnect */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => {
|
||||
void tick();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
const nameFor = (identity: string): string => {
|
||||
const m = members.find((mm) => mm.userId === identity);
|
||||
return m?.profile?.displayName ?? identity.slice(0, 8);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Call-Statistiken"
|
||||
className="fixed right-4 top-4 z-[110] w-[320px] overflow-hidden rounded-xl border border-line bg-black/85 text-[11px] text-white shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
Debug
|
||||
</div>
|
||||
<div className="font-display text-sm font-bold">Call-Stats</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Stats schließen"
|
||||
className="cursor-pointer rounded-md p-1 text-white/70 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="max-h-[60vh] overflow-y-auto px-3 py-2 space-y-2 font-mono">
|
||||
{stats.map((p) => {
|
||||
const cq = connectionQualities[p.identity] ?? ConnectionQuality.Unknown;
|
||||
return (
|
||||
<div
|
||||
key={p.identity}
|
||||
className="rounded-md border border-white/10 bg-white/5 p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold">
|
||||
{nameFor(p.identity)}
|
||||
{p.isLocal && <span className="ml-1 text-white/50">(du)</span>}
|
||||
</span>
|
||||
<span className={'text-[10px] tabular-nums ' + QUALITY_TONE[cq]}>
|
||||
{QUALITY_LABEL[cq]}
|
||||
</span>
|
||||
</div>
|
||||
{p.isLocal ? (
|
||||
<Row
|
||||
label="Audio out"
|
||||
values={[fmtKbps(p.audio.audioOutKbps)]}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Audio in"
|
||||
values={[
|
||||
fmtKbps(p.audio.audioInKbps),
|
||||
fmtPct(p.audio.packetLossPct, 'loss'),
|
||||
fmtMs(p.audio.jitterMs, 'jit'),
|
||||
fmtMs(p.audio.rttMs, 'rtt'),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{(p.video.videoInKbps ?? p.video.videoOutKbps ?? 0) > 0 && (
|
||||
p.isLocal ? (
|
||||
<Row
|
||||
label="Video out"
|
||||
values={[fmtKbps(p.video.videoOutKbps)]}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Video in"
|
||||
values={[
|
||||
fmtKbps(p.video.videoInKbps),
|
||||
fmtPct(p.video.packetLossPct, 'loss'),
|
||||
fmtMs(p.video.jitterMs, 'jit'),
|
||||
fmtMs(p.video.rttMs, 'rtt'),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.length === 0 && (
|
||||
<p className="px-1 py-1.5 text-white/60">Sammle Stats …</p>
|
||||
)}
|
||||
</div>
|
||||
<footer className="border-t border-white/10 px-3 py-1.5 text-[10px] text-white/50">
|
||||
Aktualisiert alle 1,5 s · Strg+Shift+S zum Schließen
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, values }: { label: string; values: (string | null)[] }) {
|
||||
const visible = values.filter(Boolean) as string[];
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="w-[58px] shrink-0 text-white/50">{label}</span>
|
||||
<span className="flex flex-wrap gap-x-2 tabular-nums text-white/90">
|
||||
{visible.length === 0 ? '—' : visible.map((v, i) => <span key={i}>{v}</span>)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtKbps(v: number | undefined): string | null {
|
||||
if (v === undefined) return null;
|
||||
return v.toLocaleString('de') + ' kbps';
|
||||
}
|
||||
|
||||
function fmtPct(v: number | undefined, prefix: string): string | null {
|
||||
if (v === undefined) return null;
|
||||
return prefix + ' ' + v.toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function fmtMs(v: number | undefined, prefix: string): string | null {
|
||||
if (v === undefined) return null;
|
||||
return prefix + ' ' + v + 'ms';
|
||||
}
|
||||
Reference in New Issue
Block a user