Files
stream.graff.tech-client/src/pages/StreamPage.tsx
T

733 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect, useRef, useState } from "react";
import { PixelStreamingWrapper2 } from "../components/PixelStreamingWrapper2";
import api from "../utils/api";
import { useParams, useSearchParams } from "react-router-dom";
import useStateRef from "react-usestateref";
import Peer from "peerjs";
import useIsAudioActive from "use-is-audio-active";
import { v4 as uuidv4 } from "uuid";
import { io } from "socket.io-client";
import IRemoteStream from "../types/IRemoteStream";
import Video from "../components/Video";
import ModalContainer2 from "../components/ModalContainer2";
import IUser from "../types/IUser";
import useModalStore from "../stores/useModalStore";
import SetNameModal from "../components/modals/stream/SetNameModal";
import useStreamStore from "../stores/useStreamStore";
import Button from "../components/ui/Button";
import HandOnIcon from "../components/icons/HandOnIcon";
import HandOffIcon from "../components/icons/HandOffIcon";
import MicroOnIcon from "../components/icons/MicroOnIcon";
import MicroOffIcon from "../components/icons/MicroOffIcon";
import CameraOnIcon from "../components/icons/CameraOnIcon";
import CameraOffIcon from "../components/icons/CameraOffIcon";
import { Trans, useTranslation } from "react-i18next";
import { isIOS, isMobile, useMobileOrientation } from "react-device-detect";
import WindowIcon from "../components/icons/WindowIcon";
import FullscreenIcon from "../components/icons/FullscreenIcon";
import ShareIcon from "../components/icons/ShareIcon";
import { useFullscreen } from "ahooks";
import InviteModal from "../components/modals/stream/InviteModal";
import Tooltip from "../components/Tooltip";
import { toast, ToastContainer } from "react-toastify";
import LoadingModal from "../components/modals/stream/LoadingModal";
import useSocketStore from "../stores/useSocketStore";
import useStreamUserStore from "../stores/useStreamUserStore";
import Chat2 from "../components/Chat2";
import Rotate64Icon from "../components/icons/Rotate64Icon";
import Draggable from "react-draggable";
import DesktopIcon from "../components/icons/DesktopIcon";
import MobileIcon from "../components/icons/MobileIcon";
import ChatIcon from "../components/icons/ChatIcon";
import useChatStore from "../stores/useChatStore";
import UsersIcon from "../components/icons/UsersIcon";
import Users from "../components/Users";
import SpeedtestModal from "../components/modals/stream/SpeedtestModal";
import InternetSpeedHighIcon from "../components/icons/InternetSpeedHighIcon";
import InternetSpeedLowIcon from "../components/icons/InternetSpeedLowIcon";
import InternetSpeedMediumIcon from "../components/icons/InternetSpeedMediumIcon";
// import ChatIcon from "../components/icons/ChatIcon";
// import MoreIcon from "../components/icons/MoreIcon";
const userId = uuidv4();
function StreamPage() {
const { t } = useTranslation();
const params = useParams();
const [searchParams] = useSearchParams();
const [WSUrl, setWSUrl] = useState<string>("");
const localVideoRef = useRef<HTMLVideoElement>(null);
const [localStream, setLocalStream] = useState<MediaStream>(
new MediaStream()
);
const [remoteStreams, setRemoteStreams, remoteStreamsRef] = useStateRef<
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 { me, users, setMe, setUsers } = useStreamUserStore();
const isCallInit = useRef<boolean>(false);
const [roomId] = useState<string>(params.id!);
const { socket, setSocket } = useSocketStore();
const { setModal } = useModalStore();
const { name } = useStreamStore();
const [isMicEnabled, setIsMicEnabled] = useState(true);
const [isCameraEnabled, setIsCameraEnabled] = useState(true);
const [isEnded, setIsEnded] = useState<boolean>();
const [, setEndAt] = useState<Date>();
const fullscreenRef = useRef(null);
const [isFullscreen, { toggleFullscreen }] = useFullscreen(fullscreenRef);
const [isVideoInitialized, setIsVideoInitialized] = useState<boolean>(false);
const [step, setStep] = useState<number>(1);
const [isShowChat, setIsShowChat] = useState<boolean>(false);
const [isShowUsers, setIsShowUsers] = useState<boolean>(false);
const { isPortrait } = useMobileOrientation();
const { setMessages } = useChatStore();
const [activeSession, setActiveSession, activeSessionRef] =
useStateRef<any>();
const [downloadSpeed, setDownloadSpeed] = useState(0);
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() {
console.log("initPeer");
const peer = new Peer({
host: "stream.graff.tech",
config: {
iceServers: [
{
urls: "turn:185.173.176.83:3478", // Replace with your TURN server URL
username: "username1", // Replace with your TURN username
credential: "password1", // Replace with your TURN credential
},
],
},
});
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 superAdmin = searchParams.has("admin", true);
console.log("superAdmin", superAdmin);
const socket = io(import.meta.env.VITE_SOCKET_URL, {
transports: ["websocket"],
auth: {
roomId,
user: {
id: userId,
name,
peerId,
superAdmin,
isVideoInitialized,
downloadSpeed,
},
},
});
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;
}
setMe(users.find((user) => user.id === userId));
setUsers(users);
});
socket.on("request-control", (userId) => {
const user = users.find((user) => user.id === userId);
if (user?.id === me?.id || !me?.isAdmin) return;
toast.info(`${user?.name} ${t("toasts.requestPermission")}`);
});
socket.on("transfer-control", (userId) => {
if (me?.id !== userId) return;
toast.info(t("toasts.receivedPermission"));
});
socket.on("kick", (userId) => {
if (useStreamUserStore.getState().me?.id !== userId) return;
window.close();
socket.disconnect();
setPeerInstance(undefined);
setWSUrl("");
setUsers([]);
setRemoteStreams([]);
});
socket.on("message", ({ userId, text }) => {
setMessages([...useChatStore.getState().messages, { userId, text }]);
});
socket.on("connect", () => {
setSocket(socket);
});
}
function toggleMic() {
localStream.getAudioTracks().forEach((track) => {
track.enabled = !track.enabled;
if (!permission) return;
setIsMicEnabled(track.enabled);
socket?.emit("mic-enabled", track.enabled);
});
}
function toggleCamera() {
localStream.getVideoTracks().forEach((track) => {
track.enabled = !track.enabled;
if (!permission) return;
setIsCameraEnabled(track.enabled);
});
}
useEffect(() => {
if (!socket) return;
console.log("socket", socket);
}, [socket?.connected]);
function updateRemoteStreams() {
setTimeout(() => {
console.log("users", users);
const newRemoteStreams = remoteStreamsRef.current.filter((remoteStream) =>
users.some((user) => user.peerId === remoteStream.peerId)
);
setRemoteStreams(newRemoteStreams);
}, 500);
}
function handleSetName() {
setStep(2);
getUserMedia();
}
async function getWSUrl() {
console.log("activeSession", activeSession);
setWSUrl(
`wss://${activeSessionRef.current.location}.sess.stream.graff.tech/server/${activeSessionRef.current.localIP}:${activeSessionRef.current.cirrusPort}`
);
setModal(<SetNameModal onAction={handleSetName} />);
checkSessionStatus();
}
function transferControl(userId: string) {
socket?.emit("transfer-control", userId);
}
function requestControl(userId: string) {
socket?.emit("request-control", userId);
}
function kick(userId: string) {
socket?.emit("kick", userId);
}
function videoInitialized() {
socket?.emit("video-initialized", userId);
}
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;
}
setTimeout(async () => {
await checkSessionStatus();
}, 1000);
}
async function init() {
const activeSession = await getActiveSession();
console.log("activeSession init", activeSession);
if (!activeSession || activeSession.status === "error") {
setIsEnded(true);
return;
}
setActiveSession(activeSession);
setIsEnded(false);
setModal(
<SpeedtestModal
onSuccess={(downloadSpeed) => {
setDownloadSpeed(Math.round(downloadSpeed));
getWSUrl();
}}
/>
);
}
useEffect(() => {
init();
// getWSUrl();
// initSocket();
}, []);
useEffect(() => {
console.log("permission", permission);
if (permission === undefined) return;
initPeer();
}, [permission]);
useEffect(() => {
if (step !== 2) return;
if (!isVideoInitialized) {
setModal(<LoadingModal />);
} else {
videoInitialized();
setModal(null);
}
}, [step, isVideoInitialized]);
useEffect(() => {
if (!peerId) return;
initSocket();
}, [peerId]);
useEffect(() => {
if (!users.length) return;
updateRemoteStreams();
}, [users.length]);
useEffect(() => {
toggleCamera();
toggleMic();
}, [localStream]);
return (
<div
ref={fullscreenRef}
className="h-dvh flex lg:flex-col bg-[#111C26] overflow-hidden"
>
{isEnded === false && (
<>
<Draggable
disabled={true}
positionOffset={!isMobile ? { x: "-50%", y: 0 } : { x: 0, y: 0 }}
defaultClassName={`lg:fixed top-2 left-1/2 z-10 transition-opacity ${
me ? "opacity-100" : "opacity-0"
}`}
defaultClassNameDragging="cursor-grabbing"
>
<div
className={`flex flex-col px-2 bg-white max-lg:flex-col lg:h-12 lg:px-6 max-lg:py-2 lg:min-w-[744px] lg:rounded-lg`}
>
<div
className={`flex flex-1 justify-between items-center max-lg:flex-col`}
>
<div className="lg:pr-6">
<img
src="/images/logo24.svg"
alt=""
className="hidden pointer-events-none lg:block"
/>
</div>
<div className="flex gap-4 items-center max-lg:flex-col">
<div className="hidden gap-2 items-center lg:flex">
{/* <div className="relative w-6 h-6 bg-[#E6ECF2] rounded-full flex items-center justify-center">
<p className="text-xs font-semibold">
{name[0]?.toUpperCase()}
</p>
{me?.isControlAllowed && (
<div className="absolute bottom-0 right-0 bg-[#49A1F5] w-2 h-2 rounded-full border border-white"></div>
)}
</div> */}
{me && (
<div className="-mt-0.5">
{(me.downloadSpeed < 10 && <InternetSpeedLowIcon />) ||
(me.downloadSpeed < 20 && (
<InternetSpeedMediumIcon />
)) ||
(me.downloadSpeed >= 20 && <InternetSpeedHighIcon />)}
</div>
)}
<p className="text-xs">{name}</p>
<div className="text-[#CCCCCC]">
{isMobile ? <MobileIcon /> : <DesktopIcon />}
</div>
</div>
<div className="flex gap-2 items-center max-lg:flex-col">
<div className="relative group">
<Button
variant="secondary"
icon={
me?.isControlAllowed ? (
<HandOnIcon />
) : (
<HandOffIcon />
)
}
onlyIcon
onClick={() =>
me!.isAdmin
? transferControl(me!.id)
: requestControl(me!.id)
}
/>
{me?.isAdmin && !me.isControlAllowed && (
<Tooltip text={t("tooltips.returnControl")} />
)}
{!me?.isAdmin && !me?.isControlAllowed && (
<Tooltip text={t("tooltips.requestControl")} />
)}
</div>
{permission && (
<>
<div className="relative group">
<Button
variant="secondary"
icon={
isMicEnabled ? <MicroOnIcon /> : <MicroOffIcon />
}
onlyIcon
onClick={toggleMic}
/>
<Tooltip
text={
isMicEnabled
? t("tooltips.turnOffMic")
: t("tooltips.turnOnMic")
}
/>
</div>
<div className="relative group">
<Button
variant="secondary"
icon={
isCameraEnabled ? (
<CameraOnIcon />
) : (
<CameraOffIcon />
)
}
onlyIcon
onClick={toggleCamera}
/>
<Tooltip
text={
isCameraEnabled
? t("tooltips.turnOffCamera")
: t("tooltips.turnOnCamera")
}
/>
</div>
</>
)}
</div>
<div className="h-4 w-px bg-[#DAE0E5] lg:block hidden"></div>
<div className="flex gap-6">
<div className="relative group">
<Button
variant="secondary"
icon={<UsersIcon />}
onlyIcon
onClick={() => (
setIsShowUsers((prev) => !prev), setIsShowChat(false)
)}
/>
<Tooltip
text={
isShowUsers
? t("tooltips.hideParticipants")
: t("tooltips.showParticipants")
}
/>
{users.filter((user) => user.id !== userId).length >
0 && (
<div className="absolute flex items-center justify-center w-4 h-4 bg-[#49A1F5] rounded-full -top-1 -right-1">
<p className="text-[10px] text-white">
{users.filter((user) => user.id !== userId).length}
</p>
</div>
)}
</div>
{/* {me &&
users.map((user) => {
if (user.id !== userId) {
return (
<User
me={me}
user={user}
handleTransferControl={() =>
transferControl(user.id)
}
handleKick={() => kick(user.id)}
/>
);
}
})} */}
</div>
</div>
<div className="flex gap-2 items-center lg:gap-4 max-lg:flex-col lg:ml-auto">
<div className="relative group">
<Button
variant="secondary"
icon={<ChatIcon />}
onlyIcon
onClick={() => (
setIsShowChat((prev) => !prev), setIsShowUsers(false)
)}
/>
<Tooltip
text={
isShowChat ? t("tooltips.hideChat") : t("tooltips.showChat")
}
/>
</div>
<div className="w-px h-4 bg-[#DAE0E5] max-lg:hidden"></div>
<div className="flex gap-2 max-lg:flex-col lg:ml-auto">
<div className="relative group">
<Button
variant="secondary"
icon={<ShareIcon />}
onlyIcon
onClick={() => setModal(<InviteModal />)}
/>
<Tooltip text={t("tooltips.share")} />
</div>
{!isIOS && (
<div className="relative group">
<Button
variant="secondary"
icon={
isFullscreen ? <WindowIcon /> : <FullscreenIcon />
}
onlyIcon
onClick={toggleFullscreen}
/>
<Tooltip
text={
isFullscreen
? t("tooltips.windowedMode")
: t("tooltips.fullscreenMode")
}
/>
</div>
)}
</div>
</div>
</div>
</div>
</Draggable>
<div className="flex relative flex-1">
{WSUrl && (
<PixelStreamingWrapper2
initialSettings={{
AutoPlayVideo: true,
AutoConnect: true,
ss: WSUrl,
StartVideoMuted: true,
HoveringMouse: true,
WaitForStreamer: true,
}}
onVideoInitialized={() => setIsVideoInitialized(true)}
/>
)}
{step !== 1 &&
!users.find((user) => user.id === userId)?.isControlAllowed && (
<div
className="absolute top-0 left-0 w-full h-full"
onClick={() => toast.warn(t("toasts.needPermission"))}
></div>
)}
<Draggable
disabled={isMobile}
defaultClassName={`cursor-grab transition-opacity ${
isMobile ? "overflow-y-auto h-dvh" : ""
} ${me ? "opacity-100" : "opacity-0"}`}
defaultClassNameDragging="cursor-grabbing"
>
<div className="absolute top-2 space-y-2 lg:left-2 max-lg:right-2">
<div
className={`relative border-2 rounded-lg ${
!permission || !isCameraEnabled ? "hidden" : ""
} ${
isMicEnabled && isSpeaking
? "border-green-500"
: "border-transparent"
}`}
>
<video
ref={localVideoRef}
className={`object-cover bg-gray-500 rounded-lg aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] -scale-x-100`}
playsInline
autoPlay
muted
></video>
<div className="absolute bottom-0 p-2">
<p className="text-xs text-white truncate lg:text-sm">
{name}
</p>
</div>
</div>
{remoteStreams.map(({ peerId, mediaStream }) => (
<Video
key={peerId}
mediaStream={mediaStream}
muted={!permission}
user={users.find((user) => user.peerId === peerId)}
/>
))}
</div>
</Draggable>
</div>
{isShowChat && <Chat2 onClose={() => setIsShowChat(false)} />}
{isShowUsers && (
<Users
onClose={() => setIsShowUsers(false)}
transferControl={(userId) => transferControl(userId)}
kick={(userId) => kick(userId)}
/>
)}
{isPortrait && (
<div className="flex absolute top-0 left-0 flex-col gap-2 justify-center items-center w-full h-full bg-white">
<Rotate64Icon />
<p className="font-semibold">
<Trans i18nKey={"stream.rotateDevice"}>Поверните устройство</Trans>
</p>
</div>
)}
<ToastContainer />
<ModalContainer2 />
</>
)}
{isEnded === true && (
<div className="flex flex-1 justify-center items-center p-8">
<p className="text-2xl text-center text-white font-gilroy">
<Trans i18nKey={"demonstrationCompleted"}>
Данная демонстрация была завершена
</Trans>
</p>
</div>
)}
</div>
);
}
export default StreamPage;