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' })}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -41,10 +43,17 @@ export interface ParticipantTileProps {
|
||||
avatarUrl: string | null;
|
||||
me: 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;
|
||||
sharing: boolean;
|
||||
video: 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';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -56,6 +65,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
deafened,
|
||||
speaking,
|
||||
sharing,
|
||||
video,
|
||||
@@ -95,6 +105,15 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<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="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" />}
|
||||
@@ -114,12 +133,29 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{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" />
|
||||
</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 && (
|
||||
<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" />
|
||||
</span>
|
||||
)}
|
||||
@@ -179,11 +215,45 @@ function VideoStub({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
videoTrack,
|
||||
me,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
// Video capture playback is out of scope for this UI iteration — show the
|
||||
// audio-avatar gradient background as a placeholder so the layout is stable
|
||||
// when a participant enables video.
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
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 colors = AVATAR_COLORS[key];
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -13,6 +15,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
@@ -24,13 +27,24 @@ interface Props {
|
||||
}
|
||||
|
||||
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;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
video: boolean;
|
||||
videoTrack: MediaStreamTrack | null;
|
||||
// True iff this is the current user's own screen-share tile.
|
||||
sharing: boolean;
|
||||
// True iff this is a remote user's screen-share tile.
|
||||
remoteSharing: boolean;
|
||||
}
|
||||
|
||||
@@ -43,11 +57,17 @@ export function InCallPanel({ conversation }: Props) {
|
||||
isMuted,
|
||||
isE2EEActive,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteScreenShares,
|
||||
callMode,
|
||||
focusedId,
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
toggleCamera,
|
||||
toggleDeafen,
|
||||
hangup,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
@@ -55,6 +75,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
@@ -66,9 +87,13 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const tiles = buildTiles({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities: remoteParticipants.map((p) => p.identity),
|
||||
room,
|
||||
remoteParticipants,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
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.connected');
|
||||
|
||||
const sharingTile = tiles.find((p) => p.sharing);
|
||||
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
|
||||
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
|
||||
// A screen-share tile becomes the auto-focus target when no one explicitly
|
||||
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
||||
// 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 = (
|
||||
<CallControls
|
||||
muted={isMuted}
|
||||
muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
|
||||
sharing={isScreenSharing}
|
||||
video={false}
|
||||
video={isCameraEnabled}
|
||||
deafened={isDeafened}
|
||||
onToggleMute={toggleMute}
|
||||
onToggleShare={() => void toggleScreenShare()}
|
||||
onToggleShare={() => {
|
||||
if (isScreenSharing) {
|
||||
void stopScreenShare();
|
||||
} else {
|
||||
setShareDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
onToggleVideo={() => void toggleCamera()}
|
||||
onToggleDeafen={toggleDeafen}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -105,17 +142,23 @@ export function InCallPanel({ conversation }: Props) {
|
||||
);
|
||||
|
||||
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 (
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
speaker={hasFocus ? speaker : undefined}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
onExit={() => setCallMode('grid')}
|
||||
onFocusTile={(id) => {
|
||||
setFocusedId(id);
|
||||
// Toggle: click the already-focused tile to return to grid.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
controls={controls}
|
||||
/>
|
||||
@@ -127,11 +170,19 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? conversation.name ?? t('app:chats.new_group')
|
||||
: 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 (
|
||||
<section
|
||||
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"
|
||||
style={{ height: '50%' }}
|
||||
className={sectionClass}
|
||||
style={{ height: sectionHeight }}
|
||||
>
|
||||
<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">
|
||||
@@ -174,6 +225,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
{controls}
|
||||
|
||||
<PttHint />
|
||||
|
||||
<ScreenShareDialog
|
||||
open={shareDialogOpen}
|
||||
onClose={() => setShareDialogOpen(false)}
|
||||
onStart={async (opts) => {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -185,49 +244,118 @@ export function InCallPanel({ conversation }: Props) {
|
||||
interface BuildArgs {
|
||||
conversation: ConversationSummary;
|
||||
myId: string | null;
|
||||
remoteIdentities: string[];
|
||||
room: Room | null;
|
||||
remoteParticipants: RemoteParticipant[];
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
remoteDeafen: Record<string, boolean>;
|
||||
isScreenSharing: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
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({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities,
|
||||
room,
|
||||
remoteParticipants,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds,
|
||||
}: 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[] = [];
|
||||
if (myId) {
|
||||
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({
|
||||
kind: 'user',
|
||||
id: 'user:' + myId,
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
muted: isMuted,
|
||||
video: false,
|
||||
sharing: isScreenSharing,
|
||||
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,
|
||||
videoTrack: null,
|
||||
sharing: true,
|
||||
remoteSharing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteSet.has(m.userId)) continue;
|
||||
const sharing = remoteSharerIds.has(m.userId);
|
||||
const rp = remoteById.get(m.userId);
|
||||
if (!rp) continue;
|
||||
out.push({
|
||||
kind: 'user',
|
||||
id: 'user:' + m.userId,
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
muted: false,
|
||||
video: false,
|
||||
sharing,
|
||||
remoteSharing: sharing,
|
||||
muted: !rp.isMicrophoneEnabled,
|
||||
// Deafen state arrives via LiveKit data channel; see CallContext.
|
||||
deafened: remoteDeafen[m.userId] ?? false,
|
||||
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;
|
||||
}
|
||||
@@ -322,6 +450,86 @@ interface StageProps {
|
||||
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({
|
||||
tiles,
|
||||
speaker,
|
||||
@@ -334,34 +542,33 @@ function CallStage({
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.userId !== speaker.userId);
|
||||
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
/>
|
||||
</div>
|
||||
{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) => (
|
||||
<div key={p.userId} className="h-full min-w-[160px]">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -377,19 +584,16 @@ function CallStage({
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
<CallParticipantTile
|
||||
key={p.userId}
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,45 +603,24 @@ function CallStage({
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
speaking,
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
speaking: boolean;
|
||||
activeSpeakers: Set<string>;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
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 (
|
||||
<div className="h-full">
|
||||
<ScreenShareViewer
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
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}
|
||||
<div className="h-full [&>div]:h-full">
|
||||
<TileRender
|
||||
tile={tile}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
@@ -445,9 +628,13 @@ function FocusedTile({
|
||||
}
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// 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';
|
||||
return 'grid-cols-3 grid-rows-2';
|
||||
}
|
||||
@@ -479,97 +666,90 @@ function FullscreenCall({
|
||||
onFocusTile,
|
||||
controls,
|
||||
}: FullscreenProps) {
|
||||
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{speaker && (
|
||||
<div className="absolute inset-0">
|
||||
{speaker.remoteSharing ? (
|
||||
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
|
||||
) : (
|
||||
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
|
||||
<CallParticipantTile
|
||||
userId={speaker.userId}
|
||||
displayName={speaker.displayName}
|
||||
avatarUrl={speaker.avatarUrl}
|
||||
me={speaker.self}
|
||||
muted={speaker.muted}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
sharing={speaker.sharing}
|
||||
video={speaker.video}
|
||||
e2ee={e2ee}
|
||||
focused
|
||||
/>
|
||||
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
||||
floating controls bar so tiles never sit behind it. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
||||
{hasFocus ? (
|
||||
<>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onClick={() => onFocusTile(speaker!.id)}
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<TileRender
|
||||
tile={speaker!}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
|
||||
{others.slice(0, 4).map((p) => (
|
||||
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hintGone && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||
>
|
||||
Esc zum Verlassen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{controls}
|
||||
</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}
|
||||
/>
|
||||
{!hintGone && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||
>
|
||||
Esc zum Verlassen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
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" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptIncoming()}
|
||||
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"
|
||||
onClick={() => void acceptIncoming('audio')}
|
||||
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" />
|
||||
<span>{t('app:call.accept')}</span>
|
||||
<span>
|
||||
{state.mediaKind === 'video'
|
||||
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
|
||||
: t('app:call.accept')}
|
||||
</span>
|
||||
</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>
|
||||
</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 { 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 }) {
|
||||
|
||||
@@ -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) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
|
||||
@@ -48,8 +48,11 @@ import {
|
||||
isE2EESupported,
|
||||
} from '../lib/callE2EE';
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -102,6 +105,10 @@ interface CallContextValue {
|
||||
isMuted: boolean;
|
||||
isE2EEActive: 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).
|
||||
remoteScreenShares: RemoteScreenShare[];
|
||||
// Remembers the conversation of the last call we left so a sidebar widget
|
||||
@@ -113,11 +120,21 @@ interface CallContextValue {
|
||||
// Actions:
|
||||
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||
acceptIncoming: () => Promise<void>;
|
||||
acceptIncoming: (override?: CallKind) => Promise<void>;
|
||||
rejectIncoming: () => void;
|
||||
hangup: () => Promise<void>;
|
||||
toggleMute: () => 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;
|
||||
setCallMode: (mode: CallMode) => void;
|
||||
setFocusedId: (id: string | null) => void;
|
||||
@@ -149,6 +166,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isE2EEActive, setIsE2EEActive] = useState(false);
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||
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
|
||||
// groups, a single reject doesn't terminate the call while others ring.
|
||||
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);
|
||||
stateRef.current = state;
|
||||
// Keep latest conversations accessible from signal-channel closures without
|
||||
@@ -243,6 +266,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setRemoteParticipants([]);
|
||||
setRemoteScreenShares([]);
|
||||
setIsScreenSharing(false);
|
||||
setIsCameraEnabled(false);
|
||||
setIsDeafened(false);
|
||||
deafenedActive = false;
|
||||
setRemoteDeafen({});
|
||||
setIsE2EEActive(false);
|
||||
|
||||
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
|
||||
// stays in sync if the user stops sharing via the browser's native UI.
|
||||
r.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
@@ -506,6 +572,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
if (mediaKind === 'video') {
|
||||
try {
|
||||
await r.localParticipant.setCameraEnabled(true);
|
||||
setIsCameraEnabled(true);
|
||||
} catch (camErr: unknown) {
|
||||
console.error('setCameraEnabled failed', camErr);
|
||||
}
|
||||
@@ -650,23 +717,31 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
[myId, joinRoom, disconnectRoom],
|
||||
);
|
||||
|
||||
const acceptIncoming = useCallback(async () => {
|
||||
const s = stateRef.current;
|
||||
if (s.kind !== 'incoming' || !myId) return;
|
||||
const { callId, conversationId, mediaKind } = s;
|
||||
everConnectedRef.current = false;
|
||||
setLastCallConversationId(null);
|
||||
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
||||
try {
|
||||
await joinRoom(conversationId, mediaKind, callId);
|
||||
} catch (err: unknown) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'join failed',
|
||||
});
|
||||
await disconnectRoom();
|
||||
}
|
||||
}, [myId, joinRoom, disconnectRoom]);
|
||||
const acceptIncoming = useCallback(
|
||||
async (override?: CallKind) => {
|
||||
const s = stateRef.current;
|
||||
if (s.kind !== 'incoming' || !myId) return;
|
||||
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;
|
||||
setLastCallConversationId(null);
|
||||
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
||||
try {
|
||||
await joinRoom(conversationId, mediaKind, callId);
|
||||
} catch (err: unknown) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'join failed',
|
||||
});
|
||||
await disconnectRoom();
|
||||
}
|
||||
},
|
||||
[myId, joinRoom, disconnectRoom],
|
||||
);
|
||||
|
||||
const rejectIncoming = useCallback(() => {
|
||||
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;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
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 {
|
||||
await lp.setScreenShareEnabled(true, {
|
||||
audio: false,
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
width: ssParams.dims.width,
|
||||
height: ssParams.dims.height,
|
||||
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',
|
||||
});
|
||||
setIsScreenSharing(true);
|
||||
} catch (err: unknown) {
|
||||
console.error('setScreenShareEnabled failed', err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopScreenShare = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
const nextOn = !lp.isScreenShareEnabled;
|
||||
if (!lp.isScreenShareEnabled) return;
|
||||
try {
|
||||
const ssParams = getPresetParams(getScreenShareSettings().preset);
|
||||
await lp.setScreenShareEnabled(nextOn, {
|
||||
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
|
||||
? {
|
||||
resolution: {
|
||||
width: ssParams.dims.width,
|
||||
height: ssParams.dims.height,
|
||||
frameRate: ssParams.framerate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
contentHint: 'detail',
|
||||
});
|
||||
setIsScreenSharing(nextOn);
|
||||
await lp.setScreenShareEnabled(false);
|
||||
setIsScreenSharing(false);
|
||||
} catch (err: unknown) {
|
||||
console.error('setScreenShareEnabled failed', err);
|
||||
// User cancelled or permission denied — leave state as-is.
|
||||
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,
|
||||
isE2EEActive,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -1065,6 +1239,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
hangup,
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
toggleCamera,
|
||||
toggleDeafen,
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
@@ -1078,6 +1256,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
isMuted,
|
||||
isE2EEActive,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -1089,6 +1270,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
hangup,
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
toggleCamera,
|
||||
toggleDeafen,
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
@@ -1106,6 +1291,22 @@ export function useCall(): CallContextValue {
|
||||
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(
|
||||
track: RemoteTrack,
|
||||
_publication: RemoteTrackPublication,
|
||||
@@ -1117,6 +1318,7 @@ function attachTrack(
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute('playsinline', 'true');
|
||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||
if (deafenedActive) audio.muted = true;
|
||||
document.body.appendChild(audio);
|
||||
// Apply persisted sinkId so the element routes to the user's chosen
|
||||
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,10 +1,83 @@
|
||||
import type { Participant, Room } from 'livekit-client';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
import type { AudioTrack, Participant, Room } from 'livekit-client';
|
||||
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse
|
||||
// on participant tiles. Returns the set of currently speaking identities
|
||||
// (includes local participant when they're active).
|
||||
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
|
||||
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
|
||||
// `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> {
|
||||
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
@@ -14,20 +87,84 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = (speakers: Participant[]) => {
|
||||
const next = new Set<string>();
|
||||
for (const s of speakers) {
|
||||
if (s.identity) next.add(s.identity);
|
||||
const probes = new Map<string, Probe>();
|
||||
const lastActive = new Map<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIds(next);
|
||||
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.
|
||||
update(room.activeSpeakers ?? []);
|
||||
const bind = (p: Participant): void => {
|
||||
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 () => {
|
||||
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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user