Compare commits

...

2 Commits

6 changed files with 597 additions and 89 deletions
+1 -1
View File
@@ -507,7 +507,7 @@ function App() {
</a>
</p>
<p className="text-xs text-[#C5C7CE]">
© 2023 GRAFF interactive.{" "}
© 2026 GRAFF interactive.{" "}
<Trans i18nKey={"footer.text"}>Все права защищены.</Trans>
</p>
</div>
+57 -14
View File
@@ -13,44 +13,76 @@ interface Props {
user?: IUser;
}
const SPEAKING_HIDE_DELAY_MS = 400;
function Video({ mediaStream, muted, user }: Props) {
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const remoteAudioRef = useRef<HTMLAudioElement>(null);
const isSpeaking = useIsAudioActive({ source: mediaStream });
const [showSpeakingBorder, setShowSpeakingBorder] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [_muted, setMuted] = useState(muted);
const [isLoading, setIsLoading] = useState(true);
const [minimized, setMinimized] = useState(user?.isAdmin ? false : true);
const hasVideo = (mediaStream?.getVideoTracks().length ?? 0) > 0;
useEffect(() => {
if (user && user.micEnabled === false) {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowSpeakingBorder(false);
return;
}
if (isSpeaking) {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowSpeakingBorder(true);
} else {
hideTimeoutRef.current = setTimeout(() => {
setShowSpeakingBorder(false);
hideTimeoutRef.current = null;
}, SPEAKING_HIDE_DELAY_MS);
}
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, [isSpeaking, user?.micEnabled]);
function toggleSound() {
if (!remoteVideoRef.current) return;
// remoteVideoRef.current.muted = !remoteVideoRef.current.muted;
setMuted((prev) => !prev);
}
useEffect(() => {
if (!remoteVideoRef.current) return;
if (!mediaStream) return;
if (hasVideo && remoteVideoRef.current) {
remoteVideoRef.current.srcObject = mediaStream;
remoteVideoRef.current.onloadedmetadata = () => {
remoteVideoRef.current?.play();
};
remoteVideoRef.current.onplay = () => {
setIsLoading(false);
remoteVideoRef.current.onplay = () => setIsLoading(false);
} else if (!hasVideo && remoteAudioRef.current) {
remoteAudioRef.current.srcObject = mediaStream;
remoteAudioRef.current.onloadedmetadata = () => {
remoteAudioRef.current?.play();
};
console.log("mediaStream", mediaStream?.getTracks());
}, [mediaStream]);
useEffect(() => {
console.log("remoteVideoRef.current", remoteVideoRef);
}, [remoteVideoRef.current]);
remoteAudioRef.current.onplay = () => setIsLoading(false);
setIsLoading(false);
}
}, [mediaStream, hasVideo]);
return (
<div
className={`relative border-2 rounded-lg ${
minimized ? "h-8 rounded-lg overflow-hidden" : ""
} ${!_muted && user?.micEnabled && isSpeaking ? "border-green-500" : "border-transparent"}`}
} ${!_muted && user?.micEnabled !== false && showSpeakingBorder ? "border-green-500" : "border-transparent"}`}
>
{hasVideo ? (
<video
ref={remoteVideoRef}
className={`aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg object-cover bg-gray-500`}
@@ -58,6 +90,17 @@ function Video({ mediaStream, muted, user }: Props) {
autoPlay
muted={_muted}
></video>
) : (
<>
<audio
ref={remoteAudioRef}
autoPlay
muted={_muted}
className="hidden"
/>
<div className="aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg bg-gray-500" />
</>
)}
<div
className={`absolute -bottom-1.5 flex items-center justify-between w-full gap-2 p-2 ${
minimized ? "bg-black" : ""
+332 -9
View File
@@ -1,37 +1,237 @@
import { ChangeEvent, FormEvent } from "react";
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
import Input from "../../ui/Input";
import useStreamStore from "../../../stores/useStreamStore";
import Button from "../../ui/Button";
import useModalStore from "../../../stores/useModalStore";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import MicroOnIcon from "../../icons/MicroOnIcon";
import MicroOffIcon from "../../icons/MicroOffIcon";
import CameraOnIcon from "../../icons/CameraOnIcon";
import CameraOffIcon from "../../icons/CameraOffIcon";
interface Props {
onAction: () => void;
onAction: (
audioStream: MediaStream | null,
selectedAudioDeviceId: string,
videoStream: MediaStream | null,
selectedVideoDeviceId: string
) => void;
}
function SetNameModal({ onAction }: Props) {
const { t } = useTranslation();
const { name, setName } = useStreamStore();
const { setModal } = useModalStore();
const [micStatus, setMicStatus] = useState<
"checking" | "success" | "error"
>("checking");
const [cameraStatus, setCameraStatus] = useState<
"checking" | "success" | "error"
>("checking");
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([]);
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedAudioDeviceId, setSelectedAudioDeviceId] =
useState<string>("");
const [selectedVideoDeviceId, setSelectedVideoDeviceId] =
useState<string>("");
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
const [videoStream, setVideoStream] = useState<MediaStream | null>(null);
const audioStreamRef = useRef<MediaStream | null>(null);
const videoStreamRef = useRef<MediaStream | null>(null);
async function checkDevices(audioDeviceId?: string, videoDeviceId?: string) {
setMicStatus("checking");
setCameraStatus("checking");
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
videoStreamRef.current = null;
try {
const audioConstraints: MediaStreamConstraints = {
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
};
const audioStreamResult = await navigator.mediaDevices.getUserMedia(
audioConstraints
);
const audioTracks = audioStreamResult.getAudioTracks();
if (audioTracks.length) {
const audioMediaStream = new MediaStream(audioTracks);
audioStreamRef.current = audioMediaStream;
setAudioStream(audioMediaStream);
setMicStatus("success");
} else {
setMicStatus("error");
}
} catch {
setMicStatus("error");
}
try {
const videoConstraints: MediaStreamConstraints = {
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
};
const videoStreamResult = await navigator.mediaDevices.getUserMedia(
videoConstraints
);
const videoTracks = videoStreamResult.getVideoTracks();
if (videoTracks.length) {
const videoMediaStream = new MediaStream(videoTracks);
videoStreamRef.current = videoMediaStream;
setVideoStream(videoMediaStream);
setCameraStatus("success");
} else {
setCameraStatus("error");
}
} catch {
setCameraStatus("error");
}
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones = devices.filter((d) => d.kind === "audioinput");
const cameras = devices.filter((d) => d.kind === "videoinput");
setAudioDevices(microphones);
setVideoDevices(cameras);
setSelectedAudioDeviceId(
audioDeviceId ?? microphones[0]?.deviceId ?? ""
);
setSelectedVideoDeviceId(
videoDeviceId ?? cameras[0]?.deviceId ?? ""
);
}
async function checkMicrophone(deviceId?: string) {
setMicStatus("checking");
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
audioStreamRef.current = null;
try {
const constraints: MediaStreamConstraints = {
audio: deviceId ? { deviceId: { exact: deviceId } } : true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones = devices.filter((d) => d.kind === "audioinput");
audioStreamRef.current = new MediaStream(stream.getAudioTracks());
setAudioStream(audioStreamRef.current);
setAudioDevices(microphones);
setSelectedAudioDeviceId(deviceId ?? microphones[0]?.deviceId ?? "");
setMicStatus("success");
} catch {
setMicStatus("error");
}
}
async function checkCamera(deviceId?: string) {
setCameraStatus("checking");
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current = null;
try {
const constraints: MediaStreamConstraints = {
video: deviceId ? { deviceId: { exact: deviceId } } : true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === "videoinput");
videoStreamRef.current = new MediaStream(stream.getVideoTracks());
setVideoStream(videoStreamRef.current);
setVideoDevices(cameras);
setSelectedVideoDeviceId(deviceId ?? cameras[0]?.deviceId ?? "");
setCameraStatus("success");
} catch {
setCameraStatus("error");
}
}
useEffect(() => {
checkDevices();
return () => {
audioStreamRef.current?.getTracks().forEach((track) => track.stop());
videoStreamRef.current?.getTracks().forEach((track) => track.stop());
};
}, []);
function handleAudioDeviceChange(e: ChangeEvent<HTMLSelectElement>) {
const id = e.target.value;
setSelectedAudioDeviceId(id);
if (videoStreamRef.current) {
checkMicrophone(id || undefined);
} else {
checkDevices(id || undefined, selectedVideoDeviceId || undefined);
}
}
function handleVideoDeviceChange(e: ChangeEvent<HTMLSelectElement>) {
const id = e.target.value;
setSelectedVideoDeviceId(id);
if (audioStreamRef.current) {
checkCamera(id || undefined);
} else {
checkDevices(selectedAudioDeviceId || undefined, id || undefined);
}
}
function handleChangeName(e: ChangeEvent<HTMLInputElement>) {
setName(e.target.value);
}
function handleAction(
audio: MediaStream | null,
audioId: string,
video: MediaStream | null,
videoId: string
) {
audioStreamRef.current = null;
videoStreamRef.current = null;
setModal(null);
onAction(audio, audioId, video, videoId);
}
function handleClickNoName() {
setName("Guest");
setModal(null);
onAction();
handleAction(
audioStream,
selectedAudioDeviceId,
videoStream,
selectedVideoDeviceId
);
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setModal(null);
onAction();
handleAction(
audioStream,
selectedAudioDeviceId,
videoStream,
selectedVideoDeviceId
);
}
function getDeviceLabel(
device: MediaDeviceInfo,
fallbackKey: string,
prefixKey: string
) {
let label =
device.label ||
(device.deviceId
? `${t(prefixKey)} ${device.deviceId.slice(0, 8)}`
: t(fallbackKey));
label = label.replace(/\s*\([0-9a-fA-F]+:[0-9a-fA-F]+\)\s*$/, "").trim();
return label;
}
return (
<div className="flex items-center justify-center w-full h-full bg-opacity-50 backdrop-blur-2xl">
<div className="bg-white sm:p-12 p-6 sm:rounded-lg space-y-6 sm:h-auto h-full sm:w-auto w-full flex flex-col max-sm:justify-center">
<div className="bg-white sm:p-12 p-6 sm:rounded-lg space-y-6 sm:h-auto h-full sm:w-[494px] w-full flex flex-col max-sm:justify-center max-h-dvh overflow-y-auto shadow">
<p className="text-2xl font-semibold">
<Trans i18nKey={"setName.hello"}>Здравствуйте!</Trans>
</p>
@@ -57,9 +257,132 @@ function SetNameModal({ onAction }: Props) {
onChange={handleChangeName}
autoFocus={!name}
required
className="max-sm:w-full"
className="!w-full"
/>
</div>
<div className="space-y-2">
<p className="text-[#77828C]">
<Trans i18nKey={"setName.micDevice"}>Микрофон</Trans>
</p>
<div className="flex gap-2 items-center flex-wrap">
{micStatus === "checking" && (
<p className="text-sm text-[#77828C]">
<Trans i18nKey={"setName.micChecking"}>
Проверка микрофона...
</Trans>
</p>
)}
{micStatus === "success" && (
<div className="flex gap-2 items-center text-green-600">
<MicroOnIcon />
<span className="text-sm">
<Trans i18nKey={"setName.micSuccess"}>
Микрофон подключен
</Trans>
</span>
</div>
)}
{micStatus === "error" && (
<div className="flex gap-2 items-center">
<MicroOffIcon />
<span className="text-sm text-[#EB5757]">
{t("setName.micError")}
</span>
<Button
variant="secondary"
type="button"
onClick={() =>
videoStreamRef.current
? checkMicrophone()
: checkDevices()
}
>
{t("setName.retry")}
</Button>
</div>
)}
</div>
{audioDevices.length >= 1 && micStatus === "success" && (
<select
value={selectedAudioDeviceId}
onChange={handleAudioDeviceChange}
className="bg-white border border-[#DAE0E5] w-full h-10 px-2 py-2.5 rounded-lg text-sm outline-none max-sm:w-full"
>
{audioDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{getDeviceLabel(
device,
"setName.defaultMic",
"setName.micDevice"
)}
</option>
))}
</select>
)}
</div>
<div className="space-y-2">
<p className="text-[#77828C]">
<Trans i18nKey={"setName.cameraDevice"}>Камера</Trans>
</p>
<div className="flex gap-2 items-center flex-wrap">
{cameraStatus === "checking" && (
<p className="text-sm text-[#77828C]">
<Trans i18nKey={"setName.cameraChecking"}>
Проверка камеры...
</Trans>
</p>
)}
{cameraStatus === "success" && (
<div className="flex gap-2 items-center text-green-600">
<CameraOnIcon />
<span className="text-sm">
<Trans i18nKey={"setName.cameraSuccess"}>
Камера подключена
</Trans>
</span>
</div>
)}
{cameraStatus === "error" && (
<div className="flex gap-2 items-center">
<CameraOffIcon />
<span className="text-sm text-[#EB5757]">
{t("setName.cameraError")}
</span>
<Button
variant="secondary"
type="button"
onClick={() =>
audioStreamRef.current
? checkCamera()
: checkDevices()
}
>
{t("setName.retry")}
</Button>
</div>
)}
</div>
{videoDevices.length >= 1 && cameraStatus === "success" && (
<select
value={selectedVideoDeviceId}
onChange={handleVideoDeviceChange}
className="bg-white border border-[#DAE0E5] w-full h-10 px-2 py-2.5 rounded-lg text-sm outline-none max-sm:w-full"
>
{videoDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{getDeviceLabel(
device,
"setName.defaultCamera",
"setName.cameraDevice"
)}
</option>
))}
</select>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
+22
View File
@@ -199,6 +199,17 @@ const resources = {
name: "Имя",
skip: "Не указывать",
continue: "Продолжить",
micChecking: "Проверка микрофона...",
micSuccess: "Микрофон подключен",
micError: "Микрофон недоступен",
retry: "Повторить",
micDevice: "Микрофон",
defaultMic: "Микрофон по умолчанию",
cameraChecking: "Проверка камеры...",
cameraSuccess: "Камера подключена",
cameraError: "Камера недоступна",
cameraDevice: "Камера",
defaultCamera: "Камера по умолчанию",
},
chat: {
placeholder: "Написать сообщение...",
@@ -427,6 +438,17 @@ const resources = {
name: "Name",
skip: "Skip",
continue: "Continue",
micChecking: "Checking microphone...",
micSuccess: "Microphone connected",
micError: "Microphone unavailable",
retry: "Retry",
micDevice: "Microphone",
defaultMic: "Default microphone",
cameraChecking: "Checking camera...",
cameraSuccess: "Camera connected",
cameraError: "Camera unavailable",
cameraDevice: "Camera",
defaultCamera: "Default camera",
},
chat: {
placeholder: "Write a message...",
+125 -13
View File
@@ -80,7 +80,11 @@ function StreamPage() {
const [roomId] = useState<string>(params.id!);
const { socket, setSocket } = useSocketStore();
const { setModal } = useModalStore();
const { name } = useStreamStore();
const {
name,
setSelectedAudioDeviceId,
setSelectedVideoDeviceId,
} = useStreamStore();
const [isMicEnabled, setIsMicEnabled] = useState(true);
const [isCameraEnabled, setIsCameraEnabled] = useState(true);
const [isEnded, setIsEnded] = useState<boolean>();
@@ -123,19 +127,77 @@ function StreamPage() {
});
}
async function getUserMedia() {
async function getUserMedia(
existingAudioStream?: MediaStream | null,
audioDeviceId?: string,
existingVideoStream?: MediaStream | null,
videoDeviceId?: string
) {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
let mediaStream: MediaStream;
const hasAudio =
existingAudioStream && existingAudioStream.getAudioTracks().length;
const hasVideo =
existingVideoStream && existingVideoStream.getVideoTracks().length;
if (hasAudio && hasVideo) {
mediaStream = new MediaStream([
...existingAudioStream!.getAudioTracks(),
...existingVideoStream!.getVideoTracks(),
]);
} else if (hasAudio) {
try {
const videoStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
});
mediaStream = new MediaStream([
...existingAudioStream!.getAudioTracks(),
...videoStream.getVideoTracks(),
]);
} catch {
mediaStream = new MediaStream(
existingAudioStream!.getAudioTracks()
);
}
} else if (hasVideo) {
try {
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
mediaStream = new MediaStream([
...audioStream.getAudioTracks(),
...existingVideoStream!.getVideoTracks(),
]);
} catch {
mediaStream = new MediaStream(
existingVideoStream!.getVideoTracks()
);
}
} else {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
}
if (!localVideoRef.current) return;
if (
localVideoRef.current &&
mediaStream.getVideoTracks().length > 0
) {
localVideoRef.current.srcObject = mediaStream;
localVideoRef.current.onloadedmetadata = () => {
localVideoRef.current?.play();
};
}
setLocalStream(mediaStream);
setPermission(true);
@@ -290,9 +352,21 @@ function StreamPage() {
}, 500);
}
function handleSetName() {
function handleSetName(
audioStream: MediaStream | null,
selectedAudioDeviceId: string,
videoStream: MediaStream | null,
selectedVideoDeviceId: string
) {
setSelectedAudioDeviceId(selectedAudioDeviceId);
setSelectedVideoDeviceId(selectedVideoDeviceId);
setStep(2);
getUserMedia();
getUserMedia(
audioStream,
selectedAudioDeviceId || undefined,
videoStream,
selectedVideoDeviceId || undefined
);
}
async function getWSUrl() {
@@ -420,6 +494,15 @@ function StreamPage() {
}, [users.length]);
useEffect(() => {
if (
localVideoRef.current &&
localStream.getVideoTracks().length > 0
) {
localVideoRef.current.srcObject = localStream;
localVideoRef.current.onloadedmetadata = () => {
localVideoRef.current?.play();
};
}
toggleCamera();
toggleMic();
}, [localStream]);
@@ -505,11 +588,16 @@ function StreamPage() {
{permission && (
<>
{localStream.getAudioTracks().length > 0 && (
<div className="relative group">
<Button
variant="secondary"
icon={
isMicEnabled ? <MicroOnIcon /> : <MicroOffIcon />
isMicEnabled ? (
<MicroOnIcon />
) : (
<MicroOffIcon />
)
}
onlyIcon
onClick={toggleMic}
@@ -522,6 +610,8 @@ function StreamPage() {
}
/>
</div>
)}
{localStream.getVideoTracks().length > 0 && (
<div className="relative group">
<Button
variant="secondary"
@@ -543,6 +633,7 @@ function StreamPage() {
}
/>
</div>
)}
</>
)}
</div>
@@ -674,7 +765,12 @@ function StreamPage() {
<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" : ""
!permission ? "hidden" : ""
} ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "h-8 overflow-hidden"
: ""
} ${
isMicEnabled && isSpeaking
? "border-green-500"
@@ -683,12 +779,28 @@ function StreamPage() {
>
<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`}
className={`object-cover bg-gray-500 rounded-lg aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] -scale-x-100 ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "hidden"
: ""
}`}
playsInline
autoPlay
muted
></video>
<div className="absolute bottom-0 p-2">
{(localStream.getVideoTracks().length === 0 ||
!isCameraEnabled) && (
<div className="aspect-video lg:w-[216px] w-[160px] lg:h-[162px] h-[120px] rounded-lg bg-gray-500" />
)}
<div
className={`absolute -bottom-1.5 flex items-center lg:w-[216px] w-[160px] gap-2 p-2 ${
localStream.getVideoTracks().length === 0 ||
!isCameraEnabled
? "bg-black"
: ""
}`}
>
<p className="text-xs text-white truncate lg:text-sm">
{name}
</p>
+8
View File
@@ -3,10 +3,14 @@ import { devtools, persist } from "zustand/middleware";
interface State {
name: string;
selectedAudioDeviceId: string;
selectedVideoDeviceId: string;
}
interface Actions {
setName: (name: string) => void;
setSelectedAudioDeviceId: (id: string) => void;
setSelectedVideoDeviceId: (id: string) => void;
}
const useStreamStore = create<State & Actions>()(
@@ -14,7 +18,11 @@ const useStreamStore = create<State & Actions>()(
persist(
(set) => ({
name: "",
selectedAudioDeviceId: "",
selectedVideoDeviceId: "",
setName: (name) => set({ name }),
setSelectedAudioDeviceId: (id) => set({ selectedAudioDeviceId: id }),
setSelectedVideoDeviceId: (id) => set({ selectedVideoDeviceId: id }),
}),
{
name: "auth",