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,
|
MonitorStopIcon,
|
||||||
MusicIcon,
|
MusicIcon,
|
||||||
PhoneOffIcon,
|
PhoneOffIcon,
|
||||||
|
SparklesIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
@@ -18,15 +19,22 @@ interface Props {
|
|||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
video: boolean;
|
video: boolean;
|
||||||
deafened: boolean;
|
deafened: boolean;
|
||||||
|
noiseSuppression?: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
onToggleShare: () => 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;
|
onToggleVideo?: () => void;
|
||||||
onToggleDeafen: () => void;
|
onToggleDeafen: () => void;
|
||||||
|
onToggleNoiseSuppression?: () => void;
|
||||||
onHangup: () => void;
|
onHangup: () => void;
|
||||||
onOpenParticipants?: () => void;
|
onOpenParticipants?: () => void;
|
||||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
onToggleSoundboard?: () => void;
|
onToggleSoundboard?: () => void;
|
||||||
soundboardOpen?: boolean;
|
soundboardOpen?: boolean;
|
||||||
|
participantsOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||||
@@ -40,14 +48,18 @@ export function CallControls({
|
|||||||
sharing,
|
sharing,
|
||||||
video,
|
video,
|
||||||
deafened,
|
deafened,
|
||||||
|
noiseSuppression,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onToggleShare,
|
onToggleShare,
|
||||||
|
onShareContextMenu,
|
||||||
onToggleVideo,
|
onToggleVideo,
|
||||||
onToggleDeafen,
|
onToggleDeafen,
|
||||||
|
onToggleNoiseSuppression,
|
||||||
onHangup,
|
onHangup,
|
||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
onToggleSoundboard,
|
onToggleSoundboard,
|
||||||
soundboardOpen = false,
|
soundboardOpen = false,
|
||||||
|
participantsOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
disabledMedia = false,
|
disabledMedia = false,
|
||||||
@@ -111,6 +123,7 @@ export function CallControls({
|
|||||||
active={sharing}
|
active={sharing}
|
||||||
activeTone="accent"
|
activeTone="accent"
|
||||||
onClick={onToggleShare}
|
onClick={onToggleShare}
|
||||||
|
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
|
||||||
disabled={disabledMedia}
|
disabled={disabledMedia}
|
||||||
glass={glass}
|
glass={glass}
|
||||||
className={btnSize}
|
className={btnSize}
|
||||||
@@ -121,6 +134,26 @@ export function CallControls({
|
|||||||
<MonitorShareIcon className="h-5 w-5" />
|
<MonitorShareIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</CallButton>
|
</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 && (
|
{onToggleSoundboard && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
||||||
@@ -137,6 +170,9 @@ export function CallControls({
|
|||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
onClick={onOpenParticipants}
|
onClick={onOpenParticipants}
|
||||||
|
active={participantsOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
dataTrigger="participants"
|
||||||
glass={glass}
|
glass={glass}
|
||||||
className={btnSize}
|
className={btnSize}
|
||||||
>
|
>
|
||||||
@@ -159,24 +195,30 @@ export function CallControls({
|
|||||||
interface CallButtonProps {
|
interface CallButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
activeTone?: 'accent' | 'danger';
|
activeTone?: 'accent' | 'danger';
|
||||||
tone?: 'default' | 'danger';
|
tone?: 'default' | 'danger';
|
||||||
glass?: boolean;
|
glass?: boolean;
|
||||||
className?: string;
|
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;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallButton({
|
function CallButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
disabled,
|
disabled,
|
||||||
active,
|
active,
|
||||||
activeTone = 'accent',
|
activeTone = 'accent',
|
||||||
tone = 'default',
|
tone = 'default',
|
||||||
glass = false,
|
glass = false,
|
||||||
className = '',
|
className = '',
|
||||||
|
dataTrigger,
|
||||||
children,
|
children,
|
||||||
}: CallButtonProps) {
|
}: CallButtonProps) {
|
||||||
const base =
|
const base =
|
||||||
@@ -200,11 +242,13 @@ function CallButton({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
title={label}
|
title={label}
|
||||||
className={`${base} ${toneClass} ${className}`}
|
className={`${base} ${toneClass} ${className}`}
|
||||||
|
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -173,6 +173,13 @@ function PipCall() {
|
|||||||
: conv?.peer?.displayName ?? '—';
|
: conv?.peer?.displayName ?? '—';
|
||||||
const participantCount = 1 + remoteParticipants.length;
|
const participantCount = 1 + remoteParticipants.length;
|
||||||
const someoneSharing = remoteScreenShares.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +204,11 @@ function PipCall() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -214,3 +225,24 @@ function PipCall() {
|
|||||||
</div>
|
</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 { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||||
import { Track } 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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { type CallMode, useCall } from '../context/CallContext';
|
import { type CallMode, useCall } from '../context/CallContext';
|
||||||
|
import {
|
||||||
|
getAudioSettings,
|
||||||
|
subscribeAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
|
} from '../lib/audioSettings';
|
||||||
import {
|
import {
|
||||||
getPttSettings,
|
getPttSettings,
|
||||||
type PttSettings,
|
type PttSettings,
|
||||||
@@ -15,6 +20,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
|||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile } from './CallParticipantTile';
|
import { CallParticipantTile } from './CallParticipantTile';
|
||||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
|
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
@@ -74,15 +80,39 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
hangup,
|
hangup,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
micError,
|
||||||
|
clearMicError,
|
||||||
|
retryMic,
|
||||||
} = useCall();
|
} = useCall();
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
const activeSpeakers = useActiveSpeakers(room);
|
const activeSpeakers = useActiveSpeakers(room);
|
||||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||||
|
const [participantsOpen, setParticipantsOpen] = useState(false);
|
||||||
const [volumeMenu, setVolumeMenu] = useState<
|
const [volumeMenu, setVolumeMenu] = useState<
|
||||||
{ userId: string; displayName: string; x: number; y: number } | null
|
{ userId: string; displayName: string; x: number; y: number } | null
|
||||||
>(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) => {
|
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||||
if (tile.self) return;
|
if (tile.self) return;
|
||||||
@@ -150,41 +180,86 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
sharing={isScreenSharing}
|
sharing={isScreenSharing}
|
||||||
video={isCameraEnabled}
|
video={isCameraEnabled}
|
||||||
deafened={isDeafened}
|
deafened={isDeafened}
|
||||||
|
noiseSuppression={noiseSuppression}
|
||||||
onToggleMute={toggleMute}
|
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={() => {
|
onToggleShare={() => {
|
||||||
if (isScreenSharing) {
|
if (isScreenSharing) {
|
||||||
void stopScreenShare();
|
void stopScreenShare();
|
||||||
} else {
|
} else {
|
||||||
setShareDialogOpen(true);
|
void startScreenShare();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onShareContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isScreenSharing) setShareDialogOpen(true);
|
||||||
|
}}
|
||||||
onToggleVideo={() => void toggleCamera()}
|
onToggleVideo={() => void toggleCamera()}
|
||||||
onToggleDeafen={toggleDeafen}
|
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)}
|
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
||||||
soundboardOpen={soundboardOpen}
|
soundboardOpen={soundboardOpen}
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={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') {
|
if (callMode === 'fullscreen') {
|
||||||
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
||||||
// someone is sharing a screen, OR exactly one non-self speaker is talking
|
// someone is sharing a screen, OR the person who most recently started
|
||||||
// (auto-promote). Without that we show an even grid of all participants
|
// speaking (tracked in lastStartedSpeakerId). "Most recent speaker"
|
||||||
// (Discord default). Clicking a tile switches to the big-speaker layout.
|
// beats "exactly one currently speaking" because two people briefly
|
||||||
const speakingNonSelf = tiles.filter(
|
// overlapping shouldn't kick us out of auto-focus.
|
||||||
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
|
|
||||||
);
|
|
||||||
const autoSpeaker =
|
const autoSpeaker =
|
||||||
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
|
focusedId === null && screenTile === undefined && lastStartedSpeakerId !== null
|
||||||
? speakingNonSelf[0]
|
? tiles.find(
|
||||||
|
(t) =>
|
||||||
|
t.kind === 'user' &&
|
||||||
|
!t.self &&
|
||||||
|
t.userId === lastStartedSpeakerId,
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
||||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||||
return (
|
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
|
<FullscreenCall
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={effectiveSpeaker}
|
speaker={effectiveSpeaker}
|
||||||
@@ -199,6 +274,15 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
}}
|
}}
|
||||||
onTileContextMenu={openVolumeMenu}
|
onTileContextMenu={openVolumeMenu}
|
||||||
controls={controls}
|
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 && (
|
{volumeMenu && (
|
||||||
<ParticipantVolumeMenu
|
<ParticipantVolumeMenu
|
||||||
@@ -213,6 +297,12 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
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} />
|
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{micError && (
|
||||||
|
<MicErrorBanner
|
||||||
|
message={micError}
|
||||||
|
onRetry={() => void retryMic()}
|
||||||
|
onDismiss={clearMicError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CallStage
|
<CallStage
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={speaker}
|
speaker={speaker}
|
||||||
@@ -301,6 +399,13 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
open={soundboardOpen}
|
open={soundboardOpen}
|
||||||
onClose={() => setSoundboardOpen(false)}
|
onClose={() => setSoundboardOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ParticipantsPopover
|
||||||
|
open={participantsOpen}
|
||||||
|
rows={participantRows}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
onClose={() => setParticipantsOpen(false)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -767,8 +872,13 @@ interface FullscreenProps {
|
|||||||
onFocusTile: (id: string) => void;
|
onFocusTile: (id: string) => void;
|
||||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||||
controls: React.ReactNode;
|
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({
|
function FullscreenCall({
|
||||||
tiles,
|
tiles,
|
||||||
speaker,
|
speaker,
|
||||||
@@ -780,13 +890,43 @@ function FullscreenCall({
|
|||||||
onFocusTile,
|
onFocusTile,
|
||||||
onTileContextMenu,
|
onTileContextMenu,
|
||||||
controls,
|
controls,
|
||||||
|
keepControlsVisible = false,
|
||||||
}: FullscreenProps) {
|
}: FullscreenProps) {
|
||||||
const [hintGone, setHintGone] = useState(false);
|
const [hintGone, setHintGone] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||||
return () => window.clearTimeout(id);
|
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 hasFocus = speaker !== undefined;
|
||||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||||
@@ -911,7 +1051,14 @@ function FullscreenCall({
|
|||||||
</div>
|
</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}
|
{controls}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -931,3 +1078,65 @@ function PttHint() {
|
|||||||
</p>
|
</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
|
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
|
||||||
// currently-attached remote-audio elements.
|
// currently-attached remote-audio elements.
|
||||||
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
|
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);
|
const CallContext = createContext<CallContextValue | null>(null);
|
||||||
@@ -219,6 +227,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
|
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
|
||||||
() => new Set<string>(),
|
() => new Set<string>(),
|
||||||
);
|
);
|
||||||
|
const [micError, setMicError] = useState<string | null>(null);
|
||||||
|
|
||||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const presenceChannelRef = 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 disconnectRoom = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (r) {
|
if (r) {
|
||||||
@@ -702,50 +778,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
try {
|
// Mic pipeline setup + publish. Runs asynchronously; on failure sets
|
||||||
const audioPrefs = getAudioSettings();
|
// `micError` so the InCallPanel renders a retry banner without tearing
|
||||||
const inputId = audioPrefs.inputDeviceId;
|
// down the whole call — the user can still hear peers meanwhile.
|
||||||
// Noise suppression: user-preference wins over the quality preset so
|
await setupMicPipeline(r);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
if (mediaKind === 'video') {
|
if (mediaKind === 'video') {
|
||||||
try {
|
try {
|
||||||
await r.localParticipant.setCameraEnabled(true);
|
await r.localParticipant.setCameraEnabled(true);
|
||||||
@@ -802,6 +838,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
emitCallEvent,
|
emitCallEvent,
|
||||||
disconnectRoom,
|
disconnectRoom,
|
||||||
myId,
|
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) => {
|
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ outputDeviceId: deviceId });
|
updateAudioSettings({ outputDeviceId: deviceId });
|
||||||
const sinkId = deviceId ?? '';
|
const sinkId = deviceId ?? '';
|
||||||
@@ -1611,6 +1669,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
activeSoundboardIds,
|
activeSoundboardIds,
|
||||||
setSoundboardMasterGain,
|
setSoundboardMasterGain,
|
||||||
setSoundboardMonitorGain,
|
setSoundboardMonitorGain,
|
||||||
|
micError,
|
||||||
|
clearMicError,
|
||||||
|
retryMic,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -1648,6 +1709,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
activeSoundboardIds,
|
activeSoundboardIds,
|
||||||
setSoundboardMasterGain,
|
setSoundboardMasterGain,
|
||||||
setSoundboardMonitorGain,
|
setSoundboardMonitorGain,
|
||||||
|
micError,
|
||||||
|
clearMicError,
|
||||||
|
retryMic,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user