feat(call): Discord-style in-call features (group D)

- PiP widget now shows a live mm:ss / hh:mm:ss duration instead of the
  generic "tippe zum Öffnen" while a call is in progress.
- New ParticipantsPopover — portal-mounted, fixed bottom-right, lists
  everyone in the call with avatar, speaking ring, mute/deafen badges
  and a per-peer volume slider. Wired to CallControls via the users
  button (data-participants-trigger skips the outside-click dismiss
  while toggling).
- Non-terminal MicErrorBanner: getUserMedia failures inside joinRoom
  used to be silently swallowed by a console.error; they now set a
  categorized message (NotAllowedError / NotFoundError / NotReadableError)
  on CallContext.micError, render as a rose banner in both docked and
  fullscreen modes, and offer a Retry button that calls the extracted
  setupMicPipeline without rejoining the room.
- Screen-share toggle is now 1-click using the last-saved preset +
  displaySurface. Right-click on the share button still opens the
  quality dialog for users who want to adjust before starting.
- Noise-suppression toggle in the control bar (SparklesIcon). Flipping
  it updates audioSettings and hot-swaps the mic track via
  setAudioInputDevice so the new constraint takes effect without a
  rejoin. Mirrors Discord's Krisp button placement.
- Fullscreen auto-speaker now tracks "most recently started speaking"
  instead of "exactly one currently speaking", so two people briefly
  overlapping doesn't kick the focus back to grid. Tracked in a
  prevSpeakers ref against each activeSpeakers diff.
- Fullscreen controls auto-hide after 5s of mouse idle; mousemove /
  touchstart bring them back. Pinned visible while any popover
  (soundboard / volume-menu / participants / mic-error banner) is open
  so users can interact without the chrome fading mid-click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 20:02:46 +02:00
