Bump
All checks were successful
Build and Release / build-and-release (push) Successful in 12m44s

This commit is contained in:
Bryan1029384756
2026-04-18 17:30:53 -05:00
parent 593eaba82e
commit 43cfbe65f3
22 changed files with 1244 additions and 36 deletions

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
import { CameraTile } from './CameraTile';
interface CameraGridProps {
channelId: string;
}
/**
* Grid of active webcam tiles for a voice channel. Pulls from the
* reactive `voiceStates` map — any participant with `isCameraOn: true`
* gets a tile. The local user's own preview is included when their
* LiveKit `isCameraEnabled` is on, independent of the server state,
* so the tile appears instantly on toggle without waiting for the
* Convex round-trip.
*/
export function CameraGrid({ channelId }: CameraGridProps) {
const voice = useVoice() as any;
const participants: any[] = useMemo(
() => (voice?.voiceStates && voice.voiceStates[channelId]) || [],
[voice?.voiceStates, channelId],
);
const localUserId: string | null =
typeof localStorage !== 'undefined'
? localStorage.getItem('userId')
: null;
const tiles = useMemo(() => {
const byId = new Map<
string,
{ userId: string; label: string; isLocal: boolean }
>();
for (const p of participants) {
if (!p?.isCameraOn) continue;
const isLocal = p.userId === localUserId;
byId.set(p.userId, {
userId: p.userId,
label: p.displayName || p.username || 'User',
isLocal,
});
}
// The local camera-on flag flips client-side immediately; the
// voiceStates round-trip can lag by a frame or two. Force the
// tile in for the local user so the preview appears right away.
if (voice?.isCameraOn && localUserId && !byId.has(localUserId)) {
const me = participants.find((p) => p.userId === localUserId);
byId.set(localUserId, {
userId: localUserId,
label: me?.displayName || me?.username || 'You',
isLocal: true,
});
}
return Array.from(byId.values());
}, [participants, localUserId, voice?.isCameraOn]);
if (tiles.length === 0) return null;
return (
<div
style={{
display: 'grid',
gridTemplateColumns:
tiles.length === 1
? '1fr'
: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 12,
padding: '0 16px 16px',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
}}
>
{tiles.map((t) => (
<CameraTile
key={t.userId}
userId={t.userId}
label={t.label}
isLocal={t.isLocal}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
interface CameraTileProps {
/** `identity` in LiveKit is the user's `userProfiles._id`. For the
* local user, we match the room's `localParticipant.identity` so
* the same tile component can render self-preview too. */
userId: string;
label: string;
isLocal?: boolean;
}
// LiveKit's Track.Source.Camera enum value — kept as a raw comparison
// so we don't need to import livekit-client here. Matches the pattern
// ScreenSharePreview already uses for Track.Source.ScreenShare = 3.
const TRACK_SOURCE_CAMERA = 1;
export function CameraTile({ userId, label, isLocal = false }: CameraTileProps) {
const voice = useVoice() as any;
const videoRef = useRef<HTMLVideoElement>(null);
const [hasTrack, setHasTrack] = useState(false);
useEffect(() => {
const room = voice?.room;
const video = videoRef.current;
if (!room || !video) {
setHasTrack(false);
return;
}
let attached = false;
let currentTrack: any = null;
const findParticipant = (): any | null => {
if (isLocal) return room.localParticipant ?? null;
// LiveKit keys remote participants by `identity` — we set
// that to the Convex userId on join, so the map lookup is a
// direct hit. Fall back to linear scan for safety.
const direct = room.remoteParticipants?.get(userId);
if (direct) return direct;
for (const p of room.remoteParticipants?.values?.() ?? []) {
if ((p as any).identity === userId) return p;
}
return null;
};
const findCameraTrack = (p: any): any | null => {
if (!p?.trackPublications) return null;
for (const pub of p.trackPublications.values()) {
if (pub?.source === TRACK_SOURCE_CAMERA || pub?.source === 'camera') {
const t = pub.videoTrack || pub.track;
if (t) return t;
}
}
return null;
};
const attach = () => {
const p = findParticipant();
const track = findCameraTrack(p);
if (!track) {
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
attached = false;
currentTrack = null;
setHasTrack(false);
}
return;
}
if (attached && currentTrack === track) return;
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
}
try {
track.attach(video);
currentTrack = track;
attached = true;
setHasTrack(true);
} catch {
setHasTrack(false);
}
};
attach();
const retry = window.setTimeout(attach, 250);
const onPublished = () => attach();
const onUnpublished = () => attach();
const onSubscribed = () => attach();
const onUnsubscribed = () => attach();
if (isLocal) {
room.localParticipant.on?.('localTrackPublished', onPublished);
room.localParticipant.on?.('localTrackUnpublished', onUnpublished);
} else {
room.on?.('trackSubscribed', onSubscribed);
room.on?.('trackUnsubscribed', onUnsubscribed);
room.on?.('trackPublished', onPublished);
room.on?.('trackUnpublished', onUnpublished);
}
return () => {
window.clearTimeout(retry);
if (isLocal) {
room.localParticipant.off?.('localTrackPublished', onPublished);
room.localParticipant.off?.('localTrackUnpublished', onUnpublished);
} else {
room.off?.('trackSubscribed', onSubscribed);
room.off?.('trackUnsubscribed', onUnsubscribed);
room.off?.('trackPublished', onPublished);
room.off?.('trackUnpublished', onUnpublished);
}
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
}
};
}, [voice?.room, voice?.activeChannelId, userId, isLocal, voice?.voiceStates]);
return (
<div
style={{
position: 'relative',
aspectRatio: '16 / 9',
background: '#000',
borderRadius: 12,
overflow: 'hidden',
border: '1px solid var(--background-tertiary)',
}}
>
<video
ref={videoRef}
autoPlay
playsInline
// Mute the local preview so the user doesn't hear
// themselves; remote tiles don't carry audio (that
// rides the separate mic track LiveKit subscribes to),
// but keeping `muted` here is defensive.
muted={isLocal}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
// Local preview is mirrored so the user sees
// themselves like a mirror — matches Discord/Zoom.
transform: isLocal ? 'scaleX(-1)' : undefined,
}}
/>
{!hasTrack && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
fontSize: 13,
}}
>
Waiting for video
</div>
)}
<div
style={{
position: 'absolute',
bottom: 8,
left: 8,
padding: '4px 10px',
borderRadius: 999,
background: 'rgba(0, 0, 0, 0.55)',
color: '#fff',
fontSize: 12,
fontWeight: 600,
pointerEvents: 'none',
}}
>
{label}
{isLocal ? ' (you)' : ''}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
/**
* ScreenShareFlow — global host for the two-step screen-share UI.
* Reads `screenShareUI` from VoiceContext and renders either the
* settings modal or the source picker. Mounted at app level so the
* flow survives view switches mid-pick.
*/
import { useVoice } from '../../contexts/VoiceContext';
import { ScreenShareSettingsModal } from './ScreenShareSettingsModal';
import { ScreenSourcePickerModal } from './ScreenSourcePickerModal';
export function ScreenShareFlow() {
const voice = useVoice() as any;
const ui = voice?.screenShareUI;
if (!ui) return null;
if (ui.step === 'settings') {
return (
<ScreenShareSettingsModal
initial={ui.initial}
onCancel={voice.cancelScreenShareUI}
onConfirm={voice.startScreenShareWithSettings}
/>
);
}
if (ui.step === 'picker') {
// Back returns to the settings modal pre-filled with the
// settings the user just confirmed (already persisted to prefs
// in startScreenShareWithSettings).
return (
<ScreenSourcePickerModal
includeAudio={ui.settings.includeAudio}
onCancel={voice.cancelScreenShareUI}
onBack={() => voice.setScreenSharing(true)}
onConfirm={voice.finishScreenSharePick}
/>
);
}
return null;
}

