From bc8a7c5a32485a8aad61dc4b943dc80d5f5de105 Mon Sep 17 00:00:00 2001
From: byGalax
Date: Wed, 22 Apr 2026 20:02:46 +0200
Subject: [PATCH] feat(call): Discord-style in-call features (group D)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
apps/desktop/src/components/CallControls.tsx | 44 ++++
apps/desktop/src/components/CallUI.tsx | 36 ++-
apps/desktop/src/components/InCallPanel.tsx | 233 +++++++++++++++++-
.../src/components/ParticipantsPopover.tsx | 206 ++++++++++++++++
apps/desktop/src/context/CallContext.tsx | 152 ++++++++----
5 files changed, 613 insertions(+), 58 deletions(-)
create mode 100644 apps/desktop/src/components/ParticipantsPopover.tsx
diff --git a/apps/desktop/src/components/CallControls.tsx b/apps/desktop/src/components/CallControls.tsx
index d7b0440..fd932c0 100644
--- a/apps/desktop/src/components/CallControls.tsx
+++ b/apps/desktop/src/components/CallControls.tsx
@@ -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({
)}
+ {onToggleNoiseSuppression && (
+
+
+
+ )}
{onToggleSoundboard && (
@@ -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({
diff --git a/apps/desktop/src/components/CallUI.tsx b/apps/desktop/src/components/CallUI.tsx
index c985d7e..3cc9b8e 100644
--- a/apps/desktop/src/components/CallUI.tsx
+++ b/apps/desktop/src/components/CallUI.tsx
@@ -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 (
- Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
+
+ {startedAt
+ ?
+ : t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
+