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
+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>
);
}