feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel
Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
- useCallPresence: bind presence callbacks only when we own subscribe
(Supabase forbids .on() after .subscribe() on shared dedup'd channels)
- useCallPresence: never removeChannel — channel is shared with CallContext
so tearing it down on ConversationHeader unmount killed live tracking
- ActiveCallBanner: lastCallConversationId fallback so banner shows
instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall
Bump tauri version 0.5.0 -> 0.6.0
This commit is contained in:
@@ -1,46 +1,60 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import { type CallMode, useCall } from '../context/CallContext';
|
||||
import {
|
||||
getPttSettings,
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
} from './icons';
|
||||
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;
|
||||
}
|
||||
|
||||
// Discord-style call widget rendered above the message list when the user is
|
||||
// in the current conversation's call. Shows participant avatars + controls.
|
||||
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,
|
||||
toggleMute,
|
||||
hangup,
|
||||
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' ||
|
||||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
|
||||
const remoteIds = new Set<string>(
|
||||
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
|
||||
);
|
||||
const tiles: ParticipantTileData[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
tiles.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
speaking: false,
|
||||
muted: isMuted,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteIds.has(m.userId)) continue;
|
||||
tiles.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
speaking: false,
|
||||
muted: false,
|
||||
});
|
||||
}
|
||||
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'
|
||||
@@ -86,268 +86,491 @@ 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];
|
||||
|
||||
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="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
|
||||
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="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="uppercase tracking-wide">{statusLabel}</span>
|
||||
{isE2EEActive && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
|
||||
title={t('app:call.e2ee_active_hint', {
|
||||
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
|
||||
})}
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
E2EE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{tiles.map((p) => (
|
||||
<ParticipantTile key={p.userId} {...p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{remoteScreenShares.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{remoteScreenShares.map((s) => {
|
||||
const member = conversation.members.find((m) => m.userId === s.participantId);
|
||||
return (
|
||||
<ScreenShareViewer
|
||||
key={s.track.sid ?? s.participantId}
|
||||
share={s}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? s.participantName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<ControlButton
|
||||
onClick={toggleMute}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isMuted}
|
||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
tone={isMuted ? 'amber' : 'neutral'}
|
||||
>
|
||||
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void toggleScreenShare()}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isScreenSharing}
|
||||
label={
|
||||
isScreenSharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
tone={isScreenSharing ? 'emerald' : 'neutral'}
|
||||
>
|
||||
{isScreenSharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
<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>
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void hangup()}
|
||||
label={t('app:call.hangup')}
|
||||
tone="rose"
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</ControlButton>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BuildArgs {
|
||||
conversation: ConversationSummary;
|
||||
myId: string | null;
|
||||
remoteIdentities: string[];
|
||||
isMuted: boolean;
|
||||
isScreenSharing: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
}
|
||||
|
||||
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
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(() => {
|
||||
if (!watching) return;
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const track: RemoteTrack = share.track;
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}, [share.track, watching]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
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
|
||||
ref={containerRef}
|
||||
<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={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
'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')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
{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>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 text-white shadow-lg transition group-hover:scale-105">
|
||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/90">
|
||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
<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 PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
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 (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<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 FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
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 (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 z-40 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,95 +579,11 @@ function PttHint() {
|
||||
useEffect(() => subscribePttSettings(setPtt), []);
|
||||
if (!ptt.enabled) return null;
|
||||
return (
|
||||
<p className="mt-3 text-center text-[11px] text-neutral-500">
|
||||
<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-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParticipantTileData {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
function ParticipantTile(p: ParticipantTileData) {
|
||||
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex w-28 flex-col items-center gap-2">
|
||||
<div
|
||||
className={
|
||||
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
|
||||
(p.speaking
|
||||
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
|
||||
: 'ring-white/10')
|
||||
}
|
||||
>
|
||||
{p.avatarUrl ? (
|
||||
<img
|
||||
src={p.avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{letter}</span>
|
||||
)}
|
||||
{p.muted && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
|
||||
>
|
||||
<MicOffIcon className="h-3 w-3 text-ink-950" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
|
||||
{p.displayName}
|
||||
{p.self && (
|
||||
<span className="ml-1 text-neutral-500">· Du</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlButtonProps {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'rose'
|
||||
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
|
||||
: tone === 'amber'
|
||||
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
|
||||
: tone === 'emerald'
|
||||
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
|
||||
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user