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 ( + + + ); +} + +// Tiny inline variant so we don't pull MicOffIcon's default sizing. +function MicOffIconInline() { + return ( + + ); +} diff --git a/apps/desktop/src/components/ParticipantsPopover.tsx b/apps/desktop/src/components/ParticipantsPopover.tsx new file mode 100644 index 0000000..bf39f1f --- /dev/null +++ b/apps/desktop/src/components/ParticipantsPopover.tsx @@ -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; + onClose: () => void; +} + +const AVATAR_TONES: Record = { + 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( +
+
+
+ + + {t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length} + +
+ +
+
+ {rows.length === 0 ? ( +

+ {t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })} +

+ ) : ( +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+ )} +
+
, + 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(() => + row.self ? 1 : getParticipantVolume(row.userId), + ); + + useEffect(() => { + if (row.self) return; + return subscribeParticipantVolumes(() => { + setVolume(getParticipantVolume(row.userId)); + }); + }, [row.self, row.userId]); + + return ( +
+
+
+ {row.avatarUrl ? ( + + ) : ( + + {letter} + + )} + {speaking && ( +
+
+ {row.displayName} + {row.self && (du)} +
+
+ {row.muted && ( + + + + )} + {row.deafened && ( + + + + )} +
+
+ {!row.self && ( +
+ { + const v = Number(e.target.value); + setVolume(v); + setParticipantVolume(row.userId, v); + }} + aria-label={'Lautstärke ' + row.displayName} + className="flex-1 accent-accent" + /> + + {Math.round(volume * 100)}% + +
+ )} +
+ ); +} diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index 369c1ff..a9a94ae 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -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; + /** 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; } const CallContext = createContext(null); @@ -219,6 +227,7 @@ export function CallProvider({ children }: { children: ReactNode }) { const [activeSoundboardIds, setActiveSoundboardIds] = useState>( () => new Set(), ); + const [micError, setMicError] = useState(null); const signalChannelRef = useRef(null); const presenceChannelRef = useRef(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 => { + 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, ], );