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:
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
HeadphonesIcon,
|
||||
HeadphonesOffIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
@@ -14,9 +16,11 @@ interface Props {
|
||||
muted: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
deafened: boolean;
|
||||
onToggleMute: () => void;
|
||||
onToggleShare: () => void;
|
||||
onToggleVideo?: () => void;
|
||||
onToggleDeafen: () => void;
|
||||
onHangup: () => void;
|
||||
onOpenParticipants?: () => void;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
@@ -31,9 +35,11 @@ export function CallControls({
|
||||
muted,
|
||||
sharing,
|
||||
video,
|
||||
deafened,
|
||||
onToggleMute,
|
||||
onToggleShare,
|
||||
onToggleVideo,
|
||||
onToggleDeafen,
|
||||
onHangup,
|
||||
onOpenParticipants,
|
||||
compact = false,
|
||||
@@ -59,6 +65,24 @@ export function CallControls({
|
||||
>
|
||||
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</CallButton>
|
||||
<CallButton
|
||||
label={
|
||||
deafened
|
||||
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
|
||||
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
|
||||
}
|
||||
active={deafened}
|
||||
activeTone="danger"
|
||||
onClick={onToggleDeafen}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
{deafened ? (
|
||||
<HeadphonesOffIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<HeadphonesIcon className="h-5 w-5" />
|
||||
)}
|
||||
</CallButton>
|
||||
{onToggleVideo && (
|
||||
<CallButton
|
||||
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||
|
||||
Reference in New Issue
Block a user