1:1 calls

This commit is contained in:
Bryan1029384756
2026-04-18 13:47:09 -05:00
parent 4a91415b2a
commit 938df217f4
10 changed files with 354 additions and 33 deletions

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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%;

View File

@@ -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}