diff --git a/frontend/src/components/meeting/CustomParticipantTile/index.tsx b/frontend/src/components/meeting/CustomParticipantTile/index.tsx index 6f1c00c..10a0e0b 100644 --- a/frontend/src/components/meeting/CustomParticipantTile/index.tsx +++ b/frontend/src/components/meeting/CustomParticipantTile/index.tsx @@ -5,24 +5,37 @@ import { useIsSpeaking, } from "@livekit/components-react"; import { Track } from "livekit-client"; +import { Pin } from "lucide-react"; import { useParticipantData } from "./useParticipantData"; import { AvatarDisplay } from "./AvatarDisplay"; import { ParticipantInfo } from "./ParticipantInfo"; +import { usePinContext } from "../LiveKitMeetingRoom/PinContext"; export const CustomParticipantTile: React.FC = () => { const trackRef = useEnsureTrackRef(); const { participant, avatarUrl } = useParticipantData(); const isSpeaking = useIsSpeaking(participant); + const { pinnedIdentity, setPinnedIdentity } = usePinContext(); const isCameraTrack = trackRef?.source === Track.Source.Camera; + const isScreenShare = trackRef?.source === Track.Source.ScreenShare; const hasVideo = isCameraTrack && trackRef?.publication?.isSubscribed && !trackRef?.publication?.isMuted; const showAvatar = isCameraTrack && !hasVideo; + const isPinned = participant?.identity === pinnedIdentity; + + const handlePinToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!participant) return; + setPinnedIdentity(isPinned ? null : participant.identity); + }; + return (
{ }} > + {/* Pin button — visible on hover, always visible (blue) when pinned, hidden for screen share */} + {participant && !isScreenShare && ( + + )} {showAvatar && participant && (
{ - const participant = useEnsureParticipant(); + const trackRef = useEnsureTrackRef(); + const participant = trackRef?.participant ?? null; const metadata = useMemo(() => { try { diff --git a/frontend/src/components/meeting/LiveKitMeetingRoom/PinContext.tsx b/frontend/src/components/meeting/LiveKitMeetingRoom/PinContext.tsx new file mode 100644 index 0000000..b989cc1 --- /dev/null +++ b/frontend/src/components/meeting/LiveKitMeetingRoom/PinContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; + +interface PinContextValue { + pinnedIdentity: string | null; + setPinnedIdentity: (identity: string | null) => void; +} + +export const PinContext = createContext({ + pinnedIdentity: null, + setPinnedIdentity: () => {}, +}); + +export const usePinContext = () => useContext(PinContext); diff --git a/frontend/src/components/meeting/LiveKitMeetingRoom/VideoLayout.tsx b/frontend/src/components/meeting/LiveKitMeetingRoom/VideoLayout.tsx index 515496e..24e3608 100644 --- a/frontend/src/components/meeting/LiveKitMeetingRoom/VideoLayout.tsx +++ b/frontend/src/components/meeting/LiveKitMeetingRoom/VideoLayout.tsx @@ -1,13 +1,16 @@ -import React from "react"; +import React, { useState } from "react"; import { GridLayout, FocusLayout, FocusLayoutContainer, CarouselLayout, + TrackRefContext, + ParticipantContext, } from "@livekit/components-react"; import { Track } from "livekit-client"; import type { TrackReferenceOrPlaceholder } from "@livekit/components-react"; import { CustomParticipantTile } from "../CustomParticipantTile"; +import { PinContext } from "./PinContext"; interface VideoLayoutProps { tracks: TrackReferenceOrPlaceholder[]; @@ -18,22 +21,258 @@ export const VideoLayout: React.FC = ({ tracks, hasScreenShare, }) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [pinnedIdentity, setPinnedIdentity] = useState(null); + const [isPiP, setIsPiP] = useState(false); + const screenShareContainerRef = React.useRef(null); + + const togglePiP = async () => { + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + setIsPiP(false); + } else { + const video = screenShareContainerRef.current?.querySelector("video"); + if (video) { + await video.requestPictureInPicture(); + setIsPiP(true); + } + } + } catch (e) { + console.warn("PiP not supported:", e); + } + }; + + // Track PiP exit via browser controls + React.useEffect(() => { + const onExit = () => setIsPiP(false); + document.addEventListener("leavepictureinpicture", onExit); + return () => document.removeEventListener("leavepictureinpicture", onExit); + }, []); + + const pinContextValue = { pinnedIdentity, setPinnedIdentity }; + + // Find the pinned participant's camera track (fallback to any track) + const pinnedTrack = pinnedIdentity + ? (tracks.find( + (t) => + t.participant?.identity === pinnedIdentity && + t.source === Track.Source.Camera, + ) ?? tracks.find((t) => t.participant?.identity === pinnedIdentity)) + : null; + if (hasScreenShare) { + const screenTrack = tracks.find( + (t) => t.source === Track.Source.ScreenShare, + ); + + if (isFullscreen) { + return ( + +
+ + +
+
+ ); + } + + // CarouselLayout uses useVisualStableUpdate internally and ignores order. + // When pinned, render all tiles manually so the pinned one stays first. + const pinnedCarouselTrack = pinnedIdentity + ? (tracks.find( + (t) => + t.participant?.identity === pinnedIdentity && + t.source === Track.Source.Camera, + ) ?? tracks.find((t) => t.participant?.identity === pinnedIdentity)) + : null; + + const unpinnedTracks = pinnedIdentity + ? tracks.filter( + (t) => + t.participant?.identity !== pinnedIdentity && + t.source !== Track.Source.ScreenShare, + ) + : tracks.filter((t) => t.source !== Track.Source.ScreenShare); + + const orderedTracks = pinnedCarouselTrack + ? [pinnedCarouselTrack, ...unpinnedTracks] + : null; + + // Non-pinned case: also filter screen share from carousel + const carouselTracks = tracks.filter( + (t) => t.source !== Track.Source.ScreenShare, + ); + + return ( + + + {orderedTracks ? ( +
+ {orderedTracks.map((t) => ( + + + + + + ))} +
+ ) : ( + + + + )} +
+ + {/* Floating toolbar — slides down from top on hover */} +
+ {/* Fullscreen */} + + {/* PiP */} + {document.pictureInPictureEnabled && ( + <> +
+ + + )} +
+
+ + + ); + } + + // Pinned participant layout (no screen share) — custom flex layout + if (pinnedIdentity && pinnedTrack) { + const otherTracks = tracks.filter( + (t) => t.participant?.identity !== pinnedIdentity, + ); return ( - - - - - t.source === Track.Source.ScreenShare)} - /> - + +
+ {/* Sidebar: other participants */} + {otherTracks.length > 0 && ( +
+ {otherTracks.map((t) => ( +
+ + + + + +
+ ))} +
+ )} + {/* Main: pinned participant */} +
+ + + + + +
+
+
); } return ( - - - + + + + + ); }; diff --git a/frontend/src/hooks/useLobbyDevices/useAudioDeviceSwitching.ts b/frontend/src/hooks/useLobbyDevices/useAudioDeviceSwitching.ts index 301df34..43b8d06 100644 --- a/frontend/src/hooks/useLobbyDevices/useAudioDeviceSwitching.ts +++ b/frontend/src/hooks/useLobbyDevices/useAudioDeviceSwitching.ts @@ -38,6 +38,11 @@ export function useAudioDeviceSwitching(props: UseAudioSwitchingProps) { const updateAudioStream = async () => { if (!selectedMic || !micEnabled || !permissionsGranted) return; + // If the current stream is already using the selected mic, skip restart + const currentAudioTrack = stream?.getAudioTracks()[0]; + const currentMicId = currentAudioTrack?.getSettings()?.deviceId; + if (currentMicId === selectedMic) return; + try { const constraints: MediaStreamConstraints = { video: diff --git a/frontend/src/hooks/useLobbyDevices/useVideoDeviceSwitching.ts b/frontend/src/hooks/useLobbyDevices/useVideoDeviceSwitching.ts index 716a037..2a8accd 100644 --- a/frontend/src/hooks/useLobbyDevices/useVideoDeviceSwitching.ts +++ b/frontend/src/hooks/useLobbyDevices/useVideoDeviceSwitching.ts @@ -38,6 +38,11 @@ export function useVideoDeviceSwitching(props: UseVideoSwitchingProps) { const updateVideoStream = async () => { if (!selectedCamera || !cameraEnabled || !permissionsGranted) return; + // If the current stream is already using the selected camera, skip restart + const currentVideoTrack = stream?.getVideoTracks()[0]; + const currentDeviceId = currentVideoTrack?.getSettings()?.deviceId; + if (currentDeviceId === selectedCamera) return; + try { const constraints: MediaStreamConstraints = { video: { diff --git a/frontend/src/index.css b/frontend/src/index.css index 4850649..80f79c3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -663,7 +663,8 @@ } /* Make all carousel items (participants) the same size when screen sharing */ - .lk-focus-layout .lk-carousel > * { + .lk-focus-layout .lk-carousel > *, + .lk-focus-layout .lk-carousel-vertical > * { @apply w-37.5 h-37.5 min-w-37.5 min-h-37.5 max-w-37.5 max-h-37.5 shrink-0; aspect-ratio: auto !important; } @@ -673,15 +674,27 @@ @apply flex-col overflow-y-auto overflow-x-hidden gap-2 p-2 max-h-full; } + /* Manual ordered carousel (used when a participant is pinned) */ + .lk-focus-layout .lk-carousel-vertical { + @apply flex flex-col overflow-y-auto overflow-x-hidden gap-2 p-2 max-h-full; + } + /* Keep the focused track (screen share) large */ .lk-focus-layout .lk-focused-participant { @apply flex-1 w-full h-full; } + /* Hide LiveKit's built-in expand button — we use our own custom fullscreen button */ + .lk-focus-toggle-button { + display: none !important; + } + /* Adjust focus layout container to give more space to screen share */ .lk-focus-layout { display: grid !important; grid-template-columns: 170px 1fr !important; + grid-template-rows: 1fr !important; + height: 100% !important; @apply gap-2; } diff --git a/frontend/src/pages/MeetingLobby/VideoPreview.tsx b/frontend/src/pages/MeetingLobby/VideoPreview.tsx index e738a4c..0c5ffa5 100644 --- a/frontend/src/pages/MeetingLobby/VideoPreview.tsx +++ b/frontend/src/pages/MeetingLobby/VideoPreview.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useRef } from "react"; import MediaControls from "../../components/meeting/MediaControls"; import type { VideoPreviewProps } from "./types"; @@ -10,6 +10,14 @@ export const VideoPreview: React.FC = ({ onToggleMic, onToggleCamera, }) => { + const videoRef = useRef(null); + + useEffect(() => { + if (videoRef.current) { + videoRef.current.srcObject = stream ?? null; + } + }, [stream]); + return (
= ({ > {cameraEnabled && stream ? (