feat: add call management features with useCall and useRoomCallMembers hooks
- Implemented useCall hook for managing call state and actions. - Created useRoomCallMembers hook to track active call members in a room. - Added useRtcConfig for fetching RTC configurations and LiveKit JWT. - Developed Audio settings component for managing audio devices and settings. - Introduced device selection and screen sharing options in the Audio settings. - Persisted audio settings in localStorage for user preferences.
This commit is contained in:
514
src/app/features/call/CallOverlay.tsx
Normal file
514
src/app/features/call/CallOverlay.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { useCall } from './useCall';
|
||||
import { CallState, CallType, ScreenShareTrackInfo } from './types';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/** Drag position state */
|
||||
interface DragPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Custom hook for draggable functionality */
|
||||
function useDraggable(initialPosition: DragPosition = { x: 0, y: 0 }) {
|
||||
const [position, setPosition] = useState<DragPosition>(initialPosition);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
};
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return undefined;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
const deltaX = e.clientX - dragStartRef.current.startX;
|
||||
const deltaY = e.clientY - dragStartRef.current.startY;
|
||||
setPosition({
|
||||
x: dragStartRef.current.x + deltaX,
|
||||
y: dragStartRef.current.y + deltaY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return { position, isDragging, handleMouseDown, dragRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Screen share icon SVG (Phosphor-style)
|
||||
*/
|
||||
function ScreenShareIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
|
||||
<path d="M12 9l-4 4h3v3h2v-3h3l-4-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Screen share off icon SVG (Phosphor-style)
|
||||
*/
|
||||
function ScreenShareOffIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
|
||||
<path d="M2 4l20 16" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fullscreen icon */
|
||||
function FullscreenIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Exit fullscreen icon */
|
||||
function ExitFullscreenIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Picture-in-Picture icon */
|
||||
function PipIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats call duration in MM:SS format
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays active call controls and video streams
|
||||
*/
|
||||
export function CallOverlay() {
|
||||
const { activeCall, endCall, toggleMute, toggleVideo, toggleScreenShare } = useCall();
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
|
||||
const [screenShareTrack, setScreenShareTrack] = useState<ScreenShareTrackInfo | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isPip, setIsPip] = useState(false);
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { position, isDragging, handleMouseDown } = useDraggable();
|
||||
|
||||
// Update screen share track state when activeCall changes
|
||||
useEffect(() => {
|
||||
if (activeCall?.remoteScreenTracks && activeCall.remoteScreenTracks.size > 0) {
|
||||
const [firstTrackInfo] = activeCall.remoteScreenTracks.values();
|
||||
setScreenShareTrack(firstTrackInfo || null);
|
||||
} else {
|
||||
setScreenShareTrack(null);
|
||||
}
|
||||
}, [activeCall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCall || activeCall.state !== CallState.Connected) {
|
||||
setDuration(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { startTime } = activeCall;
|
||||
const updateDuration = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setDuration(elapsed);
|
||||
};
|
||||
|
||||
updateDuration();
|
||||
const interval = setInterval(updateDuration, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeCall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localVideoRef.current && activeCall?.localStream) {
|
||||
localVideoRef.current.srcObject = activeCall.localStream;
|
||||
}
|
||||
}, [activeCall?.localStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteVideoRef.current && activeCall?.remoteStreams.size) {
|
||||
const [firstStream] = activeCall.remoteStreams.values();
|
||||
if (firstStream) {
|
||||
remoteVideoRef.current.srcObject = firstStream;
|
||||
}
|
||||
}
|
||||
}, [activeCall?.remoteStreams]);
|
||||
|
||||
// Attach screen share track to container using LiveKit's recommended approach
|
||||
useEffect(() => {
|
||||
const containerEl = document.getElementById('screen-share-container');
|
||||
|
||||
if (screenShareTrack?.track && containerEl) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const track = screenShareTrack.track as any;
|
||||
|
||||
if (typeof track.attach === 'function') {
|
||||
// Let LiveKit create and manage the video element
|
||||
const videoEl = track.attach() as HTMLVideoElement;
|
||||
videoEl.className = css.ScreenShareVideo;
|
||||
|
||||
// Clear any existing content
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.appendChild(videoEl);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('📺 Attached screen share - LiveKit created video element');
|
||||
|
||||
// Cleanup: detach when effect re-runs or unmounts
|
||||
return () => {
|
||||
if (typeof track.detach === 'function') {
|
||||
track.detach(videoEl);
|
||||
}
|
||||
if (videoEl.parentNode) {
|
||||
videoEl.parentNode.removeChild(videoEl);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No track, clear container
|
||||
if (containerEl && !screenShareTrack?.track) {
|
||||
containerEl.innerHTML = '';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [screenShareTrack]);
|
||||
|
||||
// Fullscreen toggle handler
|
||||
const handleToggleFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
try {
|
||||
if (!isFullscreen) {
|
||||
await containerRef.current.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Fullscreen error:', err);
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
|
||||
// PiP toggle handler
|
||||
const handleTogglePip = useCallback(async () => {
|
||||
const videoEl = remoteVideoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
try {
|
||||
if (!isPip && document.pictureInPictureEnabled) {
|
||||
await videoEl.requestPictureInPicture();
|
||||
setIsPip(true);
|
||||
} else if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
setIsPip(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('PiP error:', err);
|
||||
}
|
||||
}, [isPip]);
|
||||
|
||||
// Listen for fullscreen change events
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// Listen for PiP change events
|
||||
useEffect(() => {
|
||||
const videoEl = remoteVideoRef.current;
|
||||
if (!videoEl) return undefined;
|
||||
|
||||
const handlePipEnter = () => setIsPip(true);
|
||||
const handlePipLeave = () => setIsPip(false);
|
||||
|
||||
videoEl.addEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoEl.addEventListener('leavepictureinpicture', handlePipLeave);
|
||||
|
||||
return () => {
|
||||
videoEl.removeEventListener('enterpictureinpicture', handlePipEnter);
|
||||
videoEl.removeEventListener('leavepictureinpicture', handlePipLeave);
|
||||
};
|
||||
}, [activeCall]);
|
||||
|
||||
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isConnecting = activeCall.state === CallState.Connecting;
|
||||
const isVideoCall = activeCall.callType === CallType.Video;
|
||||
|
||||
// Check if anyone is sharing their screen
|
||||
const hasRemoteScreenShare = screenShareTrack !== null;
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall();
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const handleToggleVideo = () => {
|
||||
toggleVideo();
|
||||
};
|
||||
|
||||
const handleToggleScreenShare = async () => {
|
||||
if (isTogglingScreenShare) return;
|
||||
setIsTogglingScreenShare(true);
|
||||
try {
|
||||
await toggleScreenShare();
|
||||
} finally {
|
||||
setIsTogglingScreenShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
|
||||
style={!isFullscreen ? {
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
} : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={css.CallOverlayHeader}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon
|
||||
size="200"
|
||||
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
|
||||
/>
|
||||
<Text size="T300">
|
||||
{isConnecting ? 'Connecting...' : 'In Call'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Text size="T200" className={css.CallDuration}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
{isVideoCall && (
|
||||
<>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{isPip ? 'Exit PiP' : 'Picture-in-Picture'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleTogglePip} variant="Secondary" size="300">
|
||||
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PipIcon />
|
||||
</Box>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleToggleFullscreen} variant="Secondary" size="300">
|
||||
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||
</Box>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Remote Screen Share Display */}
|
||||
{hasRemoteScreenShare && screenShareTrack && (
|
||||
<div className={css.ScreenShareContainer}>
|
||||
<div className={css.ScreenShareLabel}>
|
||||
<ScreenShareIcon />
|
||||
<span>{screenShareTrack.participantId}</span>
|
||||
</div>
|
||||
{/* LiveKit will create and manage the video element inside this container */}
|
||||
<div id="screen-share-container" className={css.ScreenShareVideoContainer} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideoCall && (
|
||||
<div className={css.VideoContainer}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
ref={remoteVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls
|
||||
className={css.VideoElement}
|
||||
/>
|
||||
<div className={css.LocalVideo}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
controls
|
||||
className={css.VideoElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css.CallOverlayControls}>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleMute}
|
||||
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
|
||||
>
|
||||
<Icon
|
||||
size="300"
|
||||
src={activeCall.isMuted ? Icons.MicMute : Icons.Mic}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
{isVideoCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{activeCall.isVideoEnabled ? 'Turn Off Camera' : 'Turn On Camera'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleVideo}
|
||||
variant={activeCall.isVideoEnabled ? 'Secondary' : 'Critical'}
|
||||
>
|
||||
<Icon
|
||||
size="300"
|
||||
src={activeCall.isVideoEnabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleScreenShare}
|
||||
variant={activeCall.isScreenSharing ? 'Primary' : 'Secondary'}
|
||||
disabled={isTogglingScreenShare}
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
style={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{activeCall.isScreenSharing ? <ScreenShareOffIcon /> : <ScreenShareIcon />}
|
||||
</Box>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>End Call</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleEndCall}
|
||||
variant="Critical"
|
||||
>
|
||||
<Icon size="300" src={Icons.Phone} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user