From e34b8a39af0c9a336672b16696a5784e65acfba0 Mon Sep 17 00:00:00 2001 From: SujithThirumalaisamy Date: Mon, 7 Apr 2025 22:54:34 +0530 Subject: [PATCH] Added custom Layout for Focus --- app/custom/ParticipantList.tsx | 48 +-- app/custom/layout/CustomVideoLayout.tsx | 99 +++++++ app/custom/layout/FocusLayout.tsx | 24 ++ app/custom/layout/LayoutContextProvider.tsx | 85 ++++++ app/rooms/[roomName]/PageClientImpl.tsx | 9 +- lib/CustomVideoLayout.tsx | 116 -------- lib/ParticipantTile.tsx | 308 ++++++++++++-------- styles/ParticipantTile.css | 103 ------- styles/participant-tile.css | 37 ++- 9 files changed, 434 insertions(+), 395 deletions(-) create mode 100644 app/custom/layout/CustomVideoLayout.tsx create mode 100644 app/custom/layout/FocusLayout.tsx create mode 100644 app/custom/layout/LayoutContextProvider.tsx delete mode 100644 lib/CustomVideoLayout.tsx delete mode 100644 styles/ParticipantTile.css diff --git a/app/custom/ParticipantList.tsx b/app/custom/ParticipantList.tsx index d7b37be..85577ff 100644 --- a/app/custom/ParticipantList.tsx +++ b/app/custom/ParticipantList.tsx @@ -1,6 +1,5 @@ -import { useLayoutContext, useRoomContext } from '@livekit/components-react'; +import { useRoomContext } from '@livekit/components-react'; import { Participant, RemoteParticipant } from 'livekit-client'; -import { useEffect, useState } from 'react'; import { MicOffSVG, MicOnSVG } from '../svg/mic'; import { CameraOffSVG, CameraOnSVG } from '../svg/camera'; import { ScreenShareOnSVG } from '../svg/screen-share'; @@ -9,8 +8,7 @@ import { useCustomLayoutContext } from '../contexts/layout-context'; const ParticipantList = () => { const room = useRoomContext(); - const { localParticipant } = room; - const [participants, setParticipants] = useState>({}); + const { localParticipant, remoteParticipants } = room; const { isParticipantsListOpen } = useCustomLayoutContext(); function ToggleParticipantList() { @@ -18,42 +16,6 @@ const ParticipantList = () => { isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' }); } - useEffect(() => { - room.on('connectionStateChanged', () => { - setParticipants({}); - room.remoteParticipants.forEach((participant) => { - setParticipants((prev) => ({ ...prev, [participant.identity]: participant })); - }); - }); - room.on('participantConnected', (participant) => { - setParticipants((prev) => ({ ...prev, [participant.identity]: participant })); - }); - room.on('participantDisconnected', (participant) => { - setParticipants((prev) => { - const { [participant.identity]: toDelete, ...rest } = prev; - return rest; - }); - }); - room.on('participantNameChanged', (name, participant) => { - if (participant instanceof RemoteParticipant) - setParticipants((prev) => ({ ...prev, [participant.identity]: participant })); - }); - return () => { - room.off('participantConnected', (participant) => { - setParticipants((prev) => ({ ...prev, [participant.identity]: participant })); - }); - room.off('participantDisconnected', (participant) => { - setParticipants((prev) => { - const { [participant.identity]: toDelete, ...rest } = prev; - return rest; - }); - }); - room.off('participantNameChanged', (name, participant) => { - if (participant instanceof RemoteParticipant) - setParticipants((prev) => ({ ...prev, [participant.identity]: participant })); - }); - }; - }, []); return (
{
- {Object.values(participants).map((participant: RemoteParticipant) => { - return ; + {[...remoteParticipants.entries()].map((participant) => { + return ; })} ); @@ -134,7 +96,7 @@ const ParticipantItem: React.FC = ({ participant }) => { )} -
{participant.name}
+
{participant.name}
{participant.isScreenShareEnabled ? : <>} diff --git a/app/custom/layout/CustomVideoLayout.tsx b/app/custom/layout/CustomVideoLayout.tsx new file mode 100644 index 0000000..5ea6f16 --- /dev/null +++ b/app/custom/layout/CustomVideoLayout.tsx @@ -0,0 +1,99 @@ +import React, { useEffect } from 'react'; +import { + GridLayout, + useTracks, + Chat, + CarouselLayout, + usePinnedTracks, + useLayoutContext, +} from '@livekit/components-react'; +import { isEqualTrackRef } from '@livekit/components-core'; +import { Track, Room } from 'livekit-client'; +import { CustomControlBar } from '@/app/custom/CustomControlBar'; +import ParticipantList from '@/app/custom/ParticipantList'; +import { ParticipantTile } from '@/lib/ParticipantTile'; +import { SettingsMenu } from '@/lib/SettingsMenu'; +import { useCustomLayoutContext } from '@/app/contexts/layout-context'; +import '@/styles/Chat.css'; +import { FocusLayout, FocusLayoutContainer } from './FocusLayout'; + +interface CustomVideoLayoutProps { + room: Room; + roomName: string; +} + +export const CustomVideoLayout: React.FC = ({ room, roomName }) => { + const { isChatOpen, isParticipantsListOpen } = useCustomLayoutContext(); + const layoutContext = useLayoutContext(); + + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { onlySubscribed: false }, + ); + + const focusTrack = usePinnedTracks()[0]; + const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack)); + const test = usePinnedTracks(); + + useEffect(() => { + console.log({ test }); + }, [test]); + + return ( +
+
+
+ {!focusTrack ? ( + + + + ) : ( + + + + + {focusTrack && }{' '} + + )} + {' '} +
+
+ {isParticipantsListOpen.state && } + {isChatOpen.state && } + +
+ ); +}; + +export default CustomVideoLayout; diff --git a/app/custom/layout/FocusLayout.tsx b/app/custom/layout/FocusLayout.tsx new file mode 100644 index 0000000..372849c --- /dev/null +++ b/app/custom/layout/FocusLayout.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import type { ParticipantClickEvent } from '@livekit/components-core'; +import { ParticipantTile } from '@/lib/ParticipantTile'; + +export interface FocusLayoutContainerProps extends React.HTMLAttributes {} + +export function FocusLayoutContainer(props: FocusLayoutContainerProps) { + return ( +
+ {props.children} +
+ ); +} + +export interface FocusLayoutProps extends React.HTMLAttributes { + trackRef?: TrackReferenceOrPlaceholder; + + onParticipantClick?: (evt: ParticipantClickEvent) => void; +} + +export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) { + return ; +} diff --git a/app/custom/layout/LayoutContextProvider.tsx b/app/custom/layout/LayoutContextProvider.tsx new file mode 100644 index 0000000..f66e215 --- /dev/null +++ b/app/custom/layout/LayoutContextProvider.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { LayoutContextProvider, TrackReferenceOrPlaceholder } from '@livekit/components-react'; +import { CustomLayoutContextProvider } from '@/app/contexts/layout-context'; +import { PinAction } from '@livekit/components-react/dist/context/pin-context'; +import { PanelTopInactive, Pi } from 'lucide-react'; + +interface CustomVideoLayoutContextProviderProps { + children: React.ReactNode; +} + +export const CustomVideoLayoutContextProvider: React.FC = ({ + children, +}) => { + const [showChat, setShowChat] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [showParticipantsList, setShowParticipantsList] = useState(false); + const [pinnedTracks, setPinnedTracks] = useState(); + + const toggleParticipantsList = () => { + if (showChat) setShowChat(false); + setShowParticipantsList((prev) => !prev); + }; + + const toggleChat = () => { + if (showParticipantsList) setShowParticipantsList(false); + setShowChat((prev) => !prev); + }; + + const toggleSettings = () => { + setShowSettings((prev) => !prev); + }; + + return ( + + { + if (action.msg === 'set_pin') { + setPinnedTracks([action.trackReference]); + } + if (action.msg === 'clear_pin') { + setPinnedTracks([]); + } + }, + }, + widget: { + state: { + showChat, + showSettings, + unreadMessages: 0, + }, + dispatch: (action: any) => { + switch (action && action.msg) { + case 'toggle_settings': + toggleSettings(); + break; + case 'toggle_chat': + toggleChat(); + break; + case 'toggle_participants_list': + toggleParticipantsList(); + break; + } + }, + }, + }} + > + {children} + + + ); +}; diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx index 0f74fcb..a74e2b4 100644 --- a/app/rooms/[roomName]/PageClientImpl.tsx +++ b/app/rooms/[roomName]/PageClientImpl.tsx @@ -20,7 +20,8 @@ import { } from 'livekit-client'; import { useRouter } from 'next/navigation'; import '../../../styles/PageClientImpl.css'; -import { CustomVideoLayout } from '@/lib/CustomVideoLayout'; +import { CustomVideoLayoutContextProvider } from '@/app/custom/layout/LayoutContextProvider'; +import CustomVideoLayout from '@/app/custom/layout/CustomVideoLayout'; const CONN_DETAILS_ENDPOINT = process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details'; @@ -177,8 +178,10 @@ function VideoConferenceComponent(props: { audio={props.userChoices.audioEnabled} onDisconnected={handleOnLeave} > - - + + + + ); } diff --git a/lib/CustomVideoLayout.tsx b/lib/CustomVideoLayout.tsx deleted file mode 100644 index b03a9ba..0000000 --- a/lib/CustomVideoLayout.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react'; -import { GridLayout, useTracks, LayoutContextProvider, Chat } from '@livekit/components-react'; -import { Track, Room } from 'livekit-client'; -import { ParticipantTile } from './ParticipantTile'; -import { CustomControlBar } from '@/app/custom/CustomControlBar'; -import { SettingsMenu } from './SettingsMenu'; -import ParticipantList from '@/app/custom/ParticipantList'; -import { CustomLayoutContextProvider } from '@/app/contexts/layout-context'; -import '../styles/Chat.css'; - -interface CustomVideoLayoutProps { - room: Room; - roomName: string; -} - -export const CustomVideoLayout: React.FC = ({ room, roomName }) => { - const [showChat, setShowChat] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [showParticipantsList, setShowParticipantsList] = useState(false); - - const tracks = useTracks( - [ - { source: Track.Source.Camera, withPlaceholder: true }, - { source: Track.Source.ScreenShare, withPlaceholder: false }, - ], - { onlySubscribed: false }, - ); - - return ( - { - if (showChat) setShowChat(false); - setShowParticipantsList((prev) => !prev); - }, - }, - isChatOpen: { - state: showChat, - dispatch: () => { - if (showParticipantsList) setShowParticipantsList(false); - setShowChat((prev) => !prev); - }, - }, - }} - > - {}, - }, - widget: { - state: { - showChat, - showSettings, - unreadMessages: 0, - }, - dispatch: (action: any) => { - if ('msg' in action && action.msg === 'toggle_settings') { - setShowSettings((prev) => !prev); - } - if ('msg' in action && action.msg === 'toggle_chat') { - if (showParticipantsList) setShowParticipantsList(false); - setShowChat((prev) => !prev); - } - if ('msg' in action && action.msg === 'toggle_participants_list') { - if (showChat) setShowChat(false); - setShowParticipantsList((prev) => !prev); - } - }, - }, - }} - > -
-
-
- - - - {' '} -
-
- {showParticipantsList && } - {showChat && } - -
-
-
- ); -}; - -export default CustomVideoLayout; diff --git a/lib/ParticipantTile.tsx b/lib/ParticipantTile.tsx index d9e8137..745cef4 100644 --- a/lib/ParticipantTile.tsx +++ b/lib/ParticipantTile.tsx @@ -1,132 +1,192 @@ -import React, { useEffect, useState } from 'react'; +import * as React from 'react'; +import type { Participant } from 'livekit-client'; +import { Track } from 'livekit-client'; +import type { ParticipantClickEvent, TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { isTrackReference, isTrackReferencePinned } from '@livekit/components-core'; import { AudioTrack, - useTracks, VideoTrack, - useTrackRefContext, + ParticipantContext, + TrackRefContext, useEnsureTrackRef, - TrackRefContextIfNeeded, + useFeatureContext, + useIsEncrypted, + useMaybeLayoutContext, + useMaybeParticipantContext, + useMaybeTrackRefContext, + useParticipantTile, + ParticipantPlaceholder, + LockLockedIcon, + TrackMutedIndicator, + ParticipantName, + ConnectionQualityIndicator, + FocusToggle, + useIsSpeaking, + useIsMuted, + useTrackByName, + useTracks, } from '@livekit/components-react'; -import { Track, Participant } from 'livekit-client'; -import { isTrackReference } from '@livekit/components-core'; +import { getAvatarColor, getInitials } from './client-utils'; -function getAvatarColor(identity: string): string { - const colors = [ - '#4CAF50', - '#8BC34A', - '#CDDC39', - '#FFC107', - '#FF9800', - '#FF5722', - '#F44336', - '#E91E63', - '#9C27B0', - '#673AB7', - '#3F51B5', - '#2196F3', - '#03A9F4', - '#00BCD4', - '#009688', - ]; - - let hash = 0; - for (let i = 0; i < identity.length; i++) { - hash = identity.charCodeAt(i) + ((hash << 5) - hash); - } - - const index = Math.abs(hash) % colors.length; - return colors[index]; -} - -function getInitials(name: string): string { - if (!name) return '?'; - - const parts = name.split(' '); - if (parts.length === 1) { - return parts[0].charAt(0).toUpperCase(); - } - - return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); -} - -export interface ParticipantTileProps { - participant?: Participant; -} - -export const ParticipantTile: React.FC = ({ - participant: propParticipant, -}) => { - const trackRef = useTrackRefContext(); - const trackReference = useEnsureTrackRef(trackRef); - const participant = propParticipant || trackRef?.participant; - - if (!participant) return null; - - const [profilePictureUrl, setProfilePictureUrl] = useState(null); - - const microphoneTrack = useTracks([Track.Source.Microphone], { onlySubscribed: false }).filter( - (track) => track.participant.identity === participant.identity, - )[0]; - - const isSpeaking = participant.isSpeaking; - - useEffect(() => { - if (participant.metadata) { - try { - const metadata = JSON.parse(participant.metadata); - if (metadata.profilePictureUrl) { - setProfilePictureUrl(metadata.profilePictureUrl); - } - } catch (e) { - console.error('Failed to parse participant metadata', e); - } - } - }, [participant.metadata]); - - const isCameraEnabled = - (trackReference.source === Track.Source.Camera && !trackReference.publication?.isMuted) || - trackReference.source === Track.Source.ScreenShare; - - const hasMicrophone = !!microphoneTrack; - const isMicrophoneEnabled = hasMicrophone && !microphoneTrack.publication?.isMuted; - - const avatarColor = getAvatarColor(participant.identity); - const initials = getInitials(participant.name || participant.identity); - - return ( -
- {isTrackReference(trackReference) && isCameraEnabled ? ( -
- -
- ) : ( -
- {profilePictureUrl ? ( - {participant.name} - ) : ( - {initials} - )} -
- )} - -
- {isMicrophoneEnabled ? ( - <> - {isSpeaking ? ( - graphic_eq - ) : ( - mic - )} - - ) : ( - mic_off - )} - {participant.name || participant.identity} -
- - {hasMicrophone && microphoneTrack && } -
+export function ParticipantContextIfNeeded( + props: React.PropsWithChildren<{ + participant?: Participant; + }>, +) { + const hasContext = !!useMaybeParticipantContext(); + return props.participant && !hasContext ? ( + + {props.children} + + ) : ( + <>{props.children} ); -}; +} -export default ParticipantTile; +export function TrackRefContextIfNeeded( + props: React.PropsWithChildren<{ + trackRef?: TrackReferenceOrPlaceholder; + }>, +) { + const hasContext = !!useMaybeTrackRefContext(); + return props.trackRef && !hasContext ? ( + {props.children} + ) : ( + <>{props.children} + ); +} + +export interface ParticipantTileProps extends React.HTMLAttributes { + trackRef?: TrackReferenceOrPlaceholder; + disableSpeakingIndicator?: boolean; + + onParticipantClick?: (event: ParticipantClickEvent) => void; +} + +export const ParticipantTile: ( + props: ParticipantTileProps & React.RefAttributes, +) => React.ReactNode = React.forwardRef( + function ParticipantTile( + { + trackRef, + children, + onParticipantClick, + disableSpeakingIndicator, + ...htmlProps + }: ParticipantTileProps, + ref, + ) { + const trackReference = useEnsureTrackRef(trackRef); + const { + name, + identity, + metadata, + isEncrypted, + isSpeaking, + isMicrophoneEnabled, + isScreenShareEnabled, + } = trackReference.participant; + + const { elementProps } = useParticipantTile({ + htmlProps, + disableSpeakingIndicator, + onParticipantClick, + trackRef: trackReference, + }); + const layoutContext = useMaybeLayoutContext(); + + const autoManageSubscription = useFeatureContext()?.autoSubscription; + + const handleSubscribe = React.useCallback( + (subscribed: boolean) => { + if ( + trackReference.source && + !subscribed && + layoutContext && + layoutContext.pin.dispatch && + isTrackReferencePinned(trackReference, layoutContext.pin.state) + ) { + layoutContext.pin.dispatch({ msg: 'clear_pin' }); + } + }, + [trackReference, layoutContext], + ); + + const [profilePictureUrl, setProfilePictureUrl] = React.useState(null); + + React.useEffect(() => { + if (metadata) { + try { + const parsedMetadata = JSON.parse(metadata); + if (parsedMetadata.profilePictureUrl) { + setProfilePictureUrl(parsedMetadata.profilePictureUrl); + } + } catch (e) { + console.error('Failed to parse participant metadata', e); + } + } + }, [metadata]); + + const avatarColor = getAvatarColor(identity); + const initials = getInitials(name || identity); + + return ( +
+ + + {children ?? ( + <> + {isTrackReference(trackReference) && + (trackReference.publication?.kind === 'video' || + trackReference.source === Track.Source.Camera || + trackReference.source === Track.Source.ScreenShare) ? ( + + ) : ( + isTrackReference(trackReference) && ( + + ) + )} +
+
+ {profilePictureUrl ? ( + {name} + ) : ( + {initials} + )} +
+
+
+
+
+ {isMicrophoneEnabled ? ( + <> + {isSpeaking ? ( + graphic_eq + ) : ( + mic + )} + + ) : ( + mic_off + )} + {name || identity} +
+
+ +
+ + )} + +
+
+
+ ); + }, +); diff --git a/styles/ParticipantTile.css b/styles/ParticipantTile.css deleted file mode 100644 index 0a17ac7..0000000 --- a/styles/ParticipantTile.css +++ /dev/null @@ -1,103 +0,0 @@ -.participant-tile { - position: relative; - background-color: #1a242e; - border-radius: 5px; - overflow: hidden; - width: 100%; - height: 100%; -} - -.participant-tile.speaking { - border: 2px solid #618aff; -} - -.participant-info { - position: absolute; - bottom: 0; - left: 0; - display: flex; - align-items: center; - padding: 8px; - z-index: 10; -} - -.participant-name { - font-family: 'Roboto', sans-serif; - font-weight: 500; - font-size: 14px; - color: white; - margin-left: 5px; -} - -.mic-icon { - font-family: 'Material Symbols Outlined'; - font-size: 18px; -} - -.mic-on { - color: #ffffff; -} - -.mic-off { - color: #ff5252; -} - -.speaking-indicator { - position: absolute; - top: 10px; - right: 10px; - background-color: #618aff; - border-radius: 50%; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; -} - -.speaking-icon { - font-family: 'Material Symbols Outlined'; - color: white; - font-size: 16px; -} - -.avatar-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 100px; - height: 100px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - z-index: 5; -} - -.avatar-initials { - font-family: 'Roboto', sans-serif; - font-weight: 500; - font-size: 50px; - color: white; -} - -.avatar-image { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.video-container { - width: 100%; - height: 100%; -} - -.custom-control-bar.lk-control-bar { - padding: 6px !important; - border-top: 1px solid rgba(255, 255, 255, 0.1); - margin-right: -10px; - width: calc(100% + 10px); -} diff --git a/styles/participant-tile.css b/styles/participant-tile.css index af7d169..15187c1 100644 --- a/styles/participant-tile.css +++ b/styles/participant-tile.css @@ -33,6 +33,12 @@ font-family: 'Material Symbols Outlined'; font-size: 18px; padding: 0.1rem 0.3rem; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.25rem; } .mic-on { @@ -43,15 +49,11 @@ color: #ff5252; } -.speaking-icon { - background-color: #618aff; - border-radius: 50%; -} - .speaking-icon { font-family: 'Material Symbols Outlined'; color: white; - font-size: 16px; + background-color: #618aff; + border-radius: 50%; } .avatar-container { @@ -93,3 +95,26 @@ margin-right: -10px; width: calc(100% + 10px); } + +.focus-toggle { + width: 2rem; + background-color: #151e27; + position: absolute; + right: 0.5rem; + top: 0.5rem; + z-index: 10; +} + +.lk-participant-tile .lk-participant-placeholder { + background-color: #1a242e; +} + +.lk-participant-metadata-item, +.lk-participant-tile .lk-focus-toggle-button, +.lk-participant-tile .lk-focus-toggle-button:hover { + background-color: #151e27; +} + +.data-lk-quality { + top: 0; +}