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,8 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon, MicIcon,
MicOffIcon, MicOffIcon,
MonitorShareIcon, MonitorShareIcon,
@@ -14,9 +16,11 @@ interface Props {
muted: boolean; muted: boolean;
sharing: boolean; sharing: boolean;
video: boolean; video: boolean;
deafened: boolean;
onToggleMute: () => void; onToggleMute: () => void;
onToggleShare: () => void; onToggleShare: () => void;
onToggleVideo?: () => void; onToggleVideo?: () => void;
onToggleDeafen: () => void;
onHangup: () => void; onHangup: () => void;
onOpenParticipants?: () => void; onOpenParticipants?: () => void;
/** Compact variant used inside the docked call (36px buttons). */ /** Compact variant used inside the docked call (36px buttons). */
@@ -31,9 +35,11 @@ export function CallControls({
muted, muted,
sharing, sharing,
video, video,
deafened,
onToggleMute, onToggleMute,
onToggleShare, onToggleShare,
onToggleVideo, onToggleVideo,
onToggleDeafen,
onHangup, onHangup,
onOpenParticipants, onOpenParticipants,
compact = false, compact = false,
@@ -59,6 +65,24 @@ export function CallControls({
> >
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />} {muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</CallButton> </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 && ( {onToggleVideo && (
<CallButton <CallButton
label={t('app:call.start_video', { defaultValue: 'Video' })} label={t('app:call.start_video', { defaultValue: 'Video' })}
@@ -1,4 +1,6 @@
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons'; import { useEffect, useRef } from 'react';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal'; export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
@@ -41,10 +43,17 @@ export interface ParticipantTileProps {
avatarUrl: string | null; avatarUrl: string | null;
me: boolean; me: boolean;
muted: boolean; muted: boolean;
/** Local-only: true when THIS user has muted everyone else via the deafen
* toggle. Remote deafen state is not propagated, so only the own tile
* ever carries a truthy value. */
deafened: boolean;
speaking: boolean; speaking: boolean;
sharing: boolean; sharing: boolean;
video: boolean; video: boolean;
e2ee: boolean; e2ee: boolean;
/** MediaStreamTrack for the participant's active camera, when `video` is
* true. When null the tile falls back to the avatar placeholder. */
videoTrack?: MediaStreamTrack | null;
size?: 'default' | 'small'; size?: 'default' | 'small';
focused?: boolean; focused?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -56,6 +65,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
displayName, displayName,
me, me,
muted, muted,
deafened,
speaking, speaking,
sharing, sharing,
video, video,
@@ -95,6 +105,15 @@ export function CallParticipantTile(props: ParticipantTileProps) {
<AudioContent {...props} small={small} /> <AudioContent {...props} small={small} />
)} )}
{/* Speaking indicator visible regardless of content type (video or
audio). z-10 ensures it sits above the video element. */}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
/>
)}
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5"> <div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold"> <div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />} {me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
@@ -114,12 +133,29 @@ export function CallParticipantTile(props: ParticipantTileProps) {
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{muted && ( {muted && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"> <span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" /> <MicOffIcon className="h-3 w-3" />
</span> </span>
)} )}
{deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
{sharing && ( {sharing && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"> <span
aria-label="Teilt Bildschirm"
title="Teilt Bildschirm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"
>
<MonitorShareIcon className="h-3 w-3" /> <MonitorShareIcon className="h-3 w-3" />
</span> </span>
)} )}
@@ -179,11 +215,45 @@ function VideoStub({
userId, userId,
displayName, displayName,
avatarUrl, avatarUrl,
videoTrack,
me,
small, small,
}: ParticipantTileProps & { small: boolean }) { }: ParticipantTileProps & { small: boolean }) {
// Video capture playback is out of scope for this UI iteration — show the const videoRef = useRef<HTMLVideoElement | null>(null);
// audio-avatar gradient background as a placeholder so the layout is stable useEffect(() => {
// when a participant enables video. const el = videoRef.current;
if (!el || !videoTrack) return;
el.srcObject = new MediaStream([videoTrack]);
return () => {
if (el.srcObject) {
(el.srcObject as MediaStream).getTracks().forEach((t) => {
// Don't stop the live track — other consumers may still need it.
// Just detach from this element.
void t;
});
el.srcObject = null;
}
};
}, [videoTrack]);
if (videoTrack) {
return (
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={
'h-full w-full object-cover ' +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
</div>
);
}
// Camera flag true but no track yet (publishing / subscribing race).
const key = colorKeyFor(userId); const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key]; const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; const letter = displayName.trim().charAt(0).toUpperCase() || '?';
+318 -138
View File
@@ -1,4 +1,6 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -13,6 +15,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile'; import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer'; import { ScreenShareViewer } from './ScreenShareViewer';
// Discord-style in-call dock rendered above the message list. Renders three // Discord-style in-call dock rendered above the message list. Renders three
@@ -24,13 +27,24 @@ interface Props {
} }
interface Tile { interface Tile {
// `user` = person with avatar/video/mute info. Speaking ring applies here.
// `screen` = a separate screen-share window from a user. No speaking
// indicator, no mute, no avatar — just the stream.
kind: 'user' | 'screen';
// Stable id used for focus tracking + React keys. `user:${userId}` or
// `screen:${userId}`.
id: string;
userId: string; userId: string;
displayName: string; displayName: string;
avatarUrl: string | null; avatarUrl: string | null;
self: boolean; self: boolean;
muted: boolean; muted: boolean;
deafened: boolean;
video: boolean; video: boolean;
videoTrack: MediaStreamTrack | null;
// True iff this is the current user's own screen-share tile.
sharing: boolean; sharing: boolean;
// True iff this is a remote user's screen-share tile.
remoteSharing: boolean; remoteSharing: boolean;
} }
@@ -43,11 +57,17 @@ export function InCallPanel({ conversation }: Props) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteScreenShares, remoteScreenShares,
callMode, callMode,
focusedId, focusedId,
toggleMute, toggleMute,
toggleScreenShare, startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
hangup, hangup,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
@@ -55,6 +75,7 @@ export function InCallPanel({ conversation }: Props) {
const { session } = useAuth(); const { session } = useAuth();
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room); const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const active = const active =
(state.kind === 'connected' || (state.kind === 'connected' ||
@@ -66,9 +87,13 @@ export function InCallPanel({ conversation }: Props) {
const tiles = buildTiles({ const tiles = buildTiles({
conversation, conversation,
myId, myId,
remoteIdentities: remoteParticipants.map((p) => p.identity), room,
remoteParticipants,
isMuted, isMuted,
isDeafened,
remoteDeafen,
isScreenSharing, isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
}); });
@@ -86,17 +111,29 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected'); : t('app:call.connected');
const sharingTile = tiles.find((p) => p.sharing); // A screen-share tile becomes the auto-focus target when no one explicitly
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null; // picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0]; // distinguish a user's own avatar tile from their screen tile.
const screenTile = tiles.find((p) => p.kind === 'screen');
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
const controls = ( const controls = (
<CallControls <CallControls
muted={isMuted} muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
sharing={isScreenSharing} sharing={isScreenSharing}
video={false} video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute} onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()} onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
setShareDialogOpen(true);
}
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
@@ -105,17 +142,23 @@ export function InCallPanel({ conversation }: Props) {
); );
if (callMode === 'fullscreen') { if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone OR
// someone is sharing their screen. Without that we show an even grid of
// all participants (Discord default). Clicking a tile switches to the
// big-speaker + thumbnail-strip layout.
const hasFocus = focusedId !== null || screenTile !== undefined;
return ( return (
<FullscreenCall <FullscreenCall
tiles={tiles} tiles={tiles}
speaker={speaker} speaker={hasFocus ? speaker : undefined}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members} conversationMembers={conversation.members}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
e2ee={isE2EEActive} e2ee={isE2EEActive}
onExit={() => setCallMode('grid')} onExit={() => setCallMode('grid')}
onFocusTile={(id) => { onFocusTile={(id) => {
setFocusedId(id); // Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}} }}
controls={controls} controls={controls}
/> />
@@ -127,11 +170,19 @@ export function InCallPanel({ conversation }: Props) {
? conversation.name ?? t('app:chats.new_group') ? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—'; : conversation.peer?.displayName ?? '—';
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
return ( return (
<section <section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })} aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2" className={sectionClass}
style={{ height: '50%' }} style={{ height: sectionHeight }}
> >
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2"> <div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
@@ -174,6 +225,14 @@ export function InCallPanel({ conversation }: Props) {
{controls} {controls}
<PttHint /> <PttHint />
<ScreenShareDialog
open={shareDialogOpen}
onClose={() => setShareDialogOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
/>
</section> </section>
); );
} }
@@ -185,49 +244,118 @@ export function InCallPanel({ conversation }: Props) {
interface BuildArgs { interface BuildArgs {
conversation: ConversationSummary; conversation: ConversationSummary;
myId: string | null; myId: string | null;
remoteIdentities: string[]; room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean; isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>; remoteSharerIds: Set<string>;
} }
function cameraTrackFor(
participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
): MediaStreamTrack | null {
if (!participant) return null;
for (const pub of participant.videoTrackPublications.values()) {
if (pub.source === Track.Source.Camera && pub.track) {
return pub.track.mediaStreamTrack ?? null;
}
}
return null;
}
function buildTiles({ function buildTiles({
conversation, conversation,
myId, myId,
remoteIdentities, room,
remoteParticipants,
isMuted, isMuted,
isDeafened,
remoteDeafen,
isScreenSharing, isScreenSharing,
isCameraEnabled,
remoteSharerIds, remoteSharerIds,
}: BuildArgs): Tile[] { }: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities); const remoteById = new Map<string, RemoteParticipant>();
for (const p of remoteParticipants) {
if (p.identity) remoteById.set(p.identity, p);
}
const out: Tile[] = []; const out: Tile[] = [];
if (myId) { if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null; const me = conversation.members.find((m) => m.userId === myId) ?? null;
// Source-of-truth for own mic: the LocalParticipant publication state.
// `isMuted` reflects the toggle button, but if mic never published (no
// device / permission denied) the toggle stays false while the real
// state is "muted". Combine both so the badge always matches reality.
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
out.push({ out.push({
kind: 'user',
id: 'user:' + myId,
userId: myId, userId: myId,
displayName: me?.profile?.displayName ?? '?', displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null, avatarUrl: me?.profile?.avatarUrl ?? null,
self: true, self: true,
muted: isMuted, muted: isMuted || !micLive,
deafened: isDeafened,
video: isCameraEnabled,
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
});
if (isScreenSharing) {
out.push({
kind: 'screen',
id: 'screen:' + myId,
userId: myId,
displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: false,
deafened: false,
video: false, video: false,
sharing: isScreenSharing, videoTrack: null,
sharing: true,
remoteSharing: false, remoteSharing: false,
}); });
} }
}
for (const m of conversation.members) { for (const m of conversation.members) {
if (m.userId === myId) continue; if (m.userId === myId) continue;
if (!remoteSet.has(m.userId)) continue; const rp = remoteById.get(m.userId);
const sharing = remoteSharerIds.has(m.userId); if (!rp) continue;
out.push({ out.push({
kind: 'user',
id: 'user:' + m.userId,
userId: m.userId, userId: m.userId,
displayName: m.profile?.displayName ?? '?', displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null, avatarUrl: m.profile?.avatarUrl ?? null,
self: false, self: false,
muted: false, muted: !rp.isMicrophoneEnabled,
video: false, // Deafen state arrives via LiveKit data channel; see CallContext.
sharing, deafened: remoteDeafen[m.userId] ?? false,
remoteSharing: sharing, video: rp.isCameraEnabled,
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
}); });
if (remoteSharerIds.has(m.userId)) {
out.push({
kind: 'screen',
id: 'screen:' + m.userId,
userId: m.userId,
displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: false,
remoteSharing: true,
});
}
} }
return out; return out;
} }
@@ -322,6 +450,86 @@ interface StageProps {
compact?: boolean; compact?: boolean;
} }
// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
// render via CallParticipantTile with all its chrome.
function TileRender({
tile,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
size,
focused,
onClick,
}: {
tile: Tile;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
// Local screenshare preview — we don't mirror a copy of the outgoing
// stream. Render a labelled placeholder card so the user knows their
// share is live without double-encoding the stream.
return (
<div
onClick={onClick}
className={
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
(onClick ? 'cursor-pointer' : '')
}
>
<div className="flex flex-col items-center gap-2 p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wider">
Live
</div>
<div className="text-sm">{tile.displayName}</div>
</div>
</div>
);
}
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (!share) return <div className="h-full w-full" />;
return (
<div
onClick={onClick}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
return (
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
deafened={tile.deafened}
speaking={activeSpeakers.has(tile.userId)}
sharing={false}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
/>
);
}
function CallStage({ function CallStage({
tiles, tiles,
speaker, speaker,
@@ -334,34 +542,33 @@ function CallStage({
compact = false, compact = false,
}: StageProps) { }: StageProps) {
if (mode === 'focus' && speaker) { if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.userId !== speaker.userId); const others = tiles.filter((p) => p.id !== speaker.id);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3"> <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<FocusedTile <FocusedTile
tile={speaker} tile={speaker}
e2ee={e2ee} e2ee={e2ee}
speaking={activeSpeakers.has(speaker.userId)} activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers} conversationMembers={conversationMembers}
/> />
</div> </div>
{others.length > 0 && ( {others.length > 0 && (
<div className="flex h-[110px] gap-2.5 overflow-x-auto"> <div className="flex h-[180px] gap-2.5 overflow-x-auto">
{others.map((p) => ( {others.map((p) => (
<div key={p.userId} className="h-full min-w-[160px]"> <div
<CallParticipantTile key={p.id}
userId={p.userId} className="h-full w-[240px] shrink-0 [&>div]:h-full"
displayName={p.displayName} >
avatarUrl={p.avatarUrl} <TileRender
me={p.self} tile={p}
muted={p.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small" size="small"
onClick={() => onFocusTile(p.userId)} onClick={() => onFocusTile(p.id)}
/> />
</div> </div>
))} ))}
@@ -377,19 +584,16 @@ function CallStage({
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}> <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}> <div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => ( {tiles.map((p) => (
<CallParticipantTile <div key={p.id} className="[&>div]:h-full [&>div]:w-full">
key={p.userId} <TileRender
userId={p.userId} tile={p}
displayName={p.displayName} activeSpeakers={activeSpeakers}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
onClick={() => onFocusTile(p.userId)} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
/> />
</div>
))} ))}
</div> </div>
</div> </div>
@@ -399,45 +603,24 @@ function CallStage({
function FocusedTile({ function FocusedTile({
tile, tile,
e2ee, e2ee,
speaking, activeSpeakers,
remoteScreenShares, remoteScreenShares,
conversationMembers, conversationMembers,
}: { }: {
tile: Tile; tile: Tile;
e2ee: boolean; e2ee: boolean;
speaking: boolean; activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares']; remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers']; conversationMembers: StageProps['conversationMembers'];
}) { }) {
// When the focused participant is remotely sharing their screen, embed the
// real video stream rather than the fake-window placeholder.
if (tile.remoteSharing) {
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (share) {
return ( return (
<div className="h-full"> <div className="h-full [&>div]:h-full">
<ScreenShareViewer <TileRender
share={share} tile={tile}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl} activeSpeakers={activeSpeakers}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
}
return (
<div className="h-full">
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
speaking={speaking}
sharing={tile.sharing}
video={tile.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused focused
/> />
</div> </div>
@@ -445,9 +628,13 @@ function FocusedTile({
} }
function gridColsFor(n: number): string { function gridColsFor(n: number): string {
if (n <= 1) return 'grid-cols-1'; // Explicit `grid-rows-*` so cells get a defined height (1fr of available
if (n === 2) return 'grid-cols-2'; // space). Without this, implicit rows default to auto → they size to
if (n === 3) return 'grid-cols-3'; // content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2'; if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2'; return 'grid-cols-3 grid-rows-2';
} }
@@ -479,60 +666,77 @@ function FullscreenCall({
onFocusTile, onFocusTile,
controls, controls,
}: FullscreenProps) { }: FullscreenProps) {
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
const [hintGone, setHintGone] = useState(false); const [hintGone, setHintGone] = useState(false);
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500); const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id); return () => window.clearTimeout(id);
}, []); }, []);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const gridClass = gridColsFor(tiles.length);
return ( return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface"> <div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1"> {/* Content area. pb-24 reserves ~96px space at the bottom for the
{speaker && ( floating controls bar so tiles never sit behind it. */}
<div className="absolute inset-0"> <div className="relative flex min-h-0 flex-1 flex-col pb-24">
{speaker.remoteSharing ? ( {hasFocus ? (
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} /> <>
) : ( <div
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0"> className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
<CallParticipantTile onClick={() => onFocusTile(speaker!.id)}
userId={speaker.userId} title="Zurück zur Übersicht"
displayName={speaker.displayName} >
avatarUrl={speaker.avatarUrl} <TileRender
me={speaker.self} tile={speaker!}
muted={speaker.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(speaker.userId)}
sharing={speaker.sharing}
video={speaker.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused focused
/> />
</div> </div>
)}
</div>
)}
{others.length > 0 && ( {others.length > 0 && (
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2"> <div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.slice(0, 4).map((p) => ( {others.map((p) => (
<div key={p.userId} className="h-[100px] backdrop-blur-xl"> <div
<CallParticipantTile key={p.id}
userId={p.userId} className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
displayName={p.displayName} >
avatarUrl={p.avatarUrl} <TileRender
me={p.self} tile={p}
muted={p.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small" size="small"
onClick={() => onFocusTile(p.userId)} onClick={() => onFocusTile(p.id)}
/> />
</div> </div>
))} ))}
</div> </div>
)} )}
</>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
/>
</div>
))}
</div>
</div>
)}
</div>
{!hintGone && ( {!hintGone && (
<div <div
@@ -547,30 +751,6 @@ function FullscreenCall({
{controls} {controls}
</div> </div>
</div> </div>
</div>
);
}
function FullscreenShare({
speaker,
remoteScreenShares,
conversationMembers,
}: {
speaker: Tile;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
const member = conversationMembers.find((m) => m.userId === speaker.userId);
if (!share) return null;
return (
<div className="h-full w-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
displayName={member?.profile?.displayName ?? speaker.displayName}
/>
</div>
); );
} }
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile'; import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons'; import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
interface Props { interface Props {
conversation: ConversationSummary; conversation: ConversationSummary;
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
</> </>
)} )}
</p> </p>
<div className="mt-5 flex w-full max-w-[360px] gap-3"> <div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
<button <button
type="button" type="button"
onClick={rejectIncoming} onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400" className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
> >
<PhoneOffIcon className="h-4 w-4" /> <PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span> <span>{t('app:call.decline')}</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => void acceptIncoming()} onClick={() => void acceptIncoming('audio')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50" className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
> >
<PhoneIcon className="h-4 w-4" /> <PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span> <span>
{state.mediaKind === 'video'
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
: t('app:call.accept')}
</span>
</button> </button>
{state.mediaKind === 'video' && (
<button
type="button"
onClick={() => void acceptIncoming('video')}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<VideoIcon className="h-4 w-4" />
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
</button>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -0,0 +1,233 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</p>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -1,6 +1,5 @@
import type { RemoteTrack } from 'livekit-client'; import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext'; import type { RemoteScreenShare } from '../context/CallContext';
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
setIsFullscreen((v) => !v); setIsFullscreen((v) => !v);
}; };
const viewerNode = ( return (
<div <div
ref={containerRef} ref={containerRef}
className={ className={
isFullscreen isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black' ? '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"> <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 playsInline
muted muted
onDoubleClick={toggleFullscreen} onDoubleClick={toggleFullscreen}
className={ className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
/> />
) : ( ) : (
<button <button
@@ -132,14 +128,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)} )}
</div> </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 }) { function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
+22
View File
@@ -451,6 +451,28 @@ export function MoreVerticalIcon(props: IconProps) {
); );
} }
export function HeadphonesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
</Base>
);
}
export function HeadphonesOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
<path d="M3 3 21 21" />
</Base>
);
}
export function ReplyIcon(props: IconProps) { export function ReplyIcon(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
+216 -14
View File
@@ -48,8 +48,11 @@ import {
isE2EESupported, isE2EESupported,
} from '../lib/callE2EE'; } from '../lib/callE2EE';
import { import {
type DisplaySurfaceHint,
getPresetParams, getPresetParams,
getScreenShareSettings, getScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings'; } from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -102,6 +105,10 @@ interface CallContextValue {
isMuted: boolean; isMuted: boolean;
isE2EEActive: boolean; isE2EEActive: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraEnabled: boolean;
isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous). // Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[]; remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget // Remembers the conversation of the last call we left so a sidebar widget
@@ -113,11 +120,21 @@ interface CallContextValue {
// Actions: // Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: () => Promise<void>; acceptIncoming: (override?: CallKind) => Promise<void>;
rejectIncoming: () => void; rejectIncoming: () => void;
hangup: () => Promise<void>; hangup: () => Promise<void>;
toggleMute: () => void; toggleMute: () => void;
toggleScreenShare: () => Promise<void>; toggleScreenShare: () => Promise<void>;
startScreenShare: (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => Promise<void>;
stopScreenShare: () => Promise<void>;
toggleCamera: () => Promise<void>;
toggleDeafen: () => void;
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void; setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void; setFocusedId: (id: string | null) => void;
@@ -149,6 +166,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isE2EEActive, setIsE2EEActive] = useState(false); const [isE2EEActive, setIsE2EEActive] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]); const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid'); const [callMode, setCallModeState] = useState<CallMode>('grid');
@@ -167,6 +186,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
// For 1:1 calls the set has one entry so behaviour is unchanged; for // For 1:1 calls the set has one entry so behaviour is unchanged; for
// groups, a single reject doesn't terminate the call while others ring. // groups, a single reject doesn't terminate the call while others ring.
const pendingPeersRef = useRef<Set<string>>(new Set()); const pendingPeersRef = useRef<Set<string>>(new Set());
// identity -> their current deafen state. Populated via LiveKit data
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const stateRef = useRef<CallState>(state); const stateRef = useRef<CallState>(state);
stateRef.current = state; stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without // Keep latest conversations accessible from signal-channel closures without
@@ -243,6 +266,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteParticipants([]); setRemoteParticipants([]);
setRemoteScreenShares([]); setRemoteScreenShares([]);
setIsScreenSharing(false); setIsScreenSharing(false);
setIsCameraEnabled(false);
setIsDeafened(false);
deafenedActive = false;
setRemoteDeafen({});
setIsE2EEActive(false); setIsE2EEActive(false);
const pres = presenceChannelRef.current; const pres = presenceChannelRef.current;
@@ -463,6 +490,45 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
}); });
// Mute/unmute a camera doesn't publish or unpublish — the publication
// stays, just its `muted` flag flips. Without this listener, remote
// participants who toggle video mid-call appear as a frozen last frame
// or (worse) a black tile on every other client. Bumping the
// remoteParticipants state reference forces buildTiles to re-read
// `isCameraEnabled` and swap to the avatar placeholder.
const bumpParticipants = () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
};
r.on(RoomEvent.TrackMuted, bumpParticipants);
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
// Remote deafen state is broadcast via the LiveKit data channel. We
// store incoming states in `remoteDeafenMapRef` and bump participants
// so buildTiles re-reads it.
r.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
const id: string = participant.identity;
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current deafen state so they know.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive);
});
// Track my own screen-share state via LocalTrack events so the toggle // Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI. // stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => { r.on(RoomEvent.LocalTrackPublished, (publication) => {
@@ -506,6 +572,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
if (mediaKind === 'video') { if (mediaKind === 'video') {
try { try {
await r.localParticipant.setCameraEnabled(true); await r.localParticipant.setCameraEnabled(true);
setIsCameraEnabled(true);
} catch (camErr: unknown) { } catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr); console.error('setCameraEnabled failed', camErr);
} }
@@ -650,10 +717,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
[myId, joinRoom, disconnectRoom], [myId, joinRoom, disconnectRoom],
); );
const acceptIncoming = useCallback(async () => { const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current; const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return; if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId, mediaKind } = s; const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false; everConnectedRef.current = false;
setLastCallConversationId(null); setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind }); setState({ kind: 'connecting', callId, conversationId, mediaKind });
@@ -666,7 +739,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
}); });
await disconnectRoom(); await disconnectRoom();
} }
}, [myId, joinRoom, disconnectRoom]); },
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => { const rejectIncoming = useCallback(() => {
const s = stateRef.current; const s = stateRef.current;
@@ -740,33 +815,129 @@ export function CallProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const toggleScreenShare = useCallback(async () => { const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (!r) return;
const lp = r.localParticipant; const lp = r.localParticipant;
const nextOn = !lp.isScreenShareEnabled; if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try { try {
const ssParams = getPresetParams(getScreenShareSettings().preset); await lp.setScreenShareEnabled(true, {
await lp.setScreenShareEnabled(nextOn, {
audio: false, audio: false,
// Omitting `resolution` lets the browser return native source size —
// best possible input quality. Fixed presets pass explicit dims so
// the encoder has a predictable target.
...(ssParams.dims ...(ssParams.dims
? { ? {
resolution: { resolution: {
width: ssParams.dims.width, width: ssParams.dims.width,
height: ssParams.dims.height, height: ssParams.dims.height,
frameRate: ssParams.framerate, frameRate: fps,
}, },
} }
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}), : {}),
contentHint: 'detail', contentHint: 'detail',
}); });
setIsScreenSharing(nextOn); setIsScreenSharing(true);
} catch (err: unknown) { } catch (err: unknown) {
console.error('setScreenShareEnabled failed', err); console.error('setScreenShareEnabled failed', err);
// User cancelled or permission denied — leave state as-is. }
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isScreenShareEnabled) return;
try {
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
el.muted = next;
});
// Broadcast via LiveKit data channel so peers' UIs can show the
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
} }
}, []); }, []);
@@ -1054,6 +1225,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1065,6 +1239,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup, hangup,
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
@@ -1078,6 +1256,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1089,6 +1270,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup, hangup,
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
@@ -1106,6 +1291,22 @@ export function useCall(): CallContextValue {
return ctx; return ctx;
} }
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
console.warn('broadcastPresence failed', err);
}
}
function attachTrack( function attachTrack(
track: RemoteTrack, track: RemoteTrack,
_publication: RemoteTrackPublication, _publication: RemoteTrackPublication,
@@ -1117,6 +1318,7 @@ function attachTrack(
audio.autoplay = true; audio.autoplay = true;
audio.setAttribute('playsinline', 'true'); audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? ''); audio.setAttribute('data-livekit-track', track.sid ?? '');
if (deafenedActive) audio.muted = true;
document.body.appendChild(audio); document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen // Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice` // speaker/headphone from the start (LiveKit's own `switchActiveDevice`
@@ -14,12 +14,22 @@ export type ScreenSharePreset =
| '1440p60' | '1440p60'
| '4k60'; | '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 { export interface ScreenShareSettings {
preset: ScreenSharePreset; preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
// User-chosen framerate. `null` falls back to preset's default.
framerateOverride: number | null;
} }
const DEFAULTS: ScreenShareSettings = { const DEFAULTS: ScreenShareSettings = {
preset: 'auto', preset: 'auto',
displaySurface: null,
framerateOverride: null,
}; };
export interface PresetParams { export interface PresetParams {
@@ -103,6 +113,14 @@ function read(): ScreenShareSettings {
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>; const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
cached = { cached = {
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset, 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; return cached;
} catch { } catch {
+151 -14
View File
@@ -1,10 +1,83 @@
import type { Participant, Room } from 'livekit-client'; import type { AudioTrack, Participant, Room } from 'livekit-client';
import { RoomEvent } from 'livekit-client'; import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse // Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
// on participant tiles. Returns the set of currently speaking identities // each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
// (includes local participant when they're active). // `isSpeaking` are updated by a background monitor (default ~1s) — too
// laggy. AnalyserNodes give us raw PCM at browser frame rate, so the ring
// lights up within one animation frame of actual speech.
//
// Hold time of 250ms prevents flicker between words / short pauses.
const POLL_MS = 50;
const HOLD_MS = 250;
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
const FFT_SIZE = 256;
interface Probe {
ctx: AudioContext;
analyser: AnalyserNode;
source: MediaStreamAudioSourceNode;
buf: Uint8Array;
// Cached MediaStreamTrack reference — if a publication swaps tracks
// (mute/unmute, device switch) we rebuild the node chain.
trackId: string;
}
function makeProbe(track: MediaStreamTrack): Probe | null {
try {
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const stream = new MediaStream([track]);
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE;
analyser.smoothingTimeConstant = 0.2;
source.connect(analyser);
return {
ctx,
analyser,
source,
buf: new Uint8Array(analyser.frequencyBinCount),
trackId: track.id,
};
} catch {
return null;
}
}
function destroyProbe(probe: Probe): void {
try {
probe.source.disconnect();
} catch {
/* ignore */
}
try {
void probe.ctx.close();
} catch {
/* ignore */
}
}
function sampleRms(probe: Probe): number {
// `getByteFrequencyData` types expect `Uint8Array<ArrayBuffer>` (no
// SharedArrayBuffer). Our `probe.buf` satisfies that at runtime; the cast
// sidesteps the TS lib signature quirk.
probe.analyser.getByteFrequencyData(probe.buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < probe.buf.length; i++) sum += probe.buf[i]!;
return sum / (probe.buf.length * 255);
}
function firstAudioTrack(p: Participant): AudioTrack | null {
const pubs = p.audioTrackPublications;
for (const pub of pubs.values()) {
if (pub.kind === Track.Kind.Audio && pub.track) {
return pub.track as AudioTrack;
}
}
return null;
}
export function useActiveSpeakers(room: Room | null): Set<string> { export function useActiveSpeakers(room: Room | null): Set<string> {
const [ids, setIds] = useState<Set<string>>(() => new Set()); const [ids, setIds] = useState<Set<string>>(() => new Set());
@@ -14,20 +87,84 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
return; return;
} }
const update = (speakers: Participant[]) => { const probes = new Map<string, Probe>();
const next = new Set<string>(); const lastActive = new Map<string, number>();
for (const s of speakers) {
if (s.identity) next.add(s.identity); const syncProbe = (p: Participant): void => {
if (!p.identity) return;
const track = firstAudioTrack(p);
const mst = track?.mediaStreamTrack ?? null;
const existing = probes.get(p.identity);
if (!mst) {
if (existing) {
destroyProbe(existing);
probes.delete(p.identity);
} }
setIds(next); return;
}
if (existing && existing.trackId === mst.id) return;
if (existing) destroyProbe(existing);
const probe = makeProbe(mst);
if (probe) probes.set(p.identity, probe);
}; };
// Seed from current state. const bind = (p: Participant): void => {
update(room.activeSpeakers ?? []); p.on(ParticipantEvent.TrackPublished, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnpublished, () => syncProbe(p));
p.on(ParticipantEvent.TrackSubscribed, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnsubscribed, () => syncProbe(p));
p.on(ParticipantEvent.TrackMuted, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnmuted, () => syncProbe(p));
syncProbe(p);
};
bind(room.localParticipant);
room.remoteParticipants.forEach(bind);
const onConnected = (p: Participant) => bind(p);
const onDisconnected = (p: Participant) => {
if (!p.identity) return;
const probe = probes.get(p.identity);
if (probe) {
destroyProbe(probe);
probes.delete(p.identity);
}
lastActive.delete(p.identity);
};
room.on(RoomEvent.ParticipantConnected, onConnected);
room.on(RoomEvent.ParticipantDisconnected, onDisconnected);
const tick = () => {
// Defensively resync every tick — covers the gap where local mic
// finishes publishing between effect mount and the first
// TrackPublished event, and handles event misses on Tauri WebView.
syncProbe(room.localParticipant);
room.remoteParticipants.forEach(syncProbe);
const now = Date.now();
for (const [id, probe] of probes) {
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
}
const next = new Set<string>();
for (const [id, t] of lastActive) {
if (now - t <= HOLD_MS) next.add(id);
}
setIds((prev) => {
if (prev.size === next.size && Array.from(prev).every((v) => next.has(v))) {
return prev;
}
return next;
});
};
const interval = window.setInterval(tick, POLL_MS);
room.on(RoomEvent.ActiveSpeakersChanged, update);
return () => { return () => {
room.off(RoomEvent.ActiveSpeakersChanged, update); window.clearInterval(interval);
room.off(RoomEvent.ParticipantConnected, onConnected);
room.off(RoomEvent.ParticipantDisconnected, onDisconnected);
for (const probe of probes.values()) destroyProbe(probe);
probes.clear();
}; };
}, [room]); }, [room]);