This commit is contained in:
83
packages/shared/src/components/voice/CameraGrid.tsx
Normal file
83
packages/shared/src/components/voice/CameraGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
packages/shared/src/components/voice/CameraTile.tsx
Normal file
182
packages/shared/src/components/voice/CameraTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/shared/src/components/voice/ScreenShareFlow.tsx
Normal file
41
packages/shared/src/components/voice/ScreenShareFlow.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
147
packages/shared/src/components/voice/ScreenSourcePickerModal.tsx
Normal file
147
packages/shared/src/components/voice/ScreenSourcePickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user