feat: screen share + bottom control bar (Google Meet style)

- 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
This commit is contained in:
2026-03-24 12:35:25 +03:00
parent 87f0b5a21d
commit 287d2295b3
+316 -98
View File
@@ -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<string | null>(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 (
<div className="h-screen flex flex-col bg-surface-0">
{/* Top bar */}
<header className="flex items-center justify-between px-4 h-14 bg-surface-1 border-b border-border-default shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-white font-semibold text-[15px]">{room?.name ?? "Комната"}</h1>
{room && (
<span className="text-[11px] text-text-muted font-mono bg-surface-2 px-2 py-0.5 rounded-md border border-border-subtle">
{room.code}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{room?.webinarMode && !isHost && (
<button
onClick={handleRaiseHand}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
handRaised
? "bg-warning/20 text-warning border border-warning/30"
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
}`}
>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
</svg>
{handRaised ? "Опустить руку" : "Поднять руку"}
</span>
</button>
)}
{isHost && room?.lobbyEnabled && (
<button
onClick={() => setShowLobby((v) => !v)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
showLobby
? "bg-accent/20 text-accent-hover border border-accent/30"
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
}`}
>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
Зал ожидания
</span>
</button>
)}
<button
onClick={() => setShowChat((v) => !v)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
showChat
? "bg-accent/20 text-accent-hover border border-accent/30"
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
}`}
>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
Чат
</span>
</button>
{isHost && (
<button
onClick={() => setShowModeration((v) => !v)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
showModeration
? "bg-accent/20 text-accent-hover border border-accent/30"
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
}`}
>
<span className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
Модерация
</span>
</button>
)}
</div>
<LiveKitRoom
serverUrl={LIVEKIT_URL}
token={token}
connect={true}
className="h-screen flex flex-col bg-surface-0"
onDisconnected={() => router.push(isHost ? "/dashboard" : "/")}
>
<RoomAudioRenderer />
{/* Top bar — minimal, just room info */}
<header className="flex items-center px-4 h-12 bg-surface-1/80 backdrop-blur-sm border-b border-border-subtle shrink-0">
<h1 className="text-white font-semibold text-sm">{room?.name ?? "Комната"}</h1>
{room && (
<span className="text-[11px] text-text-muted font-mono bg-surface-2 px-2 py-0.5 rounded-md border border-border-subtle ml-3">
{room.code}
</span>
)}
</header>
{/* Main content */}
<LiveKitRoom
serverUrl={LIVEKIT_URL}
token={token}
connect={true}
className="flex flex-1 min-h-0"
>
{/* Main content area */}
<div className="flex flex-1 min-h-0">
{/* Video area */}
<div className="flex-1 min-w-0">
<VideoConference />
<div className="flex-1 min-w-0 p-2">
<VideoArea />
</div>
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
{/* Sidebar */}
{sidebarOpen && (
<aside className="w-80 shrink-0 border-l border-border-default flex flex-col bg-surface-1 transition-all">
<aside className="w-80 shrink-0 border-l border-border-default flex flex-col bg-surface-1">
{isHost && showLobby && room && (
<div className="border-b border-border-default">
<LobbyManager roomId={room.id} />
@@ -258,7 +199,284 @@ export default function RoomPage() {
)}
</aside>
)}
</LiveKitRoom>
</div>
</div>
{/* Bottom control bar — Google Meet style */}
<footer className="flex items-center justify-between px-4 h-16 bg-surface-1 border-t border-border-default shrink-0">
{/* Left: room time or empty */}
<div className="w-48" />
{/* Center: media controls */}
<div className="flex items-center gap-2">
<MediaControls />
{/* Divider */}
<div className="w-px h-8 bg-border-default mx-1" />
{/* Hand raise (webinar guests only) */}
{room?.webinarMode && !isHost && (
<ControlButton
active={handRaised}
activeColor="warning"
onClick={handleRaiseHand}
tooltip={handRaised ? "Опустить руку" : "Поднять руку"}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
</svg>
</ControlButton>
)}
{/* Chat */}
<ControlButton
active={showChat}
onClick={() => setShowChat((v) => !v)}
tooltip="Чат"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</ControlButton>
{/* Lobby (host only) */}
{isHost && room?.lobbyEnabled && (
<ControlButton
active={showLobby}
onClick={() => setShowLobby((v) => !v)}
tooltip="Зал ожидания"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</ControlButton>
)}
{/* Moderation (host only) */}
{isHost && (
<ControlButton
active={showModeration}
onClick={() => setShowModeration((v) => !v)}
tooltip="Модерация"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</ControlButton>
)}
{/* Divider */}
<div className="w-px h-8 bg-border-default mx-1" />
{/* Leave / End */}
<LeaveButton isHost={isHost} />
</div>
{/* Right: empty spacer */}
<div className="w-48" />
</footer>
</LiveKitRoom>
);
}
/* ───── 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 (
<FocusLayoutContainer className="h-full">
<CarouselLayout tracks={cameraTracks} className="h-24">
<ParticipantTile />
</CarouselLayout>
<FocusLayout trackRef={screenShareTracks[0]} className="flex-1" />
</FocusLayoutContainer>
);
}
return (
<GridLayout tracks={cameraTracks} className="h-full">
<ParticipantTile />
</GridLayout>
);
}
/* ───── 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 */}
<button
onClick={toggleMic}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
micEnabled
? "bg-surface-2 hover:bg-surface-3 text-white"
: "bg-danger text-white"
}`}
title={micEnabled ? "Выключить микрофон" : "Включить микрофон"}
>
{micEnabled ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
)}
</button>
{/* Camera */}
<button
onClick={toggleCam}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
camEnabled
? "bg-surface-2 hover:bg-surface-3 text-white"
: "bg-danger text-white"
}`}
title={camEnabled ? "Выключить камеру" : "Включить камеру"}
>
{camEnabled ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M12 18.75H4.5a2.25 2.25 0 01-2.25-2.25v-9a2.25 2.25 0 012.25-2.25h9" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
)}
</button>
{/* Screen Share */}
<button
onClick={toggleScreenShare}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
screenSharing
? "bg-accent text-white"
: "bg-surface-2 hover:bg-surface-3 text-white"
}`}
title={screenSharing ? "Остановить демонстрацию" : "Демонстрация экрана"}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
</svg>
</button>
</>
);
}
/* ───── 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 (
<button
onClick={onClick}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
active
? activeClasses
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
}`}
title={tooltip}
>
{children}
</button>
);
}
/* ───── Leave / End room button ───── */
function LeaveButton({ isHost }: { isHost: boolean }) {
const room = useRoomContext();
return (
<button
onClick={() => room.disconnect()}
className="h-11 px-5 rounded-full bg-danger hover:bg-red-600 text-white text-sm font-medium transition-colors flex items-center gap-2"
title={isHost ? "Завершить" : "Выйти"}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
{isHost ? "Завершить" : "Выйти"}
</button>
);
}