37becba7e2
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
590 lines
17 KiB
TypeScript
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:
|
|
<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>
|
|
);
|
|
}
|