1:1 calls
This commit is contained in:
@@ -10,6 +10,10 @@ import styles from './VoiceCallView.module.css';
|
||||
|
||||
interface VoiceCallViewProps {
|
||||
channelId: string;
|
||||
/** DM calls render this stacked above the chat and already have
|
||||
* the DM header above them — suppress the in-call header so the
|
||||
* channel name isn't shown twice. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface RawParticipant {
|
||||
@@ -22,7 +26,7 @@ interface RawParticipant {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
export function VoiceCallView({ channelId, compact = false }: VoiceCallViewProps) {
|
||||
const voice = useVoice();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
@@ -57,29 +61,34 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
aria-label="Back to channel list"
|
||||
>
|
||||
<ArrowLeft size={22} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.channelInfoContainer}>
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
<span className={styles.channelName}>{channelName}</span>
|
||||
{!compact && (
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
aria-label="Back to channel list"
|
||||
>
|
||||
<ArrowLeft size={22} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.channelInfoContainer}>
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
<span className={styles.channelName}>{channelName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRightSection} />
|
||||
</div>
|
||||
<div className={styles.headerRightSection} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<ScreenSharePreview />
|
||||
<VoiceGridLayout participants={participants} />
|
||||
<VoiceGridLayout
|
||||
participants={participants}
|
||||
variant={compact ? 'compact' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controlBarContainer}>
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Compact (DM) layout ────────────────────────────────────────
|
||||
A centered row of circular avatars sized by VoiceGridLayout's
|
||||
ResizeObserver. */
|
||||
.compactContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compactRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: nowrap;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
--voice-grid-columns: 1;
|
||||
--voice-grid-gap: 12px;
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { VoiceParticipantTile, type VoiceParticipantTileData } from './VoiceParticipantTile';
|
||||
import styles from './VoiceGridLayout.module.css';
|
||||
|
||||
interface VoiceGridLayoutProps {
|
||||
participants: VoiceParticipantTileData[];
|
||||
/** `compact` drops the 16:9 tile grid in favour of a centered row
|
||||
* of circular avatars — used by DM calls where the call stage
|
||||
* shares the viewport with the text chat. */
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
export function VoiceGridLayout({ participants }: VoiceGridLayoutProps) {
|
||||
const COMPACT_MAX_AVATAR = 120;
|
||||
const COMPACT_MIN_AVATAR = 48;
|
||||
const COMPACT_GAP = 20;
|
||||
const COMPACT_PADDING_X = 24;
|
||||
const COMPACT_PADDING_Y = 24;
|
||||
|
||||
export function VoiceGridLayout({ participants, variant = 'default' }: VoiceGridLayoutProps) {
|
||||
const tileCount = participants.length;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [compactAvatarSize, setCompactAvatarSize] = useState(COMPACT_MAX_AVATAR);
|
||||
|
||||
// Fit circular avatars to the available stage: shrink when the
|
||||
// stage is short (user dragged the splitter toward 25%) or when
|
||||
// there are many participants. Runs off ResizeObserver so the
|
||||
// size tracks live drags of the stage height.
|
||||
useLayoutEffect(() => {
|
||||
if (variant !== 'compact') return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const compute = () => {
|
||||
const w = el.clientWidth;
|
||||
const h = el.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const byWidth =
|
||||
(w - 2 * COMPACT_PADDING_X - (tileCount - 1) * COMPACT_GAP) /
|
||||
Math.max(1, tileCount);
|
||||
const byHeight = h - 2 * COMPACT_PADDING_Y;
|
||||
const next = Math.max(
|
||||
COMPACT_MIN_AVATAR,
|
||||
Math.min(COMPACT_MAX_AVATAR, Math.floor(Math.min(byWidth, byHeight))),
|
||||
);
|
||||
setCompactAvatarSize(next);
|
||||
};
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [variant, tileCount]);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div ref={containerRef} className={styles.compactContainer}>
|
||||
<div className={styles.compactRow}>
|
||||
{participants.map((p) => (
|
||||
<VoiceParticipantTile
|
||||
key={p.userId}
|
||||
participant={p}
|
||||
variant="compact"
|
||||
compactSize={compactAvatarSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.gridContainer}>
|
||||
|
||||
@@ -10,6 +10,49 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Compact (DM) tile ──────────────────────────────────────────
|
||||
The DM call stage shows each user as a floating circular avatar
|
||||
with no tile chrome. Speaking state becomes a green ring around
|
||||
the circle; mute becomes a red badge pinned to the bottom-right
|
||||
corner (matches Discord's DM-call styling). */
|
||||
.compactTile {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
transition:
|
||||
box-shadow 0.4s,
|
||||
outline-color 0.4s;
|
||||
transition-delay: 0.5s;
|
||||
}
|
||||
|
||||
.compactTile[data-speaking='true'] {
|
||||
box-shadow: 0 0 0 3.5px var(--voice-status-success);
|
||||
transition-delay: 0s;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
.compactMuteBadge {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28%;
|
||||
height: 28%;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--voice-status-danger);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -13,11 +13,41 @@ export interface VoiceParticipantTileData {
|
||||
|
||||
interface VoiceParticipantTileProps {
|
||||
participant: VoiceParticipantTileData;
|
||||
/** DM calls render a round-avatar-only variant: no tile chrome,
|
||||
* no name strip, and a mute badge overlayed on the avatar.
|
||||
* Speaking state becomes a ring around the circle. */
|
||||
variant?: 'default' | 'compact';
|
||||
/** Pixel size for the compact circular avatar. Callers that lay
|
||||
* tiles out side-by-side can pass a smaller value to fit the
|
||||
* stage height. */
|
||||
compactSize?: number;
|
||||
}
|
||||
|
||||
export function VoiceParticipantTile({ participant }: VoiceParticipantTileProps) {
|
||||
export function VoiceParticipantTile({
|
||||
participant,
|
||||
variant = 'default',
|
||||
compactSize = 96,
|
||||
}: VoiceParticipantTileProps) {
|
||||
const { username, avatarUrl, isMuted, isSpeaking } = participant;
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className={styles.compactTile}
|
||||
data-speaking={isSpeaking ? 'true' : 'false'}
|
||||
data-muted={isMuted ? 'true' : 'false'}
|
||||
style={{ width: compactSize, height: compactSize }}
|
||||
>
|
||||
<Avatar src={avatarUrl} fallback={username} size={compactSize} />
|
||||
{isMuted && (
|
||||
<span className={styles.compactMuteBadge} aria-label="Muted">
|
||||
<MicrophoneSlash size={Math.max(12, Math.round(compactSize * 0.22))} weight="fill" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.lkParticipantTile}
|
||||
|
||||
Reference in New Issue
Block a user