da85f0ba54
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)
138 lines
6.3 KiB
TypeScript
138 lines
6.3 KiB
TypeScript
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
|
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
|
|
|
interface Props {
|
|
conversation: ConversationSummary;
|
|
}
|
|
|
|
const PULSE_BG: Record<AvatarColorKey, string> = {
|
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
|
};
|
|
|
|
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
|
// visible and usable below (per user decision). Full-bleed overlay lives in
|
|
// the shell-level toast for cross-conversation ring notifications.
|
|
export function IncomingCallPanel({ conversation }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
|
const { friendships } = useFriendshipsContext();
|
|
|
|
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
|
|
|
const isGroup = conversation.type === 'group';
|
|
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
|
const callerName =
|
|
callerProfile?.displayName ??
|
|
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
|
'?';
|
|
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
|
const title = isGroup ? groupName ?? callerName : callerName;
|
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
|
const avatarUrl = isGroup
|
|
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
|
: callerProfile?.avatarUrl ?? null;
|
|
const colorKey = colorKeyFor(state.fromUserId);
|
|
const avatarTone = PULSE_BG[colorKey];
|
|
const mediaLabel =
|
|
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
|
|
|
return (
|
|
<section
|
|
role="region"
|
|
aria-label={t('app:call.incoming_title')}
|
|
className="relative overflow-hidden border-b border-line bg-surface-3"
|
|
>
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
|
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
|
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
|
{t('app:call.incoming_title')}
|
|
</p>
|
|
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
|
style={{ animationDelay: '1s' }}
|
|
/>
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt={title}
|
|
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={
|
|
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
|
avatarTone
|
|
}
|
|
>
|
|
{letter}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
|
<LockIcon className="h-3.5 w-3.5" />
|
|
<span>E2E verschlüsselt · {mediaLabel}</span>
|
|
{isGroup && (
|
|
<>
|
|
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
|
<span>
|
|
{t('app:call.incoming_group_from', {
|
|
name: callerName,
|
|
defaultValue: callerName + ' ruft an',
|
|
})}
|
|
</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={rejectIncoming}
|
|
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('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>
|
|
{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>
|
|
);
|
|
}
|