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 ? (