View File

@@ -0,0 +1,117 @@
.body {
padding: 4px 22px 18px;
display: flex;
flex-direction: column;
gap: 18px;
min-width: 480px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionLabel {
font-size: 0.875rem;
font-weight: 700;
color: var(--text-primary);
text-transform: none;
letter-spacing: 0;
}
.segmented {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.segment {
flex: 1 1 0;
min-width: 72px;
padding: 8px 12px;
border: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.06));
border-radius: 6px;
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.segment:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.segmentActive {
background: var(--brand-experiment, #5865f2);
border-color: var(--brand-experiment, #5865f2);
color: #fff;
}
.segmentActive:hover {
background: var(--brand-experiment, #5865f2);
}
.toggleRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 8px;
background: var(--background-tertiary, #1e1f22);
}
.toggleText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.toggleSubtitle {
font-size: 0.75rem;
color: var(--text-tertiary, #949ba4);
}
.toggle {
flex-shrink: 0;
width: 44px;
height: 24px;
border: none;
border-radius: 12px;
background: var(--background-accent, #4e5058);
position: relative;
cursor: pointer;
padding: 0;
transition: background-color 0.15s;
}
.toggleOn {
background: var(--brand-experiment, #5865f2);
}
.toggleKnob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s;
}
.toggleOn .toggleKnob {
transform: translateX(20px);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,116 @@
/**
* ScreenShareSettingsModal — first step of the screen-share flow.
* The user picks video quality, framerate, and whether to share
* audio. On confirm the parent advances to the source picker
* (Electron) or the browser's native getDisplayMedia picker (web).
*
* The chosen settings persist via userPreferences so the next
* share pre-fills with the user's last choice.
*/
import { useState } from 'react';
import { Modal, Button } from '@discord-clone/ui';
import styles from './ScreenShareSettingsModal.module.css';
export type ScreenShareQuality = '480p' | '720p' | '1080p' | '1440p' | '4k';
export type ScreenShareFps = 15 | 24 | 30 | 60;
export interface ScreenShareSettings {
quality: ScreenShareQuality;
fps: ScreenShareFps;
includeAudio: boolean;
}
interface Props {
initial: ScreenShareSettings;
onCancel: () => void;
onConfirm: (settings: ScreenShareSettings) => void;
}
const QUALITY_OPTIONS: ScreenShareQuality[] = ['480p', '720p', '1080p', '1440p', '4k'];
const FPS_OPTIONS: ScreenShareFps[] = [15, 24, 30, 60];
const QUALITY_LABEL: Record<ScreenShareQuality, string> = {
'480p': '480p',
'720p': '720p',
'1080p': '1080p',
'1440p': '1440p',
'4k': '4K',
};
export function ScreenShareSettingsModal({ initial, onCancel, onConfirm }: Props) {
const [quality, setQuality] = useState<ScreenShareQuality>(initial.quality);
const [fps, setFps] = useState<ScreenShareFps>(initial.fps);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeAudio);
const handleConfirm = () => {
onConfirm({ quality, fps, includeAudio });
};
return (
<Modal.Root isOpen onClose={onCancel} size="medium">
<Modal.Header title="Screen Share Settings" onClose={onCancel} />
<Modal.Content>
<div className={styles.body}>
<section className={styles.section}>
<div className={styles.sectionLabel}>Video Quality</div>
<div className={styles.segmented}>
{QUALITY_OPTIONS.map((q) => (
<button
key={q}
type="button"
className={`${styles.segment} ${q === quality ? styles.segmentActive : ''}`}
onClick={() => setQuality(q)}
>
{QUALITY_LABEL[q]}
</button>
))}
</div>
</section>
<section className={styles.section}>
<div className={styles.sectionLabel}>Frame Rate</div>
<div className={styles.segmented}>
{FPS_OPTIONS.map((f) => (
<button
key={f}
type="button"
className={`${styles.segment} ${f === fps ? styles.segmentActive : ''}`}
onClick={() => setFps(f)}
>
{f} FPS
</button>
))}
</div>
</section>
<section className={`${styles.section} ${styles.toggleRow}`}>
<div className={styles.toggleText}>
<div className={styles.sectionLabel}>Share Audio</div>
<div className={styles.toggleSubtitle}>
Include audio from your screen in the share
</div>
</div>
<button
type="button"
role="switch"
aria-checked={includeAudio}
className={`${styles.toggle} ${includeAudio ? styles.toggleOn : ''}`}
onClick={() => setIncludeAudio((v) => !v)}
>
<span className={styles.toggleKnob} />
</button>
</section>
<div className={styles.footer}>
<Button variant="secondary" size="md" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" size="md" onClick={handleConfirm}>
Start Sharing
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,157 @@
.body {
padding: 4px 22px 18px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 720px;
max-width: 920px;
}
.tabs {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.06));
padding-bottom: 8px;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #b5bac1);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.tab:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
}
.tabActive {
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
}
.tabCount {
font-size: 0.6875rem;
color: var(--text-tertiary, #949ba4);
background: var(--background-tertiary, #1e1f22);
padding: 1px 6px;
border-radius: 10px;
font-weight: 700;
}
.audioHint {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-tertiary, #949ba4);
}
.audioHint strong {
color: var(--text-primary);
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
max-height: 440px;
overflow-y: auto;
padding: 4px 2px;
}
.tile {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 2px solid transparent;
border-radius: 8px;
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
font: inherit;
cursor: pointer;
text-align: left;
transition: background-color 0.12s, border-color 0.12s, transform 0.12s;
}
.tile:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.tileActive {
border-color: var(--brand-experiment, #5865f2);
background: var(--background-modifier-selected, rgba(88, 101, 242, 0.12));
}
.thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background: var(--background-tertiary, #1e1f22);
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
width: 100%;
height: 100%;
object-fit: contain;
}
.tileLabel {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tileIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.tileName {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--text-tertiary, #949ba4);
font-size: 0.875rem;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,147 @@
/**
* ScreenSourcePickerModal — Electron-only step that runs after the
* settings modal. Lists available windows and screens with live
* thumbnails (refreshed every 2s) using the existing
* `desktopCapturer.getSources` IPC. The user picks one and clicks
* Share — the parent then stashes the source ID via
* `platform.screenCapture.setPendingSource` and triggers LiveKit's
* `setScreenShareEnabled(true)`, which our main-process
* setDisplayMediaRequestHandler resolves to that source.
*/
import { useEffect, useState } from 'react';
import { Modal, Button, Spinner } from '@discord-clone/ui';
import { usePlatform } from '../../platform';
import styles from './ScreenSourcePickerModal.module.css';
interface ScreenSource {
id: string;
name: string;
thumbnail: string;
appIcon: string | null;
}
interface Props {
includeAudio: boolean;
onCancel: () => void;
onBack: () => void;
onConfirm: (sourceId: string) => void;
}
type Tab = 'screens' | 'windows';
export function ScreenSourcePickerModal({ includeAudio, onCancel, onBack, onConfirm }: Props) {
const platform = usePlatform() as any;
const [sources, setSources] = useState<ScreenSource[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>('screens');
const [loading, setLoading] = useState(true);
useEffect(() => {
const sc = platform?.screenCapture;
if (!sc?.getScreenSources) return;
let cancelled = false;
const refresh = async () => {
try {
const list = await sc.getScreenSources();
if (cancelled) return;
setSources(Array.isArray(list) ? list : []);
setLoading(false);
} catch {
if (cancelled) return;
setSources([]);
setLoading(false);
}
};
refresh();
const t = window.setInterval(refresh, 2000);
return () => {
cancelled = true;
window.clearInterval(t);
};
}, [platform]);
const screens = sources.filter((s) => s.id.startsWith('screen:'));
const windows = sources.filter((s) => s.id.startsWith('window:'));
const visible = tab === 'screens' ? screens : windows;
const handleShare = () => {
if (selectedId) onConfirm(selectedId);
};
return (
<Modal.Root isOpen onClose={onCancel} size="large">
<Modal.Header title="Choose what to share" onClose={onCancel} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.tabs}>
<button
type="button"
className={`${styles.tab} ${tab === 'screens' ? styles.tabActive : ''}`}
onClick={() => { setTab('screens'); setSelectedId(null); }}
>
Screens
<span className={styles.tabCount}>{screens.length}</span>
</button>
<button
type="button"
className={`${styles.tab} ${tab === 'windows' ? styles.tabActive : ''}`}
onClick={() => { setTab('windows'); setSelectedId(null); }}
>
Applications
<span className={styles.tabCount}>{windows.length}</span>
</button>
<div className={styles.audioHint}>
Audio: <strong>{includeAudio ? 'On' : 'Off'}</strong>
</div>
</div>
{loading ? (
<div className={styles.loading}>
<Spinner />
</div>
) : visible.length === 0 ? (
<div className={styles.empty}>
No {tab === 'screens' ? 'screens' : 'open windows'} available.
</div>
) : (
<div className={styles.grid}>
{visible.map((s) => (
<button
key={s.id}
type="button"
className={`${styles.tile} ${selectedId === s.id ? styles.tileActive : ''}`}
onClick={() => setSelectedId(s.id)}
onDoubleClick={() => onConfirm(s.id)}
>
<div className={styles.thumbWrap}>
<img src={s.thumbnail} alt="" className={styles.thumb} />
</div>
<div className={styles.tileLabel}>
{s.appIcon && (
<img src={s.appIcon} alt="" className={styles.tileIcon} />
)}
<span className={styles.tileName} title={s.name}>{s.name}</span>
</div>
</button>
))}
</div>
)}
<div className={styles.footer}>
<Button variant="secondary" size="md" onClick={onBack}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={handleShare}
disabled={!selectedId}
>
Share
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -2,6 +2,7 @@ import { ArrowLeft, Hash } from '@phosphor-icons/react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useVoice } from '../../contexts/VoiceContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { CameraGrid } from './CameraGrid';
import { ScreenSharePreview } from './ScreenSharePreview';
import { VoiceGridLayout } from './VoiceGridLayout';
import { VoiceControlBar } from './VoiceControlBar';
@@ -85,6 +86,7 @@ export function VoiceCallView({ channelId, compact = false }: VoiceCallViewProps
<div className={styles.mainContent}>
<ScreenSharePreview />
<CameraGrid channelId={channelId} />
<VoiceGridLayout
participants={participants}
variant={compact ? 'compact' : 'default'}