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