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
+108 -44
View File
@@ -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<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);
@@ -219,6 +227,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [micError, setMicError] = useState<string | null>(null);
const signalChannelRef = 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 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,
],
);