1235 lines
45 KiB
TypeScript
1235 lines
45 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||
/* eslint-disable react-hooks/rules-of-hooks */
|
||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
/* eslint-disable react-hooks/exhaustive-deps */
|
||
import "./StreamPage2.css";
|
||
import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||
import { useParams, useSearchParams } from "react-router-dom";
|
||
import { FormEvent, useEffect, useRef } from "react";
|
||
import { Transition } from "react-transition-group";
|
||
import Button from "../components/ui/Button";
|
||
// import CloseIcon from "../components/icons/CloseIcon";
|
||
import HandOffIcon from "../components/icons/HandOffIcon";
|
||
// import MicroOffIcon from "../components/icons/MicroOffIcon";
|
||
import PersonsIcon from "../components/icons/PersonsIcon";
|
||
import MicroOnIcon from "../components/icons/MicroOnIcon";
|
||
// import MoreIcon from "../components/icons/MoreIcon";
|
||
import FullscreenIcon from "../components/icons/FullscreenIcon";
|
||
import WindowIcon from "../components/icons/WindowIcon";
|
||
import ChatIcon from "../components/icons/ChatIcon";
|
||
import ShareIcon from "../components/icons/ShareIcon";
|
||
// import GearIcon from "../components/icons/GearIcon";
|
||
import { useFullscreen } from "ahooks";
|
||
import api from "../utils/api";
|
||
import { Socket, io } from "socket.io-client";
|
||
import { v4 as uuidv4 } from "uuid";
|
||
import HandOnIcon from "../components/icons/HandOnIcon";
|
||
import User from "../components/User";
|
||
import Tooltip from "../components/Tooltip";
|
||
import { Bounce, ToastContainer, toast } from "react-toastify";
|
||
import "react-toastify/dist/ReactToastify.css";
|
||
import InfoBlueIcon from "../components/icons/InfoBlueIcon";
|
||
import CloseIcon from "../components/icons/CloseIcon";
|
||
import SendChatIcon from "../components/icons/SendChatIcon";
|
||
import UserIcon from "../components/icons/UserIcon";
|
||
import { isIOS, isMobile, useMobileOrientation } from "react-device-detect";
|
||
import LinkIcon from "../components/icons/LinkIcon";
|
||
import removeSpaces from "../utils/removeSpaces";
|
||
import MobileIcon from "../components/icons/MobileIcon";
|
||
import DesktopIcon from "../components/icons/DesktopIcon";
|
||
import { format } from "date-fns";
|
||
import IUser from "../types/IUser";
|
||
import IMessage from "../types/IMessage";
|
||
import InfoIcon from "../components/icons/InfoIcon";
|
||
import Rotate64Icon from "../components/icons/Rotate64Icon";
|
||
import { useClipboard } from "use-clipboard-copy";
|
||
import QRCode from "react-qr-code";
|
||
import Star12Icon from "../components/icons/Star12Icon";
|
||
import { Trans, useTranslation } from "react-i18next";
|
||
import Countdown from "react-countdown";
|
||
import useStreamStore from "../stores/useStreamStore";
|
||
import Peer from "peerjs";
|
||
import Video from "../components/Video";
|
||
import MicroOffIcon from "../components/icons/MicroOffIcon";
|
||
import useIsAudioActive from "use-is-audio-active";
|
||
import CameraOffIcon from "../components/icons/CameraOffIcon";
|
||
import CameraOnIcon from "../components/icons/CameraOnIcon";
|
||
import useState from "react-usestateref";
|
||
|
||
const renderer = ({ minutes, seconds }: any) => {
|
||
return (
|
||
<>
|
||
{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")}
|
||
</>
|
||
);
|
||
};
|
||
|
||
interface IRemoteStream {
|
||
peerId: string;
|
||
mediaStream: MediaStream;
|
||
}
|
||
|
||
function StreamPage2() {
|
||
const { t, i18n } = useTranslation();
|
||
const { isPortrait } = useMobileOrientation();
|
||
const params = useParams();
|
||
const [searchParams] = useSearchParams();
|
||
const [socket, setSocket] = useState<Socket>();
|
||
const [wsUrl, setWsUrl] = useState<string>();
|
||
const [isEnded, setIsEnded] = useState<boolean>(false);
|
||
const { name, setName } = useStreamStore();
|
||
const userId = uuidv4();
|
||
const [step, setStep] = useState<number>(1);
|
||
const nameRef = useRef<HTMLInputElement>(null!);
|
||
const [users, setUsers] = useState<IUser[]>([]);
|
||
const fullscreenRef = useRef(null);
|
||
const [isFullscreen, { toggleFullscreen }] = useFullscreen(fullscreenRef);
|
||
const [isShowChat, setIsShowChat] = useState<boolean>(false);
|
||
const [isShowUsers, setIsShowUsers] = useState<boolean>(false);
|
||
const [isShowInviteModal, setIsShowInviteModal] = useState(false);
|
||
const [isVideoInitialized, setIsVideoInitialized] = useState<boolean>(false);
|
||
const [messageText, setMessageText] = useState("");
|
||
const [messages] = useState<IMessage[]>([]);
|
||
const messagesRef = useRef<HTMLDivElement>(null);
|
||
const messageTextRef = useRef<HTMLInputElement>(null);
|
||
const parentElementRef = useRef<HTMLDivElement>(null);
|
||
const clipboard = useClipboard();
|
||
const link = window.location.origin + window.location.pathname;
|
||
const [anyNewMessages, setAnyNewMessages] = useState(false);
|
||
const [endAt, setEndAt] = useState();
|
||
|
||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||
const [localStream, setLocalStream] = useState<MediaStream>(
|
||
new MediaStream()
|
||
);
|
||
const [remoteStreams, setRemoteStreams, remoteStreamsRef] = useState<
|
||
IRemoteStream[]
|
||
>([]);
|
||
const [peerId, setPeerId] = useState<string>("");
|
||
const [peerInstance, setPeerInstance] = useState<Peer>();
|
||
const [permission, setPermission] = useState<boolean>();
|
||
const isSpeaking = useIsAudioActive({
|
||
source: localStream.getTracks().length ? localStream : null,
|
||
});
|
||
const isCallInit = useRef<boolean>(false);
|
||
|
||
const [isVideoEnabled, setIsVideoEnabled] = useState<boolean>(false);
|
||
const [isAudioEnabled, setIsAudioEnabled] = useState<boolean>(false);
|
||
|
||
async function getLang() {
|
||
const { countryCode, error }: { countryCode: string; error: string } =
|
||
await api.get("getCountryCode").json();
|
||
|
||
if (!error && countryCode !== "RU") {
|
||
i18n.changeLanguage("en");
|
||
}
|
||
}
|
||
|
||
function handleClickClipboard() {
|
||
clipboard.copy();
|
||
|
||
toast.info("Ссылка скопирована в буфер обмена", {
|
||
icon: <InfoIcon className="text-blue-500" />,
|
||
position: "top-center",
|
||
autoClose: 3000,
|
||
hideProgressBar: true,
|
||
closeOnClick: true,
|
||
pauseOnHover: true,
|
||
draggable: true,
|
||
progress: undefined,
|
||
theme: "light",
|
||
transition: Bounce,
|
||
});
|
||
}
|
||
|
||
async function sendMessage(e: FormEvent) {
|
||
e.preventDefault();
|
||
|
||
const text = removeSpaces(messageText);
|
||
|
||
if (text) {
|
||
const name = users.find((user) => user.id === userId)?.name;
|
||
socket?.emit("message", { name, text });
|
||
}
|
||
|
||
setMessageText("");
|
||
messageTextRef.current?.focus();
|
||
}
|
||
|
||
async function getActiveSession() {
|
||
const activeSession: any = await api
|
||
.get(`activeSessions/${params.id}`)
|
||
.json();
|
||
|
||
if (activeSession?.endAt) {
|
||
setEndAt(activeSession.endAt);
|
||
}
|
||
|
||
return activeSession;
|
||
}
|
||
|
||
async function checkSessionStatus() {
|
||
const activeSession = await getActiveSession();
|
||
|
||
if (!activeSession || activeSession.status === "error") {
|
||
setIsEnded(true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function getWsUrl() {
|
||
const activeSession = await getActiveSession();
|
||
|
||
if (!activeSession || activeSession.status === "error") {
|
||
setIsEnded(true);
|
||
return;
|
||
}
|
||
|
||
setIsEnded(false);
|
||
setWsUrl(
|
||
`wss://${activeSession.location}.sess.stream.graff.tech/${activeSession.name}/${activeSession.cirrusPort}/`
|
||
);
|
||
|
||
checkSessionStatus();
|
||
}
|
||
|
||
function handleSetName(e: FormEvent) {
|
||
e.preventDefault();
|
||
|
||
if (!name) {
|
||
nameRef.current.focus();
|
||
return;
|
||
}
|
||
|
||
setStep(2);
|
||
}
|
||
|
||
function setNameGuest() {
|
||
i18n.language === "ru" ? setName("Гость") : setName("Guest");
|
||
|
||
setStep(2);
|
||
}
|
||
|
||
function toggleVideo() {
|
||
localStream.getVideoTracks().forEach((track) => {
|
||
track.enabled = !track.enabled;
|
||
|
||
if (!permission) return;
|
||
setIsVideoEnabled(track.enabled);
|
||
});
|
||
}
|
||
|
||
function toggleAudio() {
|
||
localStream.getAudioTracks().forEach((track) => {
|
||
track.enabled = !track.enabled;
|
||
|
||
if (!permission) return;
|
||
setIsAudioEnabled(track.enabled);
|
||
});
|
||
}
|
||
|
||
function transferControl(userId: string) {
|
||
socket?.emit("transferControl", userId);
|
||
}
|
||
|
||
function requestControl(userId: string) {
|
||
socket?.emit("requestControl", userId);
|
||
|
||
toast.info(`Вы запросили разрешение на управление`, {
|
||
icon: <InfoBlueIcon />,
|
||
position: "top-center",
|
||
autoClose: 3000,
|
||
hideProgressBar: false,
|
||
closeOnClick: true,
|
||
pauseOnHover: true,
|
||
draggable: true,
|
||
progress: undefined,
|
||
theme: "light",
|
||
transition: Bounce,
|
||
});
|
||
}
|
||
|
||
function kickUser(userId: string) {
|
||
socket?.emit("kickUser", userId);
|
||
}
|
||
|
||
async function startCall(remotePeerId: string) {
|
||
if (!peerInstance) return;
|
||
|
||
console.log("startCall", remotePeerId);
|
||
|
||
const options = {
|
||
constraints: {
|
||
offerToReceiveVideo: true,
|
||
offerToReceiveAudio: true,
|
||
},
|
||
};
|
||
|
||
const call = peerInstance.call(remotePeerId, localStream, options as any);
|
||
|
||
let accept = true;
|
||
call.on("stream", (remoteStream) => {
|
||
if (!accept) return;
|
||
console.log("setRemoteStreams", remoteStream);
|
||
setRemoteStreams((prev) => [
|
||
...prev,
|
||
{ peerId: remotePeerId, mediaStream: remoteStream },
|
||
]);
|
||
accept = false;
|
||
});
|
||
}
|
||
|
||
async function getUserMedia() {
|
||
try {
|
||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||
video: true,
|
||
audio: true,
|
||
});
|
||
|
||
if (!localVideoRef.current) return;
|
||
|
||
localVideoRef.current.srcObject = mediaStream;
|
||
localVideoRef.current.onloadedmetadata = () => {
|
||
localVideoRef.current?.play();
|
||
};
|
||
|
||
setLocalStream(mediaStream);
|
||
setPermission(true);
|
||
console.log("setLocalStream mediaStream", mediaStream);
|
||
} catch (error) {
|
||
setPermission(false);
|
||
console.log("ERROR: ", error);
|
||
}
|
||
}
|
||
|
||
function initPeer() {
|
||
const peer = new Peer();
|
||
|
||
peer.on("open", (id) => {
|
||
setPeerId(id);
|
||
});
|
||
|
||
peer.on("call", (call) => {
|
||
call.answer(localStream || undefined);
|
||
|
||
let accept = true;
|
||
call.on("stream", (remoteStream) => {
|
||
if (!accept) return;
|
||
setRemoteStreams((prev) => [
|
||
...prev,
|
||
{ peerId: call.peer, mediaStream: remoteStream },
|
||
]);
|
||
accept = false;
|
||
});
|
||
});
|
||
|
||
setPeerInstance(peer);
|
||
}
|
||
|
||
function initSocket() {
|
||
const socket = io(import.meta.env.VITE_SOCKET_URL, {
|
||
transports: ["websocket"],
|
||
auth: {
|
||
roomId: params.id,
|
||
user: {
|
||
id: userId,
|
||
name: name,
|
||
device: isMobile ? "mobile" : "desktop",
|
||
isAdmin: searchParams.has("admin", true),
|
||
peerId,
|
||
},
|
||
},
|
||
});
|
||
|
||
socket.on("update", async (users: IUser[]) => {
|
||
console.log("isCallInit", isCallInit.current);
|
||
|
||
if (!isCallInit.current) {
|
||
for (const user of users) {
|
||
if (userId === user.id) continue;
|
||
|
||
await startCall(user.peerId);
|
||
}
|
||
|
||
isCallInit.current = true;
|
||
}
|
||
|
||
setUsers(users);
|
||
});
|
||
|
||
setSocket(socket);
|
||
setStep(3);
|
||
}
|
||
|
||
useEffect(() => {
|
||
console.log("users", users);
|
||
}, [users]);
|
||
|
||
function updateRemoteStreams() {
|
||
setTimeout(() => {
|
||
console.log("users", users);
|
||
|
||
const newRemoteStreams = remoteStreamsRef.current.filter((remoteStream) =>
|
||
users.some((user) => user.peerId === remoteStream.peerId)
|
||
);
|
||
|
||
setRemoteStreams(newRemoteStreams);
|
||
}, 500);
|
||
}
|
||
|
||
useEffect(() => {
|
||
getLang();
|
||
getWsUrl();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
document.title = t("title");
|
||
}, [i18n.language]);
|
||
|
||
useEffect(() => {
|
||
if (!name) return;
|
||
|
||
setName(name.trim());
|
||
}, [name]);
|
||
|
||
useEffect(() => {
|
||
if (!isShowChat || messagesRef.current?.scrollTop === undefined) return;
|
||
|
||
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
||
}, [messages, isShowChat]);
|
||
|
||
useEffect(() => {
|
||
if (!messages.length || isShowChat) return;
|
||
|
||
setAnyNewMessages(true);
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
if (isShowChat) {
|
||
setAnyNewMessages(false);
|
||
}
|
||
}, [isShowChat]);
|
||
|
||
// useEffect(() => {
|
||
// if (!peerId) return;
|
||
|
||
// const socket = io(import.meta.env.VITE_SOCKET_URL, {
|
||
// auth: {
|
||
// roomId: params.id,
|
||
// user: {
|
||
// id: userId,
|
||
// name: name,
|
||
// device: isMobile ? "mobile" : "desktop",
|
||
// isAdmin: searchParams.has("admin", true),
|
||
// peerId,
|
||
// },
|
||
// },
|
||
// });
|
||
|
||
// // TODO
|
||
|
||
// socket.on("message", (message: IMessage) => {
|
||
// setMessages((prev) => [...prev, message]);
|
||
// });
|
||
|
||
// socket.on("requestControl", (user: IUser) => {
|
||
// if (!usersRef.current.find((user) => user.id === userId)?.isAdmin) return;
|
||
|
||
// toast.info(`${user.name} запрашивает разрешение на управление`, {
|
||
// icon: <InfoBlueIcon />,
|
||
// position: "top-center",
|
||
// autoClose: 5000,
|
||
// hideProgressBar: false,
|
||
// closeOnClick: true,
|
||
// pauseOnHover: true,
|
||
// draggable: true,
|
||
// progress: undefined,
|
||
// theme: "light",
|
||
// transition: Bounce,
|
||
// });
|
||
// });
|
||
|
||
// socket.on("transferControl", (user: IUser) => {
|
||
// if (user.id !== userId) return;
|
||
|
||
// toast.info(`Вы получили разрешение на управление`, {
|
||
// icon: <InfoBlueIcon />,
|
||
// position: "top-center",
|
||
// autoClose: 3000,
|
||
// hideProgressBar: false,
|
||
// closeOnClick: true,
|
||
// pauseOnHover: true,
|
||
// draggable: true,
|
||
// progress: undefined,
|
||
// theme: "light",
|
||
// transition: Bounce,
|
||
// });
|
||
// });
|
||
|
||
// socket.on("kickUser", (socketUserId: string) => {
|
||
// if (socketUserId === userId) {
|
||
// socket.disconnect();
|
||
// window.close();
|
||
// window.location.reload();
|
||
// }
|
||
// });
|
||
|
||
// setSocket(socket);
|
||
// }, [peerId]);
|
||
|
||
useEffect(() => {
|
||
if (!isMobile) return;
|
||
if (isShowUsers) {
|
||
setIsShowChat(false);
|
||
}
|
||
}, [isShowUsers]);
|
||
|
||
useEffect(() => {
|
||
if (!isMobile) return;
|
||
if (isShowChat) {
|
||
setIsShowUsers(false);
|
||
}
|
||
}, [isShowChat]);
|
||
|
||
useEffect(() => {
|
||
if (step === 3) {
|
||
setInterval(async () => {
|
||
await checkSessionStatus();
|
||
}, 1000);
|
||
}
|
||
}, [step]);
|
||
|
||
useEffect(() => {
|
||
if (permission === undefined) return;
|
||
|
||
initPeer();
|
||
}, [permission]);
|
||
|
||
useEffect(() => {
|
||
if (!peerId) return;
|
||
|
||
initSocket();
|
||
}, [peerId]);
|
||
|
||
useEffect(() => {
|
||
if (!users.length) return;
|
||
|
||
updateRemoteStreams();
|
||
}, [users.length]);
|
||
|
||
return (
|
||
<>
|
||
{isEnded === false ? (
|
||
<div
|
||
ref={fullscreenRef}
|
||
className="h-[100dvh] flex flex-col overflow-hidden touch-none"
|
||
>
|
||
<div className="top-header hidden h-12 bg-white px-6 py-3 lg:flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="mr-2">
|
||
<img src="/images/logo24.svg" alt="" />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative h-6 w-6 flex items-center justify-center bg-[#E6ECF2] font-semibold text-[10px] rounded-full">
|
||
{name && name[0].toUpperCase()}
|
||
{users.find((user) => user.id === userId)
|
||
?.isControlAllowed && (
|
||
<div className="w-2 h-2 border border-white bg-[#49A1F5] absolute right-0 bottom-0 rounded-full"></div>
|
||
)}
|
||
{users.find((user) => user.id === userId)?.isAdmin && (
|
||
<div className="absolute -top-[3px] -left-[3px] text-[#49A1F5]">
|
||
<Star12Icon />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="text-xs">{name}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{users.find((user) => user.id === userId)?.isControlAllowed ? (
|
||
<Button variant="secondary" icon={<HandOnIcon />} onlyIcon />
|
||
) : users.find((user) => user.id === userId)?.isAdmin ? (
|
||
<Button
|
||
variant="secondary"
|
||
icon={<HandOffIcon />}
|
||
onlyIcon
|
||
onClick={() => transferControl(userId)}
|
||
className="group relative"
|
||
>
|
||
<Tooltip text="Вернуть управление" />
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="secondary"
|
||
icon={<HandOffIcon />}
|
||
onlyIcon
|
||
onClick={() => requestControl(userId)}
|
||
className="group relative"
|
||
>
|
||
<Tooltip text="Запросить управление" />
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="secondary"
|
||
icon={isVideoEnabled ? <CameraOnIcon /> : <CameraOffIcon />}
|
||
onlyIcon
|
||
onClick={toggleVideo}
|
||
/>
|
||
<Button
|
||
variant="secondary"
|
||
icon={isAudioEnabled ? <MicroOnIcon /> : <MicroOffIcon />}
|
||
onlyIcon
|
||
onClick={toggleAudio}
|
||
/>
|
||
</div>
|
||
<div className="w-px h-4 bg-[#DAE0E5]"></div>
|
||
<div className="flex gap-6">
|
||
{users
|
||
.filter((user) => user.id !== userId)
|
||
.map(
|
||
(user, index) =>
|
||
index < 3 && (
|
||
<div key={user.id} className="flex items-center gap-2">
|
||
<div className="relative h-6 w-6 flex items-center justify-center bg-[#E6ECF2] font-semibold text-[10px] rounded-full">
|
||
{user.name[0].toUpperCase()}
|
||
{user.isControlAllowed && (
|
||
<div className="w-2 h-2 border border-white bg-[#49A1F5] absolute right-0 bottom-0 rounded-full"></div>
|
||
)}
|
||
{user.isAdmin && (
|
||
<div className="absolute -top-[3px] -left-[3px] text-[#49A1F5]">
|
||
<Star12Icon />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="text-xs">{user.name}</p>
|
||
<div className="text-[#CCC]">
|
||
{user.device === "mobile" ? (
|
||
<MobileIcon />
|
||
) : (
|
||
<DesktopIcon />
|
||
)}
|
||
</div>
|
||
{users.find((user) => user.id === userId)
|
||
?.isAdmin && (
|
||
<User
|
||
user={user}
|
||
onTransferControl={(userId) =>
|
||
transferControl(userId)
|
||
}
|
||
onKickUser={(userId) => {
|
||
kickUser(userId);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{users.length > 4 && (
|
||
<Button
|
||
variant="secondary"
|
||
icon={<PersonsIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowUsers((prev) => !prev)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div id="right" className="flex items-center gap-4">
|
||
<div className="relative">
|
||
<Button
|
||
variant="secondary"
|
||
icon={<ChatIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowChat((prev) => !prev)}
|
||
/>
|
||
{anyNewMessages && (
|
||
<div className="w-2 h-2 bg-[#49A1F5] border rounded-full animate-ping absolute top-[25%] right-[20%] pointer-events-none"></div>
|
||
)}
|
||
</div>
|
||
<div id="divider" className="w-px h-4 bg-[#DAE0E5]"></div>
|
||
<div className="flex gap-2">
|
||
{!isIOS && (
|
||
<Button
|
||
variant="secondary"
|
||
icon={isFullscreen ? <WindowIcon /> : <FullscreenIcon />}
|
||
onlyIcon
|
||
onClick={toggleFullscreen}
|
||
/>
|
||
)}
|
||
<Button
|
||
variant="secondary"
|
||
icon={<ShareIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowInviteModal((prev) => !prev)}
|
||
/>
|
||
</div>
|
||
{endAt && searchParams.has("admin", "true") && (
|
||
<>
|
||
<div id="divider" className="w-px h-4 bg-[#DAE0E5]"></div>
|
||
<div className="flex items-center gap-4">
|
||
<p className="text-[#77828C] font-semibold text-sm">
|
||
До завершения сеанса
|
||
</p>
|
||
<p className="font-semibold text-sm w-10">
|
||
<Countdown date={new Date(endAt)} renderer={renderer} />
|
||
</p>
|
||
{/* <Button variant="secondary">Завершить</Button> */}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="main flex-1 bg-white flex">
|
||
<div className="left-header lg:hidden flex flex-col justify-between gap-4 w-12 p-2">
|
||
<div className="flex flex-col items-center gap-4">
|
||
<div className="mt-2">
|
||
<img src="/images/logo24.svg" alt="" />
|
||
</div>
|
||
{!isIOS && (
|
||
<Button
|
||
variant="secondary"
|
||
icon={isFullscreen ? <WindowIcon /> : <FullscreenIcon />}
|
||
onlyIcon
|
||
onClick={toggleFullscreen}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
icon={<UserIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowUsers((prev) => !prev)}
|
||
/>
|
||
<div className="relative">
|
||
<Button
|
||
variant="secondary"
|
||
icon={<ChatIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowChat((prev) => !prev)}
|
||
/>
|
||
{anyNewMessages && (
|
||
<div className="w-2 h-2 bg-[#49A1F5] border rounded-full animate-ping absolute top-[25%] right-[20%] pointer-events-none"></div>
|
||
)}
|
||
</div>
|
||
<hr className="bg-[#DAE0E5] w-4" />
|
||
<Button
|
||
variant="secondary"
|
||
icon={isAudioEnabled ? <MicroOnIcon /> : <MicroOffIcon />}
|
||
onlyIcon
|
||
onClick={toggleAudio}
|
||
/>
|
||
</div>
|
||
<div className="">
|
||
<Button
|
||
variant="secondary"
|
||
icon={<ShareIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowInviteModal((prev) => !prev)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="relative w-full bg-[#111C26]">
|
||
{wsUrl && (
|
||
<PixelStreamingWrapper
|
||
initialSettings={{
|
||
AutoPlayVideo: true,
|
||
AutoConnect: true,
|
||
ss: wsUrl,
|
||
StartVideoMuted: true,
|
||
HoveringMouse: true,
|
||
WaitForStreamer: true,
|
||
}}
|
||
onVideoInitialized={() => setIsVideoInitialized(true)}
|
||
/>
|
||
)}
|
||
|
||
{!users.find((user) => user.id === userId)?.isControlAllowed && (
|
||
<div
|
||
className="absolute w-full h-full top-0 left-0"
|
||
onTouchStart={() =>
|
||
toast.error(
|
||
`Запросите доступ на управление у администратора!`,
|
||
{
|
||
icon: <InfoIcon className="text-red-500" />,
|
||
position: "top-center",
|
||
autoClose: 5000,
|
||
hideProgressBar: false,
|
||
closeOnClick: true,
|
||
pauseOnHover: true,
|
||
draggable: true,
|
||
progress: undefined,
|
||
theme: "light",
|
||
transition: Bounce,
|
||
}
|
||
)
|
||
}
|
||
onMouseDown={() =>
|
||
toast.error(
|
||
`Запросите доступ на управление у администратора!`,
|
||
{
|
||
icon: <InfoIcon className="text-red-500" />,
|
||
position: "top-center",
|
||
autoClose: 5000,
|
||
hideProgressBar: false,
|
||
closeOnClick: true,
|
||
pauseOnHover: true,
|
||
draggable: true,
|
||
progress: undefined,
|
||
theme: "light",
|
||
transition: Bounce,
|
||
}
|
||
)
|
||
}
|
||
></div>
|
||
)}
|
||
|
||
<div className="absolute top-2 lg:left-2 lg:right-auto right-2 flex flex-col gap-2">
|
||
<video
|
||
ref={localVideoRef}
|
||
className={`aspect-video bg-black rounded-lg -scale-x-100 object-cover ring-2 ${
|
||
isAudioEnabled && isSpeaking
|
||
? "ring-green-500"
|
||
: "ring-transparent"
|
||
} ${
|
||
permission
|
||
? "lg:w-[216px] lg:h-[162px] w-[112px] h-[84px]"
|
||
: "w-0 h-0"
|
||
}`}
|
||
playsInline
|
||
autoPlay
|
||
muted
|
||
/>
|
||
{remoteStreams.map(({ peerId, mediaStream }) => (
|
||
<Video
|
||
key={peerId}
|
||
mediaStream={mediaStream}
|
||
muted={!permission}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{(isShowUsers || isShowChat) && (
|
||
<div className="absolute right-0 bg-white min-w-[240px] lg:w-[296px] w-[240px] h-full lg:max-h-[calc(100dvh-48px)] flex flex-col border-t border-[#DAE0E5] shadow-2xl shadow-black">
|
||
{isShowUsers && (
|
||
<div
|
||
className={`users min-h-[50%] h-full flex flex-col ${
|
||
isShowChat ? "max-h-[calc(50vh-48px)]" : "max-h-[100dvh]"
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between p-2 pl-4 border-b border-[#DAE0E5]">
|
||
<p className="text-sm font-semibold">
|
||
<Trans i18nKey={"members"}>Участники</Trans>
|
||
</p>
|
||
<Button
|
||
variant="tertiary"
|
||
icon={<CloseIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowUsers((prev) => !prev)}
|
||
/>
|
||
</div>
|
||
<div className="flex-1 overflow-y-scroll overflow-x-hidden flex flex-col gap-4 p-4 border-b border-[#DAE0E5]">
|
||
{users.map((user) => (
|
||
<div key={user.id} className="flex items-center gap-2">
|
||
<div className="relative h-6 w-6 flex items-center justify-center bg-[#E6ECF2] font-semibold text-[10px] rounded-full">
|
||
{user.name[0].toUpperCase()}
|
||
{user.isControlAllowed && (
|
||
<div className="w-2 h-2 border border-white bg-[#49A1F5] absolute right-0 bottom-0 rounded-full"></div>
|
||
)}
|
||
{user.isAdmin && (
|
||
<div className="absolute -top-[3px] -left-[3px] text-[#49A1F5]">
|
||
<Star12Icon />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="text-xs">{user.name}</p>
|
||
<div className="text-[#CCC]">
|
||
{user.device === "mobile" ? (
|
||
<MobileIcon />
|
||
) : (
|
||
<DesktopIcon />
|
||
)}
|
||
</div>
|
||
<div className="ml-auto">
|
||
{users.find((user) => user.id === userId)
|
||
?.isAdmin && (
|
||
<User
|
||
className="top-[calc(100%+4px)] -translate-x-[calc(100%-42px)]"
|
||
user={user}
|
||
onTransferControl={(userId) =>
|
||
transferControl(userId)
|
||
}
|
||
onKickUser={(userId) => {
|
||
kickUser(userId);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="px-4 py-2">
|
||
<Button
|
||
fullWidth
|
||
onClick={() => setIsShowInviteModal((prev) => !prev)}
|
||
>
|
||
<Trans i18nKey={"invite"}>Пригласить</Trans>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{isShowChat && (
|
||
<div
|
||
className={`chat min-h-[50%] h-full flex flex-col ${
|
||
isShowUsers ? "max-h-[calc(50vh-48px)]" : "max-h-[100dvh]"
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between p-2 pl-4 border-b border-[#DAE0E5]">
|
||
<p className="text-sm font-semibold">
|
||
<Trans i18nKey={"chat"}>Чат</Trans>
|
||
</p>
|
||
<Button
|
||
variant="tertiary"
|
||
icon={<CloseIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowChat((prev) => !prev)}
|
||
/>
|
||
</div>
|
||
<div
|
||
ref={messagesRef}
|
||
className="flex-1 overflow-y-scroll flex flex-col gap-1 p-3 border-b border-[#DAE0E5]"
|
||
>
|
||
{messages.map((message, index) => (
|
||
<div
|
||
key={index}
|
||
className={`text-sm p-2 flex flex-col gap-1 rounded-lg ${
|
||
users.find((user) => user.id === userId)?.name ===
|
||
message.name
|
||
? "bg-[#C4DDF5]"
|
||
: "bg-[#F0F1F2]"
|
||
}`}
|
||
>
|
||
{users.find((user) => user.id === userId)?.name !==
|
||
message.name && (
|
||
<p className="text-[#49A1F5] font-semibold">
|
||
{message.name}
|
||
</p>
|
||
)}
|
||
|
||
<p className="break-words">{message.text}</p>
|
||
<p className="text-[#767676] translate-x-1 translate-y-1 self-end leading-none">
|
||
{format(new Date(), "HH:mm")}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="p-3 pl-4">
|
||
<form
|
||
onSubmit={sendMessage}
|
||
className="flex items-center gap-3"
|
||
>
|
||
<input
|
||
ref={messageTextRef}
|
||
type="text"
|
||
placeholder={t("writeAMessage")}
|
||
className="w-full bg-transparent text-sm outline-none"
|
||
value={messageText}
|
||
onChange={(e) => setMessageText(e.target.value)}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="text-[#49A1F5] outline-none"
|
||
>
|
||
<SendChatIcon />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isShowInviteModal && (
|
||
<div className="absolute top-0 lg:left-0 left-12 lg:w-full w-[calc(100vw-48px)] h-full lg:bg-black lg:bg-opacity-50 bg-white border-l border-[#DAE0E5] flex flex-col lg:items-center lg:justify-center">
|
||
<div className="bg-white flex flex-col lg:rounded-lg lg:w-[400px] lg:flex-none flex-1">
|
||
<div className="p-2 pl-6 flex items-center justify-between border-b border-[#DAE0E5]">
|
||
<p className="text-sm font-semibold">
|
||
<Trans i18nKey={"invite"}>Пригласить</Trans>
|
||
</p>
|
||
<Button
|
||
variant="tertiary"
|
||
icon={<CloseIcon />}
|
||
onlyIcon
|
||
onClick={() => setIsShowInviteModal(false)}
|
||
/>
|
||
</div>
|
||
<div className="py-4 px-6 flex-1 flex flex-col gap-8">
|
||
<div className="flex items-center lg:justify-between justify-center gap-8">
|
||
<QRCode
|
||
size={128}
|
||
value={link}
|
||
className="rounded-lg p-3 shadow-lg"
|
||
/>
|
||
<p className="font-semibold text-right">
|
||
<Trans i18nKey={"scanQRCode"}>
|
||
Отсканируйте QR-код,
|
||
<br />
|
||
чтобы присоедениться
|
||
<br />к демонстрации
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="">
|
||
<Button
|
||
icon={<LinkIcon />}
|
||
fullWidth
|
||
large
|
||
onClick={handleClickClipboard}
|
||
>
|
||
<Trans i18nKey={"copyLinkToConnect"}>
|
||
Скопировать ссылку для подключения
|
||
</Trans>
|
||
</Button>
|
||
<input ref={clipboard.target} type="hidden" value={link} />
|
||
</div>
|
||
<div className="">
|
||
<form
|
||
onSubmit={(e) => e.preventDefault()}
|
||
className="flex gap-2"
|
||
>
|
||
<input
|
||
type="email"
|
||
placeholder="Email"
|
||
className="text-sm bg-transparent border border-[#DAE0E5] rounded-lg px-2 pb-0.5 outline-none h-10 w-full"
|
||
/>
|
||
<Button type="submit" large className="" disabled>
|
||
<Trans i18nKey={"invite"}>Пригласить</Trans>
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
{/* <div className="flex flex-col gap-2">
|
||
<p className="text-sm font-semibold">Участники</p>
|
||
<div className="flex flex-col gap-4 overflow-y-scroll max-h-[calc(100dvh-206px)]">
|
||
{users.map((user) => (
|
||
<div key={user.id} className="flex items-center gap-2">
|
||
<div className="h-8 w-8 bg-[#E6ECF2] rounded-full flex items-center justify-center">
|
||
<p className="text-xs font-semibold">
|
||
{user.name[0].toUpperCase()}
|
||
</p>
|
||
</div>
|
||
<p className="text-sm">{user.name}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div> */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Transition
|
||
in={step !== 3 || !isVideoInitialized}
|
||
timeout={300}
|
||
mountOnEnter
|
||
unmountOnExit
|
||
>
|
||
{(state) => (
|
||
<div
|
||
className={`absolute top-0 left-0 w-full h-full flex items-center justify-center bg-[#14161F] transition-all ${state} ${
|
||
step !== 3 || !isVideoInitialized
|
||
? "bg-opacity-50 backdrop-blur-2xl"
|
||
: "bg-opacity-0 backdrop-blur-none"
|
||
} `}
|
||
>
|
||
<Transition
|
||
in={step === 1}
|
||
timeout={300}
|
||
mountOnEnter
|
||
unmountOnExit
|
||
>
|
||
{(state) => (
|
||
<form
|
||
className={`absolute lg:w-[396px] lg:h-auto w-full h-full flex flex-col justify-center bg-white lg:rounded-lg lg:p-12 p-6 transition-opacity ${state}`}
|
||
onSubmit={handleSetName}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="mb-6">
|
||
<p className="text-2xl font-semibold">
|
||
<Trans i18nKey={"hello"}>Здравствуйте!</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="mb-6 flex flex-col gap-2">
|
||
<p className="font-semibold">
|
||
<Trans i18nKey={"pleaseIntroduceYourself"}>
|
||
Представьтесь, пожалуйста
|
||
</Trans>
|
||
</p>
|
||
<p className="text-[#77828C] text-sm">
|
||
<Trans i18nKey={"communicateWithYou"}>
|
||
Так мы будем знать, как к вам обращаться
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="mb-10">
|
||
<div className="flex flex-col gap-1">
|
||
<label className="text-[#77828C] text-xs">
|
||
<Trans i18nKey={"name"}>Имя</Trans>
|
||
</label>
|
||
<input
|
||
ref={nameRef}
|
||
type="text"
|
||
autoComplete="off"
|
||
autoFocus
|
||
className="rounded-lg px-2 py-2.5 bg-white border border-[#DAE0E5] hover:border-[#49A1F5] focus:border-[#49A1F5] outline-none transition-colors text-sm"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
fullWidth
|
||
large
|
||
onClick={setNameGuest}
|
||
>
|
||
<Trans i18nKey={"skip"}>Не указывать</Trans>
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
fullWidth
|
||
large
|
||
onClick={void handleSetName}
|
||
>
|
||
<Trans i18nKey={"сontinue"}>Продолжить</Trans>
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</Transition>
|
||
|
||
<Transition
|
||
in={step === 2}
|
||
timeout={300}
|
||
mountOnEnter
|
||
unmountOnExit
|
||
>
|
||
{(state) => (
|
||
<div
|
||
className={`absolute lg:w-[396px] lg:h-auto w-full h-full flex flex-col justify-center bg-white lg:rounded-lg lg:p-12 p-6 transition-opacity ${state}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="mb-6">
|
||
<p className="text-2xl font-semibold">
|
||
<Trans i18nKey={"partInDiscussion"}>
|
||
Хотите принять участие в обсуждении?
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="mb-6 flex items-center gap-6">
|
||
<div className="border border-[#DAE0E5] text-[#49A1F5] p-2 rounded-full">
|
||
<MicroOnIcon />
|
||
</div>
|
||
<p className="text-sm">
|
||
<Trans i18nKey={"allowMicrophoneUse"}>
|
||
Разрешите использование камеры и микрофона
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="mb-10">
|
||
<p className="text-[#77828C] text-xs">
|
||
<Trans i18nKey={"turnOffMicrophone"}>
|
||
Выключить камеру и микрофон можно в любой момент
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{/* <Button
|
||
variant="secondary"
|
||
fullWidth
|
||
large
|
||
onClick={disallowMic}
|
||
>
|
||
<Trans i18nKey={"skip"}>Пропустить</Trans>
|
||
</Button> */}
|
||
<Button fullWidth large onClick={getUserMedia}>
|
||
<Trans i18nKey={"allow"}>Продолжить</Trans>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Transition>
|
||
|
||
<Transition
|
||
in={step === 3 && !isVideoInitialized}
|
||
timeout={300}
|
||
mountOnEnter
|
||
unmountOnExit
|
||
>
|
||
{(state) => (
|
||
<div
|
||
className={`absolute bg-white rounded-lg p-12 w-[396px] transition-opacity ${state}`}
|
||
>
|
||
<div className="mb-6">
|
||
<p className="text-2xl font-semibold">
|
||
<Trans i18nKey={"pleaseWait"}>
|
||
Пожалуйста, подождите
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<img
|
||
src="/icons/LoaderPrimary.png"
|
||
alt=""
|
||
className="w-8 h-8 animate-spin"
|
||
/>
|
||
<p className="font-semibold text-[#49A1F5]">
|
||
<Trans i18nKey={"connection"}>Подключение</Trans>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Transition>
|
||
</div>
|
||
)}
|
||
</Transition>
|
||
|
||
<div ref={parentElementRef} className="hidden"></div>
|
||
|
||
{step === 3 && isMobile && isPortrait && (
|
||
<div className="absolute top-0 left-0 w-full h-full bg-white sm:hidden flex items-center justify-center">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<Rotate64Icon />
|
||
<p className="font-semibold">
|
||
<Trans i18nKey={"rotateYourDevice"}>
|
||
Поверните устройство
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ToastContainer />
|
||
</div>
|
||
) : (
|
||
<div className="h-[100dvh] flex items-center justify-center p-8">
|
||
<p className="text-2xl text-white font-gilroy text-center">
|
||
<Trans i18nKey={"demonstrationCompleted"}>
|
||
Данная демонстрация была завершена
|
||
</Trans>
|
||
</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default StreamPage2;
|