Files
ChatApp/apps/desktop/src/components/InCallPanel.tsx
T
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00

1763 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, 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 {
listSounds,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
import { UserProfilePopover } from './UserProfilePopover';
// 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 {
// `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;
// LiveKit-measured connection quality for this participant. Drives the
// Discord-style Wifi badge on user-tiles. Undefined for screen-tiles.
connectionQuality?: TileConnectionQuality;
// Discord-style host marker — only true for the call initiator's tile in
// group calls. Drives the crown.
isHost: boolean;
// True iff the user pinned this tile via right-click → "Anpinnen".
// Mirrors the value of CallContext.focusedId for this tile id.
pinned: boolean;
// True iff this user-tile's owner is currently publishing a screen share.
// Drives the LIVE pill on the avatar tile so the share-tile next to it
// visually belongs to the same person. Always false on screen-kind tiles.
streaming: boolean;
}
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
callMode,
focusedId,
toggleMute,
stopScreenShare,
toggleCamera,
toggleDeafen,
hangup,
setCallMode,
setFocusedId,
micError,
clearMicError,
retryMic,
dismissedShareUserIds,
connectionQualities,
callHostId,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
// Discord-style screen-share configure modal. Opens when the user clicks
// the Share button while not yet sharing — collects FPS/resolution/audio
// before triggering the OS source picker.
const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
// controls bar can show an "active" state without polling. Captions
// broadcasting is wired in CallContext via useLiveCaptions; this only
// tracks the toggle state for the button.
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
() => getLiveCaptionsSettings().enabled,
);
useEffect(
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
[],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
if (
(e.ctrlKey || e.metaKey) &&
e.shiftKey &&
!e.altKey &&
e.code === 'KeyS'
) {
e.preventDefault();
setStatsOverlayOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const [volumeMenu, setVolumeMenu] = useState<
{
userId: string;
displayName: string;
x: number;
y: number;
tileId: string;
self: boolean;
} | null
>(null);
const [shareMenu, setShareMenu] = useState<
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
>(null);
// Discord-style profile popover triggered from the right-click context menu
// on a participant tile. Anchored at the same coords as the volume menu.
const [profileMenu, setProfileMenu] = useState<
{ userId: string; x: number; y: number } | null
>(null);
// Soundboard-count so the in-call bar only surfaces the music button when
// the user actually has something to play. Matches Discord's "hide soundboard
// when empty" behaviour — no point dangling a button that opens to a blank
// "Keine Sounds" popover. Subscribes live so a sound added mid-call makes
// the button pop in without reopening the call.
const [soundboardCount, setSoundboardCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setSoundboardCount(all.length);
} catch {
if (!cancelled) setSoundboardCount(0);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
// Close the popover if the user just cleared their last sound while it was
// open — keeps the panel from lingering over an empty list.
useEffect(() => {
if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]);
// Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can
// stop your own share from the control bar.
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
e.preventDefault();
if (tile.kind === 'user') {
// Self-tile gets the pin row only — there's no remote volume to control.
setShareMenu(null);
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
tileId: tile.id,
self: tile.self,
});
return;
}
// Self screen-tiles: same — pin row is still useful, share menu is not.
if (tile.self) return;
// Screen-tile. Check whether the participant has a published screen-share
// audio track so the menu can hide the volume/mute rows when there's
// nothing to control.
const participant = remoteParticipants.find((p) => p.identity === tile.userId);
let hasAudio = false;
if (participant) {
for (const pub of participant.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio) {
hasAudio = true;
break;
}
}
}
setVolumeMenu(null);
setShareMenu({
userId: tile.userId,
displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
hasAudio,
x: e.clientX,
y: e.clientY,
});
};
const active =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversation.id;
if (!active) return null;
const tiles = buildTiles({
conversation,
myId,
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
// Sharer ids that survived the user's dismiss-set. If the user did
// "Zuschauen beenden" on someone's share, they drop out of the tile
// grid until that sharer stops + restarts (TrackUnsubscribed clears
// dismissedShareUserIds — see CallContext).
remoteSharerIds: new Set(
remoteScreenShares
.map((s) => s.participantId)
.filter((id) => !dismissedShareUserIds.has(id)),
),
connectionQualities,
// Discord-style host crown — only show in group calls (>2 members).
// 1:1s have no concept of "host" so the crown stays hidden.
hostUserId: conversation.members.length > 2 ? callHostId : null,
// Pin marker — focusedId === tile.id means the user explicitly pinned.
pinnedTileId: focusedId,
});
// Duration keeps ticking during reconnecting so the user sees the call is
// still alive — but the status label below takes precedence in the header
// so the "Verbinde neu…" message is prominent, not buried under the timer.
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')
: state.kind === 'reconnecting'
? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
: remoteParticipants.length === 0
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// Discord-style precedence:
// 1. focusedId set → 'focus', that tile is the stage.
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
// 4. no shares, no pin → 'equal-grid'.
type StageLayout =
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
const stageLayout: StageLayout = (() => {
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
return { kind: 'focus', bigTileId: focusedId };
}
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
if (shareIds.length === 1 && shareIds[0]) {
return { kind: 'focus', bigTileId: shareIds[0] };
}
return { kind: 'equal-grid' };
})();
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
// stays consistent with the layout decision.
const bigTile =
stageLayout.kind === 'focus'
? tiles.find((t) => t.id === stageLayout.bigTileId)
: undefined;
const controls = (
<CallControls
muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
sharing={isScreenSharing}
video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute}
// Click opens the Discord-style source picker (thumbnails + quality +
// audio). Clicking again while a share is live stops it. Right-click
// also opens the picker in case the user wants to swap sources.
onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
// Discord-parity: open the configure-then-share modal instead of
// jumping straight into the OS picker. Modal calls
// startScreenShare with the chosen overrides on confirm.
setSharePickerOpen(true);
}
}}
onShareContextMenu={(e) => {
e.preventDefault();
if (!isScreenSharing) setSharePickerOpen(true);
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onOpenParticipants={() => setParticipantsOpen((v) => !v)}
participantsOpen={participantsOpen}
// Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count
// aus IndexedDB geladen ist (null), auch nicht rendern — verhindert
// einen Flash des Buttons beim Call-Start wenn der User eh keine
// Sounds hat.
{...(soundboardCount && soundboardCount > 0
? {
onToggleSoundboard: () => setSoundboardOpen((v) => !v),
soundboardOpen,
}
: {})}
// Live-Captions only when SpeechRecognition is available in the
// runtime — Firefox lacks it, would just show a dead button.
{...(isLiveCaptionsSupported()
? {
onToggleCaptions: () =>
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
captionsOn: captionsEnabled,
}
: {})}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
/>
);
// Only participant-tiles feed the popover (screen-share tiles aren't
// people). Own row is always first, rest follows conversation order.
const participantRows: ParticipantRow[] = tiles
.filter((t) => t.kind === 'user')
.map((t) => ({
userId: t.userId,
displayName: t.displayName,
avatarUrl: t.avatarUrl,
self: t.self,
muted: t.muted,
deafened: t.deafened,
}));
if (callMode === 'fullscreen') {
return (
<>
{micError && (
<div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
<div className="pointer-events-auto max-w-[520px] w-full">
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
</div>
</div>
)}
<FullscreenCall
tiles={tiles}
speaker={bigTile}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
// Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openTileContextMenu}
controls={controls}
// Any active popover / menu / banner pins the controls so the user
// can interact with them without the chrome fading out under their
// cursor while they're mid-action.
keepControlsVisible={
soundboardOpen ||
participantsOpen ||
volumeMenu !== null ||
shareMenu !== null ||
micError !== null
}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
// We're already in fullscreen here — toggling focusedId is enough,
// no mode-switch needed.
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
// Self-tiles get no slider — the volume control would adjust the
// remote volume of someone the local user isn't hearing.
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
? {}
: {
onShowProfile: () =>
setProfileMenu({
userId: volumeMenu.userId,
x: volumeMenu.x,
y: volumeMenu.y,
}),
})}
onClose={() => setVolumeMenu(null)}
/>
)}
{shareMenu && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
{profileMenu && (
<UserProfilePopover
userId={profileMenu.userId}
profile={
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
}
x={profileMenu.x}
y={profileMenu.y}
onClose={() => setProfileMenu(null)}
/>
)}
{sharePickerOpen && (
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
)}
{statsOverlayOpen && room && (
<CallStatsOverlay
room={room}
members={conversation.members}
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</>
);
}
const title =
conversation.type === 'group'
? 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 =
stageLayout.kind === '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 = stageLayout.kind === 'focus' ? '75%' : '50%';
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
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">
<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>
{micError && (
<MicErrorBanner
message={micError}
onRetry={() => void retryMic()}
onDismiss={clearMicError}
/>
)}
<CallStage
tiles={tiles}
speaker={bigTile}
stageLayout={stageLayout}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openTileContextMenu}
compact
/>
{controls}
<PttHint />
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
? {}
: {
onShowProfile: () =>
setProfileMenu({
userId: volumeMenu.userId,
x: volumeMenu.x,
y: volumeMenu.y,
}),
})}
onClose={() => setVolumeMenu(null)}
/>
)}
{profileMenu && (
<UserProfilePopover
userId={profileMenu.userId}
profile={
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
}
x={profileMenu.x}
y={profileMenu.y}
onClose={() => setProfileMenu(null)}
/>
)}
{shareMenu && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
<ParticipantsPopover
open={participantsOpen}
rows={participantRows}
activeSpeakers={activeSpeakers}
onClose={() => setParticipantsOpen(false)}
/>
{sharePickerOpen && (
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
)}
{statsOverlayOpen && room && (
<CallStatsOverlay
room={room}
members={conversation.members}
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section>
);
}
// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
<div className="pointer-events-auto">
<SoundboardPanel onClose={onClose} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
connectionQualities: Record<string, ConnectionQuality>;
/** Host userId (call initiator). Null in re-joined calls and 1:1 calls.
* Used to mark exactly one user's tile with the crown. */
hostUserId: string | null;
/** Tile id (e.g. "user:abc") the user has pinned via right-click. Null
* while no pin is active or while auto-tracking the active speaker. */
pinnedTileId: string | null;
}
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,
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
connectionQualities,
hostUserId,
pinnedTileId,
}: BuildArgs): Tile[] {
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;
const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined;
out.push({
kind: 'user',
id: 'user:' + myId,
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted || !micLive,
deafened: isDeafened,
video: isCameraEnabled,
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
...(myQuality ? { connectionQuality: myQuality } : {}),
isHost: hostUserId === myId,
pinned: pinnedTileId === 'user:' + myId,
streaming: isScreenSharing,
});
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,
isHost: false,
pinned: pinnedTileId === 'screen:' + myId,
streaming: false,
});
}
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
const rp = remoteById.get(m.userId);
if (!rp) continue;
const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined;
out.push({
kind: 'user',
id: 'user:' + m.userId,
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
// Peer's self-reported mute state via data channel. LiveKit's own
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
// output track stays published. See remoteMute broadcast in CallContext.
muted: remoteMute[m.userId] ?? false,
// Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled,
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
...(peerQuality ? { connectionQuality: peerQuality } : {}),
isHost: hostUserId === m.userId,
pinned: pinnedTileId === 'user:' + m.userId,
streaming: remoteSharerIds.has(m.userId),
});
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,
isHost: false,
pinned: pinnedTileId === 'screen:' + m.userId,
streaming: false,
});
}
}
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 === '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;
/** Discriminated layout decision driven by InCallPanel's StageLayout
* selector. Drives the bento-vs-grid-vs-focus render branch. */
stageLayout:
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
track: import('livekit-client').RemoteTrack;
participantId: string;
participantName: string;
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
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,
cinema,
onClick,
onDoubleClick,
onContextMenu,
}: {
tile: Tile;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
/** True when rendered inside FullscreenCall's big-tile slot. Drives
* chrome-suppression on the inner ScreenShareViewer so its toggle
* doesn't visually collide with the cinema-mode strip-hidden button. */
cinema?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
return <LocalSharePreview displayName={tile.displayName} {...(onClick ? { onClick } : {})} />;
}
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}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
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}
hideFullscreenToggle={cinema === true}
/>
</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)}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
isHost={tile.isHost}
pinned={tile.pinned}
streaming={tile.streaming}
{...(tile.connectionQuality ? { connectionQuality: tile.connectionQuality } : {})}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
}
function CallStage({
tiles,
speaker,
stageLayout,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
onFocusTile,
onTileContextMenu,
compact = false,
}: StageProps) {
if (stageLayout.kind === 'focus' && speaker) {
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}
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onDoubleClick={() => onFocusTile(speaker.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})}
/>
</div>
{others.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{others.map((p) => (
<div
key={p.id}
// Docked strip: thumbs grow to share the row width evenly
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
// with the share above; min-w keeps them readable when many.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
if (stageLayout.kind === 'bento') {
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
const bentoCols = gridColsFor(shares.length);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<div
className={
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
}
>
{shares.map((s) => (
<div
key={s.id}
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{webcams.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{webcams.map((w) => (
<div
key={w.id}
// Same docked-strip sizing as the focus branch above.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid (equal-grid fallthrough)
const gridClass = gridColsFor(tiles.length);
// aspect-video on every cell pushed the row past the section height on
// wide chat panels — a single cell at full width forced height = width
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
// below. Let cells fill grid tracks normally for n>=2, and only enforce
// a 16:9 silhouette (capped width, centred) for the solo-user case.
const isSolo = tiles.length === 1;
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{tiles.map((p) => (
<div
key={p.id}
className={
isSolo
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
: '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)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
</div>
);
}
function FocusedTile({
tile,
e2ee,
activeSpeakers,
remoteScreenShares,
conversationMembers,
onContextMenu,
onDoubleClick,
}: {
tile: Tile;
e2ee: boolean;
activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div className="h-full [&>div]:h-full">
<TileRender
tile={tile}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
{...(onContextMenu ? { onContextMenu } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
/>
</div>
);
}
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string {
// Discord-style: column count only. Cells are `aspect-video` so their
// height follows from their width, and the container centers them
// vertically when the row stack is shorter than the available area.
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';
if (n <= 6) return 'grid-cols-3';
if (n <= 9) return 'grid-cols-3';
return 'grid-cols-4';
}
// Promote self + active speakers to the front of the tile list. Stable
// otherwise. Used by both pagination (so page 1 always carries the most
// "useful" tiles) and active-speaker promotion in fullscreen.
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
const score = (t: Tile): number => {
if (t.self) return 3;
if (activeSpeakers.has(t.userId)) return 2;
if (t.kind === 'screen') return 1;
return 0;
};
return [...tiles].sort((a, b) => score(b) - score(a));
}
// ---------------------------------------------------------------------------
// 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;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode;
/** When true, controls stay visible regardless of mouse idle (used while
* a popover / menu / error banner is open). */
keepControlsVisible?: boolean;
}
const CONTROLS_IDLE_MS = 5_000;
function FullscreenCall({
tiles,
speaker,
remoteScreenShares,
conversationMembers,
activeSpeakers,
e2ee,
onExit: _onExit,
onFocusTile,
onTileContextMenu,
controls,
keepControlsVisible = false,
}: FullscreenProps) {
const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0);
// Discord-style auto-hide: controls fade out after 5s of mouse idle in
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
// popover opening via keepControlsVisible) brings them back immediately.
const [controlsVisible, setControlsVisible] = useState(true);
// Session-only toggle to collapse the participant strip while watching a
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
// participants" — gives the focused content the full fullscreen height.
const [stripHidden, setStripHidden] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
}, []);
useEffect(() => {
if (keepControlsVisible) {
setControlsVisible(true);
return;
}
let timer: number | null = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
const reset = () => {
setControlsVisible(true);
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(
() => setControlsVisible(false),
CONTROLS_IDLE_MS,
);
};
window.addEventListener('mousemove', reset);
window.addEventListener('touchstart', reset);
return () => {
if (timer !== null) window.clearTimeout(timer);
window.removeEventListener('mousemove', reset);
window.removeEventListener('touchstart', reset);
};
}, [keepControlsVisible]);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const fsShareIds = tiles
.filter((t) => t.kind === 'screen')
.map((t) => t.id);
const bentoMode = !hasFocus && fsShareIds.length >= 2;
const bentoShares = bentoMode
? tiles.filter((t) => fsShareIds.includes(t.id))
: [];
const bentoWebcams = bentoMode
? tiles.filter((t) => !fsShareIds.includes(t.id))
: [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages and prioritize active speakers onto page 1.
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
// whenever someone speaks.
const needsPagination = tiles.length > GRID_PAGE_SIZE;
const sortedGridTiles = useMemo(
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
[tiles, activeSpeakers, needsPagination],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
if (page >= pageCount) setPage(0);
}, [pageCount, page]);
const visibleTiles = sortedGridTiles.slice(
page * GRID_PAGE_SIZE,
(page + 1) * GRID_PAGE_SIZE,
);
const gridClass = gridColsFor(visibleTiles.length);
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
{/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar plus extra clearance so the tiles' bottom
name-chip (positioned `bottom-2` inside each tile) doesn't sit
directly underneath the controls — pb-24 was tight enough that
on wide screens with audio-only avatars the chip got eclipsed. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
{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)}
onDoubleClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
tile={speaker!}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
cinema
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
/>
</div>
{others.length > 0 && !stripHidden && (
<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="aspect-video h-full 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)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
)}
</>
) : bentoMode ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<div className="min-h-0 flex-1">
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
{bentoShares.map((s) => (
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{bentoWebcams.length > 0 && (
<div className="flex h-[180px] gap-2 overflow-x-auto">
{bentoWebcams.map((w) => (
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
) : (
<div className="min-h-0 flex-1 p-4">
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{visibleTiles.map((p) => {
const isSolo = visibleTiles.length === 1;
return (
<div
key={p.id}
// Same fix as docked equal-grid: aspect-video w-full on
// wide screens pushed cell height to ~ width × 9/16,
// which dragged the tile's bottom name-chip down behind
// the floating controls bar. For n>=2 fill the grid
// tracks normally; for solo, cap width + centre.
className={
isSolo
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
: '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)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
);
})}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
<button
type="button"
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Vorherige Seite"
>
</button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => (p + 1) % pageCount)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Nächste Seite"
>
</button>
</div>
)}
</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>
)}
{/* Hide-participant-strip toggle, only meaningful when there's a focus
+ extras to hide. Fades alongside the bottom control bar so idle
fullscreen still goes clean. */}
{hasFocus && others.length > 0 && (
<button
type="button"
onClick={() => setStripHidden((v) => !v)}
aria-pressed={stripHidden}
title={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
aria-label={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
className={
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(controlsVisible
? 'pointer-events-auto opacity-100 '
: 'pointer-events-none opacity-0 ') +
(stripHidden
? 'border-accent bg-accent/20 text-accent-fg'
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
}
>
<StripToggleIcon hidden={stripHidden} />
</button>
)}
<div
className={
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
(controlsVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0')
}
>
{controls}
</div>
</div>
);
}
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
// MicOff/HeadphonesOff naming convention used elsewhere.
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
</svg>
);
}
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>
);
}
// Non-terminal mic error banner. Shown inside the call panel when mic setup
// fails — the call itself stays alive, the user just can't be heard. Retry
// invokes the pipeline setup again with the current audioSettings so a
// permission granted in OS settings mid-call works without rejoin.
function MicErrorBanner({
message,
onRetry,
onDismiss,
}: {
message: string;
onRetry: () => void;
onDismiss: () => void;
}) {
return (
<div
role="alert"
className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
>
<MicOffIconInline />
<p className="flex-1 leading-relaxed">{message}</p>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
>
Erneut versuchen
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Schließen"
className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
// Preview card for the local sharer's screen-tile. Shows a "Live" label and,
// when the share carries a ScreenShareAudio track, a small overlay button to
// mute that outgoing audio publication without tearing down the share. The
// mute state lives in CallContext so the toggle survives re-renders and
// resets cleanly when the share ends.
function LocalSharePreview({
displayName,
onClick,
}: {
displayName: string;
onClick?: () => void;
}) {
const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall();
const [hasShareAudio, setHasShareAudio] = useState(false);
useEffect(() => {
if (!room) {
setHasShareAudio(false);
return;
}
const compute = () => {
const lp = room.localParticipant;
let found = false;
for (const pub of lp.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio) {
found = true;
break;
}
}
setHasShareAudio(found);
};
compute();
const onPub = () => compute();
room.on(RoomEvent.LocalTrackPublished, onPub);
room.on(RoomEvent.LocalTrackUnpublished, onPub);
return () => {
room.off(RoomEvent.LocalTrackPublished, onPub);
room.off(RoomEvent.LocalTrackUnpublished, onPub);
};
}, [room]);
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">{displayName}</div>
</div>
{hasShareAudio && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleOutgoingShareAudioMute();
}}
aria-pressed={outgoingShareAudioMuted}
aria-label={
outgoingShareAudioMuted
? 'Sound der Übertragung wieder senden'
: 'Sound der Übertragung stumm'
}
title={
outgoingShareAudioMuted
? 'Sound der Übertragung wieder senden'
: 'Sound der Übertragung stumm'
}
className={
'absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border backdrop-blur-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(outgoingShareAudioMuted
? 'border-rose-500/60 bg-rose-500/20 text-rose-600 hover:bg-rose-500/30 dark:text-rose-300'
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-200')
}
>
{outgoingShareAudioMuted ? <SpeakerOffIconInline /> : <SpeakerOnIconInline />}
</button>
)}
</div>
);
}
function SpeakerOnIconInline() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);
}
function SpeakerOffIconInline() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
);
}
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
function MicOffIconInline() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mt-0.5 shrink-0"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
<line x1="12" y1="19" x2="12" y2="23" />
</svg>
);
}