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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 20:02:46 +02:00
parent 1c67a5c97f
commit bc8a7c5a32
5 changed files with 613 additions and 58 deletions
@@ -9,6 +9,7 @@ import {
MonitorStopIcon,
MusicIcon,
PhoneOffIcon,
SparklesIcon,
UsersIcon,
VideoIcon,
} from './icons';
@@ -18,15 +19,22 @@ interface Props {
sharing: boolean;
video: boolean;
deafened: boolean;
noiseSuppression?: boolean;
onToggleMute: () => void;
onToggleShare: () => void;
/** Right-click on the share button opens the quality picker dialog while
* left-click just starts with last-used settings. Optional so pages that
* don't need the advanced path (mobile, etc.) can skip it. */
onShareContextMenu?: (e: React.MouseEvent) => void;
onToggleVideo?: () => void;
onToggleDeafen: () => void;
onToggleNoiseSuppression?: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
@@ -40,14 +48,18 @@ export function CallControls({
sharing,
video,
deafened,
noiseSuppression,
onToggleMute,
onToggleShare,
onShareContextMenu,
onToggleVideo,
onToggleDeafen,
onToggleNoiseSuppression,
onHangup,
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
participantsOpen = false,
compact = false,
glass = false,
disabledMedia = false,
@@ -111,6 +123,7 @@ export function CallControls({
active={sharing}
activeTone="accent"
onClick={onToggleShare}
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
disabled={disabledMedia}
glass={glass}
className={btnSize}
@@ -121,6 +134,26 @@ export function CallControls({
<MonitorShareIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleNoiseSuppression && (
<CallButton
label={
noiseSuppression
? t('app:call.ns_off', {
defaultValue: 'Rauschunterdrückung aus',
})
: t('app:call.ns_on', {
defaultValue: 'Rauschunterdrückung an',
})
}
active={noiseSuppression ?? false}
activeTone="accent"
onClick={onToggleNoiseSuppression}
glass={glass}
className={btnSize}
>
<SparklesIcon className="h-5 w-5" />
</CallButton>
)}
{onToggleSoundboard && (
<CallButton
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
@@ -137,6 +170,9 @@ export function CallControls({
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
onClick={onOpenParticipants}
active={participantsOpen}
activeTone="accent"
dataTrigger="participants"
glass={glass}
className={btnSize}
>
@@ -159,24 +195,30 @@ export function CallControls({
interface CallButtonProps {
label: string;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
disabled?: boolean;
active?: boolean;
activeTone?: 'accent' | 'danger';
tone?: 'default' | 'danger';
glass?: boolean;
className?: string;
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
* when the user is toggling their own trigger. */
dataTrigger?: string;
children: React.ReactNode;
}
function CallButton({
label,
onClick,
onContextMenu,
disabled,
active,
activeTone = 'accent',
tone = 'default',
glass = false,
className = '',
dataTrigger,
children,
}: CallButtonProps) {
const base =
@@ -200,11 +242,13 @@ function CallButton({
<button
type="button"
onClick={onClick}
onContextMenu={onContextMenu}
disabled={disabled}
aria-label={label}
aria-pressed={active}
title={label}
className={`${base} ${toneClass} ${className}`}
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
>
{children}
</button>