8baac2fd1e
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>
1763 lines
60 KiB
TypeScript
1763 lines
60 KiB
TypeScript
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:
|
||
<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>
|
||
);
|
||
}
|