parent 1c67a5c97f
commit bc8a7c5a32
5 changed files with 613 additions and 58 deletions
@@ -9,6 +9,7 @@ import {
MonitorStopIcon,
MusicIcon,
PhoneOffIcon,
SparklesIcon,
UsersIcon,
VideoIcon,
} from './icons';
@@ -18,15 +19,22 @@ interface Props {
sharing: boolean;
video: boolean;
deafened: boolean;
noiseSuppression?: boolean;
onToggleMute: () => void;
onToggleShare: () => void;
/** Right-click on the share button opens the quality picker dialog while
* left-click just starts with last-used settings. Optional so pages that
* don't need the advanced path (mobile, etc.) can skip it. */
onShareContextMenu?: (e: React.MouseEvent) => void;
onToggleVideo?: () => void;
onToggleDeafen: () => void;
onToggleNoiseSuppression?: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
@@ -40,14 +48,18 @@ export function CallControls({
sharing,
video,
deafened,
noiseSuppression,
onToggleMute,
onToggleShare,
onShareContextMenu,
onToggleVideo,
onToggleDeafen,
onToggleNoiseSuppression,
onHangup,
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
participantsOpen = false,
compact = false,
glass = false,
disabledMedia = false,
@@ -111,6 +123,7 @@ export function CallControls({
active={sharing}
activeTone="accent"
onClick={onToggleShare}
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
disabled={disabledMedia}
glass={glass}
className={btnSize}
@@ -121,6 +134,26 @@ export function CallControls({
<MonitorShareIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleNoiseSuppression && (
<CallButton
label={
noiseSuppression
? t('app:call.ns_off', {
defaultValue: 'Rauschunterdrückung aus',
})
: t('app:call.ns_on', {
defaultValue: 'Rauschunterdrückung an',
})
}
active={noiseSuppression ?? false}
activeTone="accent"
onClick={onToggleNoiseSuppression}
glass={glass}
className={btnSize}
>
<SparklesIcon className="h-5 w-5" />
</CallButton>
)}
{onToggleSoundboard && (
<CallButton
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
@@ -137,6 +170,9 @@ export function CallControls({
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
onClick={onOpenParticipants}
active={participantsOpen}
activeTone="accent"
dataTrigger="participants"
glass={glass}
className={btnSize}
>
@@ -159,24 +195,30 @@ export function CallControls({
interface CallButtonProps {
label: string;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
disabled?: boolean;
active?: boolean;
activeTone?: 'accent' | 'danger';
tone?: 'default' | 'danger';
glass?: boolean;
className?: string;
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
* when the user is toggling their own trigger. */
dataTrigger?: string;
children: React.ReactNode;
}
function CallButton({
label,
onClick,
onContextMenu,
disabled,
active,
activeTone = 'accent',
tone = 'default',
glass = false,
className = '',
dataTrigger,
children,
}: CallButtonProps) {
const base =
@@ -200,11 +242,13 @@ function CallButton({
<button
type="button"
onClick={onClick}
onContextMenu={onContextMenu}
disabled={disabled}
aria-label={label}
aria-pressed={active}
title={label}
className={`${base} ${toneClass} ${className}`}
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
>
{children}
</button>
+34 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -173,6 +173,13 @@ function PipCall() {
: conv?.peer?.displayName ?? '—';
const participantCount = 1 + remoteParticipants.length;
const someoneSharing = remoteScreenShares.length > 0;
// Duration ticks while connected or reconnecting (LiveKit holds the room
// across reconnects, so the timer shouldn't reset on a wobble). Absent
// on outgoing/connecting where the call hasn't started yet.
const startedAt =
state.kind === 'connected' || state.kind === 'reconnecting'
? state.startedAt
: null;
return (
<div
@@ -197,7 +204,11 @@ function PipCall() {
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
/>
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
<span className="tabular-nums">
{startedAt
? <PipDuration startedAt={startedAt} />
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
</span>
</p>
</div>
<button
@@ -214,3 +225,24 @@ function PipCall() {
</div>
);
}
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
// deliberately — the two widgets have different typography + tabular
// contexts, and extracting a shared component would be heavier than the
// 8-line countup it replaces.
function PipDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
const total = Math.max(
0,
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
);
const hh = Math.floor(total / 3600);
const mm = Math.floor((total % 3600) / 60);
const ss = total % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
}
+221 -12
View File
@@ -1,11 +1,16 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type CallMode, useCall } from '../context/CallContext';
import {
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
getPttSettings,
type PttSettings,
@@ -15,6 +20,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer';
@@ -74,15 +80,39 @@ export function InCallPanel({ conversation }: Props) {
hangup,
setCallMode,
setFocusedId,
setAudioInputDevice,
micError,
clearMicError,
retryMic,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
>(null);
const [noiseSuppression, setNoiseSuppression] = useState<boolean>(
() => getAudioSettings().noiseSuppression,
);
useEffect(() => subscribeAudioSettings((s) => setNoiseSuppression(s.noiseSuppression)), []);
// Active-speaker auto-focus uses "who most recently started speaking"
// rather than "exactly one speaker" — matches Discord more closely and
// handles the case where two people talk briefly without the focus
// collapsing to nobody.
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const id of activeSpeakers) {
if (!prevActiveSpeakersRef.current.has(id)) {
setLastStartedSpeakerId(id);
break;
}
}
prevActiveSpeakersRef.current = new Set(activeSpeakers);
}, [activeSpeakers]);
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return;
@@ -150,41 +180,86 @@ export function InCallPanel({ conversation }: Props) {
sharing={isScreenSharing}
video={isCameraEnabled}
deafened={isDeafened}
noiseSuppression={noiseSuppression}
onToggleMute={toggleMute}
// 1-click share uses last-saved preset + displaySurface. Right-click
// opens the quality picker for users who want to change settings
// before starting — matches Discord's "Go Live" vs quick-share split.
onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
setShareDialogOpen(true);
void startScreenShare();
}
}}
onShareContextMenu={(e) => {
e.preventDefault();
if (!isScreenSharing) setShareDialogOpen(true);
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
// Hot-swap the mic track with new constraints by replaying the
// input-device switch with the same id. setAudioInputDevice re-reads
// audioSettings, so flipping noiseSuppression first is enough.
onToggleNoiseSuppression={() => {
const prev = getAudioSettings().noiseSuppression;
updateAudioSettings({ noiseSuppression: !prev });
void setAudioInputDevice(getAudioSettings().inputDeviceId);
}}
onOpenParticipants={() => setParticipantsOpen((v) => !v)}
participantsOpen={participantsOpen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'}
disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
/>
);
// Only participant-tiles feed the popover (screen-share tiles aren't
// people). Own row is always first, rest follows conversation order.
const participantRows: ParticipantRow[] = tiles
.filter((t) => t.kind === 'user')
.map((t) => ({
userId: t.userId,
displayName: t.displayName,
avatarUrl: t.avatarUrl,
self: t.self,
muted: t.muted,
deafened: t.deafened,
}));
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
// someone is sharing a screen, OR exactly one non-self speaker is talking
// (auto-promote). Without that we show an even grid of all participants
// (Discord default). Clicking a tile switches to the big-speaker layout.
const speakingNonSelf = tiles.filter(
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
);
// someone is sharing a screen, OR the person who most recently started
// speaking (tracked in lastStartedSpeakerId). "Most recent speaker"
// beats "exactly one currently speaking" because two people briefly
// overlapping shouldn't kick us out of auto-focus.
const autoSpeaker =
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
? speakingNonSelf[0]
focusedId === null && screenTile === undefined && lastStartedSpeakerId !== null
? tiles.find(
(t) =>
t.kind === 'user' &&
!t.self &&
t.userId === lastStartedSpeakerId,
)
: undefined;
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<>
{micError && (
<div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
<div className="pointer-events-auto max-w-[520px] w-full">
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
</div>
</div>
)}
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
@@ -199,6 +274,15 @@ export function InCallPanel({ conversation }: Props) {
}}
onTileContextMenu={openVolumeMenu}
controls={controls}
// Any active popover / menu / banner pins the controls so the user
// can interact with them without the chrome fading out under their
// cursor while they're mid-action.
keepControlsVisible={
soundboardOpen ||
participantsOpen ||
volumeMenu !== null ||
micError !== null
}
/>
{volumeMenu && (
<ParticipantVolumeMenu
@@ -213,6 +297,12 @@ export function InCallPanel({ conversation }: Props) {
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
</>
);
}
@@ -259,6 +349,14 @@ export function InCallPanel({ conversation }: Props) {
<ModeToggles mode={callMode} onChange={setCallMode} />
</div>
{micError && (
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
)}
<CallStage
tiles={tiles}
speaker={speaker}
@@ -301,6 +399,13 @@ export function InCallPanel({ conversation }: Props) {
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
</section>
);
}
@@ -767,8 +872,13 @@ interface FullscreenProps {
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode;
/** When true, controls stay visible regardless of mouse idle (used while
* a popover / menu / error banner is open). */
keepControlsVisible?: boolean;
}
const CONTROLS_IDLE_MS = 5_000;
function FullscreenCall({
tiles,
speaker,
@@ -780,13 +890,43 @@ function FullscreenCall({
onFocusTile,
onTileContextMenu,
controls,
keepControlsVisible = false,
}: FullscreenProps) {
const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0);
// Discord-style auto-hide: controls fade out after 5s of mouse idle in
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
// popover opening via keepControlsVisible) brings them back immediately.
const [controlsVisible, setControlsVisible] = useState(true);
useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
}, []);
useEffect(() => {
if (keepControlsVisible) {
setControlsVisible(true);
return;
}
let timer: number | null = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
const reset = () => {
setControlsVisible(true);
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
};
window.addEventListener('mousemove', reset);
window.addEventListener('touchstart', reset);
return () => {
if (timer !== null) window.clearTimeout(timer);
window.removeEventListener('mousemove', reset);
window.removeEventListener('touchstart', reset);
};
}, [keepControlsVisible]);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
@@ -911,7 +1051,14 @@ function FullscreenCall({
</div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
<div
className={
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
(controlsVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0')
}
>
{controls}
</div>
</div>
@@ -931,3 +1078,65 @@ function PttHint() {
</p>
);
}
// Non-terminal mic error banner. Shown inside the call panel when mic setup
// fails — the call itself stays alive, the user just can't be heard. Retry
// invokes the pipeline setup again with the current audioSettings so a
// permission granted in OS settings mid-call works without rejoin.
function MicErrorBanner({
message,
onRetry,
onDismiss,
}: {
message: string;
onRetry: () => void;
onDismiss: () => void;
}) {
return (
<div
role="alert"
className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
>
<MicOffIconInline />
<p className="flex-1 leading-relaxed">{message}</p>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
>
Erneut versuchen
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Schließen"
className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
function MicOffIconInline() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
<line x1="12" y1="19" x2="12" y2="23" />
</svg>
);
}
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
import {
AvatarColorKey,
colorKeyFor,
} from './CallParticipantTile';
import { HeadphonesOffIcon, MicOffIcon, UsersIcon, XIcon } from './icons';
// Rows the popover knows how to render. Subset of InCallPanel's Tile so this
// component can be reused without the screen-share / video fields.
export interface ParticipantRow {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
deafened: boolean;
}
interface Props {
open: boolean;
rows: ParticipantRow[];
/** Set of userIds currently above the speaking-threshold. */
activeSpeakers: Set<string>;
onClose: () => void;
}
const AVATAR_TONES: Record<AvatarColorKey, string> = {
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
};
// Call-scoped participant list. Portal-mounted + fixed-positioned so it
// floats above whichever call layout the user is in (docked, focus, or
// fullscreen cinema). Mirrors the ParticipantVolumeMenu pattern for
// close-on-outside / close-on-Esc behaviour so both feel consistent.
export function ParticipantsPopover({ open, rows, activeSpeakers, onClose }: Props) {
const { t } = useTranslation(['app']);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-participants-popover]')) return;
// Clicks on the triggering button also bubble here; the button itself
// handles toggle, so we only close on genuine outside clicks. The
// trigger uses `data-participants-trigger` — ignore those.
if (target?.closest('[data-participants-trigger]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
data-participants-popover
role="dialog"
aria-label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
className="fixed bottom-20 right-5 z-[70] flex max-h-[60vh] w-[300px] flex-col overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-xl backdrop-blur-md"
>
<header className="flex items-center justify-between gap-2 border-b border-line px-3.5 py-2.5">
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
<UsersIcon className="h-4 w-4 text-fg-muted" />
<span>
{t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length}
</span>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">
{rows.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-fg-muted">
{t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })}
</p>
) : (
<ul className="flex flex-col gap-1">
{rows.map((row) => (
<li key={row.userId}>
<Row row={row} speaking={activeSpeakers.has(row.userId)} />
</li>
))}
</ul>
)}
</div>
</div>,
document.body,
);
}
function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
const key = colorKeyFor(row.userId);
const tone = AVATAR_TONES[key];
const letter = row.displayName.trim().charAt(0).toUpperCase() || '?';
const [volume, setVolume] = useState<number>(() =>
row.self ? 1 : getParticipantVolume(row.userId),
);
useEffect(() => {
if (row.self) return;
return subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(row.userId));
});
}, [row.self, row.userId]);
return (
<div className="flex flex-col gap-1.5 rounded-lg px-2 py-1.5 hover:bg-surface-3/60">
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
{row.avatarUrl ? (
<img
src={row.avatarUrl}
alt=""
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<span
className={
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ' +
tone
}
>
{letter}
</span>
)}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute -inset-0.5 rounded-full border-2 border-emerald-500 dark:border-emerald-400"
/>
)}
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
{row.displayName}
{row.self && <span className="ml-1 text-fg-muted">(du)</span>}
</div>
<div className="flex shrink-0 items-center gap-1">
{row.muted && (
<span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" />
</span>
)}
{row.deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
{!row.self && (
<div className="flex items-center gap-2 pl-10 text-[11px] text-fg-muted">
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(row.userId, v);
}}
aria-label={'Lautstärke ' + row.displayName}
className="flex-1 accent-accent"
/>
<span className="w-8 text-right tabular-nums">
{Math.round(volume * 100)}%
</span>
</div>
)}
</div>
);
}
+108 -44
View File
@@ -188,6 +188,14 @@ interface CallContextValue {
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
// currently-attached remote-audio elements.
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
/** Non-fatal mic-setup error message (e.g. permission denied). Surfaced in
* the in-call panel as a retry-banner so the user can stay in the call and
* hear others while sorting out their mic. Null when the mic is working. */
micError: string | null;
clearMicError: () => void;
/** Retry mic acquisition using the current audioSettings. Safe to call
* multiple times; no-op if there's no active room. */
retryMic: () => Promise<void>;
}
const CallContext = createContext<CallContextValue | null>(null);
@@ -219,6 +227,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [micError, setMicError] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
@@ -327,6 +336,73 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, []);
// Extracted so retryMic can call it after the user grants permission from
// OS settings. Reads audioSettings fresh every call so NS/input-device
// flips take effect without rejoining the room.
const setupMicPipeline = useCallback(async (r: Room): Promise<void> => {
const audioPrefs = getAudioSettings();
const aParams = getAudioQualityParams(audioPrefs.quality);
const inputId = audioPrefs.inputDeviceId;
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
try {
const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(inputId ? { deviceId: { ideal: inputId } } : {}),
},
video: false,
});
const rawTrack = rawStream.getAudioTracks()[0];
if (!rawTrack) throw new Error('no audio track from getUserMedia');
// Retry path: dispose any prior pipeline so we don't leak contexts.
const prev = pipelineRef.current;
if (prev) {
try {
prev.destroy();
} catch {
/* ignore */
}
pipelineRef.current = null;
}
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
try {
const prefs = await getSoundboardPrefs();
pipeline.setSoundboardGain(prefs.masterGain);
pipeline.setMonitorGain(prefs.monitorGain);
} catch (err: unknown) {
console.warn('getSoundboardPrefs failed', err);
}
await r.localParticipant.publishTrack(pipeline.outputTrack, {
source: Track.Source.Microphone,
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
});
setMicError(null);
} catch (err: unknown) {
// Categorise the error so the banner can be specific. DOMException
// names are stable across Chrome/Firefox/WebKit.
const name = (err as { name?: string }).name;
let msg = 'Mikrofon konnte nicht gestartet werden.';
if (name === 'NotAllowedError' || name === 'SecurityError') {
msg =
'Mikrofon-Zugriff blockiert. Erlaube den Zugriff in den Systemeinstellungen.';
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
msg = 'Kein Mikrofon gefunden. Schließe eines an und versuche es erneut.';
} else if (name === 'NotReadableError') {
msg =
'Mikrofon ist von einer anderen App belegt. Schließe sie und versuche es erneut.';
}
setMicError(msg);
console.error('mic pipeline setup failed', err);
}
}, []);
const disconnectRoom = useCallback(async () => {
const r = roomRef.current;
if (r) {
@@ -702,50 +778,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
} else {
setIsE2EEActive(false);
}
try {
const audioPrefs = getAudioSettings();
const inputId = audioPrefs.inputDeviceId;
// Noise suppression: user-preference wins over the quality preset so
// hifi-mode users can still enable NS when they need to cut room hum.
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
// Grab the raw mic ourselves instead of going through LiveKit's
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
// through createMicPipeline, which mixes in soundboard buffers and
// exposes a single output track we hand to publishTrack. Mute / PTT
// are gain-based from here on, never track.enabled or device stop.
const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(inputId ? { deviceId: { ideal: inputId } } : {}),
},
video: false,
});
const rawTrack = rawStream.getAudioTracks()[0];
if (!rawTrack) throw new Error('no audio track from getUserMedia');
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
// Pull the user's last-saved soundboard gains onto the live pipeline
// before the first sound ever plays so nothing blasts at 100%.
try {
const prefs = await getSoundboardPrefs();
pipeline.setSoundboardGain(prefs.masterGain);
pipeline.setMonitorGain(prefs.monitorGain);
} catch (err: unknown) {
console.warn('getSoundboardPrefs failed', err);
}
await r.localParticipant.publishTrack(pipeline.outputTrack, {
source: Track.Source.Microphone,
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
});
} catch (micErr: unknown) {
console.error('mic pipeline setup failed', micErr);
}
// Mic pipeline setup + publish. Runs asynchronously; on failure sets
// `micError` so the InCallPanel renders a retry banner without tearing
// down the whole call — the user can still hear peers meanwhile.
await setupMicPipeline(r);
if (mediaKind === 'video') {
try {
await r.localParticipant.setCameraEnabled(true);
@@ -802,6 +838,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
emitCallEvent,
disconnectRoom,
myId,
setupMicPipeline,
],
);
@@ -1507,6 +1544,27 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, []);
const clearMicError = useCallback(() => {
setMicError(null);
}, []);
const retryMic = useCallback(async () => {
const r = roomRef.current;
if (!r) {
setMicError(null);
return;
}
await setupMicPipeline(r);
}, [setupMicPipeline]);
// Clear the stale mic-error state whenever a call fully tears down so the
// next join starts with a clean slate.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setMicError(null);
}
}, [state.kind]);
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
@@ -1611,6 +1669,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
micError,
clearMicError,
retryMic,
}),
[
state,
@@ -1648,6 +1709,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
micError,
clearMicError,
retryMic,
],
);