Files
ChatApp/apps/desktop/src/components/InCallPanel.tsx
T
byGalax 37becba7e2
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
  setAudioInputDevice / setAudioOutputDevice actions that hot-swap
  without reconnect. Output swap applies HTMLMediaElement.setSinkId
  to every attached remote-audio element (LiveKit's switchActiveDevice
  only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
  enumerateDevices, devicechange listener, permission-probe button.
  setSinkId-unsupported fallback is messaged but non-blocking

Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
  <main> pane — sidebar + chat-list stayed visible. Switched to
  fixed inset-0 z-[60] so the call overlays the whole window
  Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
  unreliable under Tauri WKWebView), portalled to document.body when
  active so no ancestor stacking context can clip it. Esc exits

ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
  leaving the "1 im Raum" fallback stuck after both peers left. Now
  schedules dismissLastCall as soon as othersIn.length === 0, with a
  3s grace window to absorb presence re-sync flicker

Bump tauri version 0.6.0 -> 0.7.0
2026-04-20 21:26:10 +02:00

590 lines
17 KiB
TypeScript

import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type CallMode, useCall } from '../context/CallContext';
import {
getPttSettings,
type PttSettings,
subscribePttSettings,
} from '../lib/pttSettings';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ScreenShareViewer } from './ScreenShareViewer';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
// The fullscreen variant absolute-positions itself over the conversation
// container so the rail+chat-list remain visible on the left.
interface Props {
conversation: ConversationSummary;
}
interface Tile {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
video: boolean;
sharing: boolean;
remoteSharing: boolean;
}
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
remoteScreenShares,
callMode,
focusedId,
toggleMute,
toggleScreenShare,
hangup,
setCallMode,
setFocusedId,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const active =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversation.id;
if (!active) return null;
const tiles = buildTiles({
conversation,
myId,
remoteIdentities: remoteParticipants.map((p) => p.identity),
isMuted,
isScreenSharing,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
});
const duration =
state.kind === 'connected'
? <LiveDuration startedAt={state.startedAt} />
: null;
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: remoteParticipants.length === 0
? 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];
const controls = (
<CallControls
muted={isMuted}
sharing={isScreenSharing}
video={false}
onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'}
/>
);
if (callMode === 'fullscreen') {
return (
<FullscreenCall
tiles={tiles}
speaker={speaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
setFocusedId(id);
}}
controls={controls}
/>
);
}
const title =
conversation.type === 'group'
? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—';
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%' }}
>
<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 items-center gap-2 text-sm font-semibold text-fg">
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{title}</span>
<span className="text-fg-muted/50" aria-hidden="true">·</span>
<span className="tabular-nums text-fg-muted">
{duration ?? statusLabel}
{state.kind !== 'connected' && (
<SpinnerIcon className="ml-1 inline h-3 w-3" />
)}
</span>
</div>
{isE2EEActive && (
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<LockIcon className="h-3 w-3" />
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
</div>
)}
</div>
<ModeToggles mode={callMode} onChange={setCallMode} />
</div>
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
compact
/>
{controls}
<PttHint />
</section>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
remoteIdentities: string[];
isMuted: boolean;
isScreenSharing: boolean;
remoteSharerIds: Set<string>;
}
function buildTiles({
conversation,
myId,
remoteIdentities,
isMuted,
isScreenSharing,
remoteSharerIds,
}: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities);
const out: Tile[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
out.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted,
video: false,
sharing: isScreenSharing,
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);
out.push({
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
video: false,
sharing,
remoteSharing: sharing,
});
}
return out;
}
function LiveDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hh = Math.floor(total / 3600);
const mm = Math.floor((total % 3600) / 60);
const ss = total % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
function ModeToggles({
mode,
onChange,
}: {
mode: CallMode;
onChange: (mode: CallMode) => void;
}) {
return (
<div className="flex items-center gap-1">
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
label="Vollbild"
>
<MaximizeIcon className="h-4 w-4" />
</ModeButton>
</div>
);
}
function ModeButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(active
? 'border-accent bg-accent text-accent-fg'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
{children}
</button>
);
}
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
track: import('livekit-client').RemoteTrack;
participantId: string;
participantName: string;
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
compact?: boolean;
}
function CallStage({
tiles,
speaker,
mode,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
onFocusTile,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.userId !== speaker.userId);
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)}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
/>
</div>
{others.length > 0 && (
<div className="flex h-[110px] 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}
e2ee={e2ee}
size="small"
onClick={() => onFocusTile(p.userId)}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid
const gridClass = gridColsFor(tiles.length);
return (
<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>
</div>
);
}
function FocusedTile({
tile,
e2ee,
speaking,
remoteScreenShares,
conversationMembers,
}: {
tile: Tile;
e2ee: boolean;
speaking: boolean;
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}
e2ee={e2ee}
focused
/>
</div>
);
}
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';
if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2';
}
// ---------------------------------------------------------------------------
// Fullscreen cinema mode
// ---------------------------------------------------------------------------
interface FullscreenProps {
tiles: Tile[];
speaker: Tile | undefined;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
activeSpeakers: Set<string>;
e2ee: boolean;
onExit: () => void;
onFocusTile: (id: string) => void;
controls: React.ReactNode;
}
function FullscreenCall({
tiles,
speaker,
remoteScreenShares,
conversationMembers,
activeSpeakers,
e2ee,
onExit: _onExit,
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);
}, []);
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
/>
</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}
/>
</div>
);
}
function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null;
return (
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
Push-to-Talk:&nbsp;
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
{ptt.keyLabel}
</kbd>
</p>
);
}