meet/app/custom/CustomControlBar.tsx
2025-04-10 22:03:17 +05:30

288 lines
9.0 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo } from 'react';
import {
DisconnectButton,
useLayoutContext,
useLocalParticipant,
useRoomContext,
} from '@livekit/components-react';
import { Room, RoomEvent, Track } from 'livekit-client';
import { mergeClasses } from '@/lib/client-utils';
import { ToggleSource } from '@livekit/components-core';
import '../../styles/CustomControlBar.css';
import { CameraOffSVG, CameraOnSVG } from '../svg/camera';
import { MicOffSVG, MicOnSVG } from '../svg/mic';
import { ScreenShareOnSVG } from '../svg/screen-share';
import { useCustomLayoutContext } from '../contexts/layout-context';
import { useToast } from './toast/use-toast';
export function CustomControlBar() {
const room = useRoomContext();
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
const { localParticipant } = useLocalParticipant();
const [isRecordingRequestPending, setIsRecordingRequestPending] = useState(false);
const [participantCount, setParticipantCount] = useState(room.numParticipants);
const { dispatch } = useLayoutContext().widget;
const { isParticipantsListOpen, isChatOpen } = useCustomLayoutContext();
const { toast } = useToast();
const [recordingState, setRecordingState] = useState({
recording: { isRecording: false, recorder: '' },
});
const isRecording = useMemo(() => {
return recordingState.recording.isRecording;
}, [recordingState]);
const isSelfRecord = useMemo(() => {
return recordingState.recording.recorder === localParticipant.identity;
}, [recordingState]);
const [isFirstMount, setIsFirstMount] = useState(true);
useEffect(() => {
setIsFirstMount(false);
}, []);
useEffect(() => {
if (isRecording) {
toast({
title: 'Recording in progress. Please be aware this call is being recorded.',
});
} else {
if (isFirstMount) return;
toast({
title: 'Recorded ended. This call is no longer being recorded.',
});
}
}, [isRecording]);
function ToggleParticipantsList() {
if (isParticipantsListOpen.dispatch)
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
}
const toggleChat = () => {
if (isChatOpen.dispatch) isChatOpen.dispatch({ msg: 'toggle_chat' });
};
const toggleRoomRecording = async () => {
if (isRecordingRequestPending || (isRecording && !isSelfRecord)) return;
setIsRecordingRequestPending(true);
if (!isRecording)
toast({
title: 'Starting call recording. Please wait...',
});
else
toast({
title: 'Stopping call recording. Please wait...',
});
if (!recordingEndpoint) {
throw TypeError('No recording endpoint specified');
}
if (room.isE2EEEnabled) {
throw Error('Recording of encrypted meetings is currently not supported');
}
let response: Response;
const now = new Date(Date.now()).toISOString();
// const fileName = `${now}-${room.name}.mp4`;
if (isRecording) {
response = await fetch(
recordingEndpoint + `/stop?roomName=${room.name}&identity=${localParticipant.identity}`,
);
} else {
response = await fetch(
recordingEndpoint +
`/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`,
);
}
if (response.ok) {
} else {
console.error(
'Error handling recording request, check server logs:',
response.status,
response.statusText,
);
}
};
const updateRoomMetadata = (metadata: string) => {
const parsedMetadata = JSON.parse(metadata === '' ? '{}' : metadata);
setIsRecordingRequestPending(false);
setRecordingState({
recording: {
isRecording: parsedMetadata.recording.isRecording,
recorder: parsedMetadata.recording.recorder,
},
});
};
useEffect(() => {
if (room) {
const updateParticipantCount = () => {
setParticipantCount(room.numParticipants);
};
room.on(RoomEvent.Connected, updateParticipantCount);
room.on(RoomEvent.ParticipantConnected, updateParticipantCount);
room.on(RoomEvent.ParticipantDisconnected, updateParticipantCount);
room.on(RoomEvent.RoomMetadataChanged, updateRoomMetadata);
return () => {
room.off(RoomEvent.Connected, updateParticipantCount);
room.off(RoomEvent.ParticipantConnected, updateParticipantCount);
room.off(RoomEvent.ParticipantDisconnected, updateParticipantCount);
room.off(RoomEvent.RoomMetadataChanged, updateRoomMetadata);
};
}
}, [room]);
const handleCopyLink = () => {
navigator.clipboard
.writeText(window.location.href)
.then(() => alert('Link copied to clipboard!'))
.catch((err) => console.error('Failed to copy link:', err));
};
return (
<div className="custom-control-bar">
<div className="room-name-box">
<span className="room-name">{room.name}</span>
<button className="copy-link-button" onClick={handleCopyLink}>
{' '}
<span className="material-symbols-outlined">content_copy</span>
</button>
</div>
{/* Center: Control Buttons */}
<div className="control-bar control-buttons">
<TrackToggle source={Track.Source.Microphone} />
<TrackToggle source={Track.Source.Camera} />
<div
className={`control-btn ${isRecording ? '' : 'disabled'} ${isRecordingRequestPending || isRecording ? 'blinking' : ''}`}
onClick={toggleRoomRecording}
style={{
cursor: isRecordingRequestPending ? 'not-allowed' : 'pointer',
}}
>
{isRecording && isSelfRecord ? (
<span className="material-symbols-outlined">stop_circle</span>
) : (
<span className="material-symbols-outlined">radio_button_checked</span>
)}
</div>
<TrackToggle source={Track.Source.ScreenShare} />
<DisconnectButton className="end-call-button">
<span className="material-symbols-outlined">call_end</span>
</DisconnectButton>
</div>
{/* Participants, Settings btn */}
<div className="top-right-controls">
<div className="participant-box" onClick={ToggleParticipantsList}>
<span className="material-symbols-outlined">people</span>
<span className="participant-count">{participantCount}</span>
</div>
<div className={`control-btn`} onClick={toggleChat}>
<span className="material-symbols-outlined">chat</span>
</div>
<div
className="settings-box"
onClick={() => {
if (dispatch) dispatch({ msg: 'toggle_settings' });
}}
>
<span className="material-symbols-outlined">settings</span>
</div>
</div>
</div>
);
}
interface ControlButtonProps {
enabled?: boolean;
icon: React.ReactNode;
className?: string;
onClick?: () => void;
}
function ControlButton({ enabled = true, icon, className, onClick }: ControlButtonProps) {
return (
<button
className={mergeClasses('control-btn', className)}
data-lk-active={!enabled}
onClick={onClick}
>
{icon}
</button>
);
}
function TrackToggle({ source }: { source: ToggleSource }) {
const { enabled, toggle } = useTrackToggle({ source });
const isScreenShare = source === Track.Source.ScreenShare;
return (
<ControlButton
onClick={toggle}
enabled={isScreenShare ? !enabled : enabled}
icon={<TrackIcon trackSource={source} enabled={isScreenShare ? !enabled : enabled} />}
/>
);
}
interface TrackIconProps {
trackSource: ToggleSource;
enabled: boolean;
}
function TrackIcon({ trackSource, enabled }: TrackIconProps) {
switch (trackSource) {
case Track.Source.Camera:
return enabled ? <CameraOnSVG /> : <CameraOffSVG />;
case Track.Source.Microphone:
return enabled ? <MicOnSVG /> : <MicOffSVG />;
case Track.Source.ScreenShare:
return enabled ? (
<ScreenShareOnSVG />
) : (
<span
button-state="inactive"
data-lk-screen-share-enabled="true"
className="material-symbols-outlined"
>
stop_screen_share
</span>
);
}
}
// Custom hook for track toggle
function useTrackToggle({ source }: { source: ToggleSource }) {
const { localParticipant } = useRoomContext();
const toggle = () => {
switch (source) {
case Track.Source.Camera:
return localParticipant.setCameraEnabled(!enabled);
case Track.Source.Microphone:
return localParticipant.setMicrophoneEnabled(!enabled);
case Track.Source.ScreenShare:
return localParticipant.setScreenShareEnabled(!enabled);
}
};
const enabled = (() => {
switch (source) {
case Track.Source.Camera:
return localParticipant.isCameraEnabled;
case Track.Source.Microphone:
return localParticipant.isMicrophoneEnabled;
case Track.Source.ScreenShare:
return localParticipant.isScreenShareEnabled;
}
})();
return { enabled, toggle };
}