feat: call UX overhaul — deafen sync, share dialog, fullscreen redesign

Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
  batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
  participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
  250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
  missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
  z-10) so video mode shows the ring too, not just audio mode

Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
  intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
  screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
  hint) opens on the share button. startScreenShare / stopScreenShare
  actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
  doesn't implement requestFullscreen), always `h-full w-full
  object-contain`, Esc exits

Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
  MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
  LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
  state so peers switch to avatar placeholder when a camera is disabled

Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
  attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
  via module-level flag
- Broadcast state over the LiveKit data channel
  ({type:'presence', deafened}) so peers can render the headphones-off
  badge. Attributes API not used because the self-hosted server may run
  older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
  DataReceived and re-broadcast on ParticipantConnected

Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
  answer a video invite with audio only or promote an audio invite to
  video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
  invite is a video call

Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
  setAudioInputDevice / setAudioOutputDevice hot-swap actions.
  Output swap applies setSinkId to every attached remote-audio element
  since LiveKit's own switchActiveDevice only tracks elements it
  attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
  listener and a permission-probe button

Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
  flex layout. Default = even grid of all tiles. Clicking a tile flips
  to big-speaker + horizontal thumbnail strip. Click focused tile =
  back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
  never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
  (without it, video intrinsic dimensions blew tiles past the container
  bounds on Windows)

UI chips:
- Mic-off badge combines isMuted flag AND
  localParticipant.isMicrophoneEnabled, so a user with no mic / denied
  permission sees the badge + the toolbar button red even though they
  never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
  data-channel broadcast)
This commit is contained in:
2026-04-21 00:02:43 +02:00
parent a4c9b959a9
commit da85f0ba54
10 changed files with 1129 additions and 241 deletions
@@ -1,6 +1,5 @@
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
setIsFullscreen((v) => !v);
};
const viewerNode = (
return (
<div
ref={containerRef}
className={
isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
: 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
}
>
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
@@ -104,10 +103,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
playsInline
muted
onDoubleClick={toggleFullscreen}
className={
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
) : (
<button
@@ -132,14 +128,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)}
</div>
);
// When in fullscreen, portal out of the call-panel subtree into <body> so
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
// AppShell's root — portalled node sits above them via z-[60].
if (isFullscreen) {
return createPortal(viewerNode, document.body);
}
return viewerNode;
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {