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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user