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>
|
||||
|
||||
Reference in New Issue
Block a user