From 287d2295b30aa3db3c2b6d623d7cad324e018412 Mon Sep 17 00:00:00 2001 From: joylessorchid Date: Tue, 24 Mar 2026 12:35:25 +0300 Subject: [PATCH] feat: screen share + bottom control bar (Google Meet style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace VideoConference with custom layout: GridLayout for cameras, FocusLayout when someone shares screen (carousel + focused view) - Bottom control bar: mic, camera, screen share, chat, lobby, moderation, hand raise, leave/end — all as round icon buttons - Top bar reduced to minimal room name + code badge - MediaControls: synced with LiveKit track state via RoomEvents - LeaveButton disconnects and redirects (host → dashboard, guest → home) - Screen share auto-detected: switches to focus layout with carousel --- src/app/room/[code]/page.tsx | 414 ++++++++++++++++++++++++++--------- 1 file changed, 316 insertions(+), 98 deletions(-) diff --git a/src/app/room/[code]/page.tsx b/src/app/room/[code]/page.tsx index e0420da..c03bce3 100644 --- a/src/app/room/[code]/page.tsx +++ b/src/app/room/[code]/page.tsx @@ -1,11 +1,21 @@ "use client"; import { useEffect, useState, useCallback } from "react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; import { LiveKitRoom, - VideoConference, + GridLayout, + RoomAudioRenderer, + useTracks, + TrackRefContext, + ParticipantTile, + FocusLayout, + FocusLayoutContainer, + CarouselLayout, + useRoomContext, + useLocalParticipant, } from "@livekit/components-react"; +import { Track, RoomEvent } from "livekit-client"; import { useSession } from "@/lib/auth-client"; import ModerationPanel from "@/components/room/ModerationPanel"; import ChatPanel from "@/components/room/ChatPanel"; @@ -27,6 +37,7 @@ type RoomInfo = { export default function RoomPage() { const params = useParams<{ code: string }>(); const searchParams = useSearchParams(); + const router = useRouter(); const { data: session } = useSession(); const [token, setToken] = useState(null); @@ -59,9 +70,7 @@ export default function RoomPage() { const roomData = await res.json(); setRoom(roomData); - // If authenticated user is host and no token yet if (!token && userId && roomData.hostId === userId) { - // Auto-start room if still WAITING if (roomData.status === "WAITING") { const startRes = await fetch(`/api/rooms/${roomData.id}/start`, { method: "POST", @@ -72,7 +81,6 @@ export default function RoomPage() { } } - // Now fetch token const tokenRes = await fetch("/api/livekit/token", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -107,7 +115,7 @@ export default function RoomPage() { }), }); } catch { - // silent — hand raise also works via LiveKit DataChannels + // silent } }, [room, handRaised, userId]); @@ -141,102 +149,35 @@ export default function RoomPage() { } return ( -
- {/* Top bar */} -
-
-

{room?.name ?? "Комната"}

- {room && ( - - {room.code} - - )} -
-
- {room?.webinarMode && !isHost && ( - - )} - {isHost && room?.lobbyEnabled && ( - - )} - - {isHost && ( - - )} -
+ router.push(isHost ? "/dashboard" : "/")} + > + + + {/* Top bar — minimal, just room info */} +
+

{room?.name ?? "Комната"}

+ {room && ( + + {room.code} + + )}
- {/* Main content */} - + {/* Main content area */} +
{/* Video area */} -
- +
+
- {/* Sidebars — inside LiveKitRoom for useParticipants() context */} + {/* Sidebar */} {sidebarOpen && ( -
+
+ + {/* Bottom control bar — Google Meet style */} +
+ {/* Left: room time or empty */} +
+ + {/* Center: media controls */} +
+ + + {/* Divider */} +
+ + {/* Hand raise (webinar guests only) */} + {room?.webinarMode && !isHost && ( + + + + + + )} + + {/* Chat */} + setShowChat((v) => !v)} + tooltip="Чат" + > + + + + + + {/* Lobby (host only) */} + {isHost && room?.lobbyEnabled && ( + setShowLobby((v) => !v)} + tooltip="Зал ожидания" + > + + + + + )} + + {/* Moderation (host only) */} + {isHost && ( + setShowModeration((v) => !v)} + tooltip="Модерация" + > + + + + + )} + + {/* Divider */} +
+ + {/* Leave / End */} + +
+ + {/* Right: empty spacer */} +
+
+
+ ); +} + +/* ───── Video area with screen share focus ───── */ +function VideoArea() { + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { onlySubscribed: false } + ); + + const screenShareTracks = tracks.filter( + (t) => t.source === Track.Source.ScreenShare + ); + const cameraTracks = tracks.filter( + (t) => t.source === Track.Source.Camera + ); + + // If someone is screen sharing, use focus layout + if (screenShareTracks.length > 0) { + return ( + + + + + + + ); + } + + return ( + + + + ); +} + +/* ───── Media controls: mic, camera, screen share ───── */ +function MediaControls() { + const room = useRoomContext(); + const { localParticipant } = useLocalParticipant(); + const [micEnabled, setMicEnabled] = useState(true); + const [camEnabled, setCamEnabled] = useState(true); + const [screenSharing, setScreenSharing] = useState(false); + + // Sync state with actual track state + useEffect(() => { + setMicEnabled(localParticipant.isMicrophoneEnabled); + setCamEnabled(localParticipant.isCameraEnabled); + setScreenSharing(localParticipant.isScreenShareEnabled); + }, [ + localParticipant.isMicrophoneEnabled, + localParticipant.isCameraEnabled, + localParticipant.isScreenShareEnabled, + ]); + + useEffect(() => { + const handleTrackMuted = () => { + setMicEnabled(localParticipant.isMicrophoneEnabled); + setCamEnabled(localParticipant.isCameraEnabled); + }; + room.on(RoomEvent.TrackMuted, handleTrackMuted); + room.on(RoomEvent.TrackUnmuted, handleTrackMuted); + room.on(RoomEvent.LocalTrackPublished, handleTrackMuted); + room.on(RoomEvent.LocalTrackUnpublished, handleTrackMuted); + return () => { + room.off(RoomEvent.TrackMuted, handleTrackMuted); + room.off(RoomEvent.TrackUnmuted, handleTrackMuted); + room.off(RoomEvent.LocalTrackPublished, handleTrackMuted); + room.off(RoomEvent.LocalTrackUnpublished, handleTrackMuted); + }; + }, [room, localParticipant]); + + const toggleMic = async () => { + await localParticipant.setMicrophoneEnabled(!micEnabled); + setMicEnabled(!micEnabled); + }; + + const toggleCam = async () => { + await localParticipant.setCameraEnabled(!camEnabled); + setCamEnabled(!camEnabled); + }; + + const toggleScreenShare = async () => { + await localParticipant.setScreenShareEnabled(!screenSharing); + setScreenSharing(!screenSharing); + }; + + return ( + <> + {/* Microphone */} + + + {/* Camera */} + + + {/* Screen Share */} + + + ); +} + +/* ───── Reusable control button ───── */ +function ControlButton({ + active, + activeColor = "accent", + onClick, + tooltip, + children, +}: { + active: boolean; + activeColor?: "accent" | "warning"; + onClick: () => void; + tooltip: string; + children: React.ReactNode; +}) { + const activeClasses = + activeColor === "warning" + ? "bg-warning/20 text-warning" + : "bg-accent/20 text-accent-hover"; + + return ( + + ); +} + +/* ───── Leave / End room button ───── */ +function LeaveButton({ isHost }: { isHost: boolean }) { + const room = useRoomContext(); + + return ( + ); }