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
@@ -14,12 +14,22 @@ export type ScreenSharePreset =
| '1440p60'
| '4k60';
// Browser getDisplayMedia `displaySurface` hint. The OS picker honours this
// to pre-filter the source list (monitor = whole display, window = single
// window). `null` leaves everything selectable.
export type DisplaySurfaceHint = 'monitor' | 'window' | null;
export interface ScreenShareSettings {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
// User-chosen framerate. `null` falls back to preset's default.
framerateOverride: number | null;
}
const DEFAULTS: ScreenShareSettings = {
preset: 'auto',
displaySurface: null,
framerateOverride: null,
};
export interface PresetParams {
@@ -103,6 +113,14 @@ function read(): ScreenShareSettings {
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
cached = {
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
displaySurface:
parsed.displaySurface === 'monitor' || parsed.displaySurface === 'window'
? parsed.displaySurface
: DEFAULTS.displaySurface,
framerateOverride:
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
? parsed.framerateOverride
: DEFAULTS.framerateOverride,
};
return cached;
} catch {