Update footer copyright year to 2026 and enhance media device management in SetNameModal and StreamPage components. Added microphone and camera status handling, device selection, and improved user experience with updated state management in the stream store.

This commit is contained in:
2026-02-27 15:21:28 +05:00
parent 42082faf0d
commit cb156bd99d
5 changed files with 422 additions and 18 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>
+324 -9
View File
@@ -1,37 +1,229 @@
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 constraints: MediaStreamConstraints = {
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const devices = await navigator.mediaDevices.enumerateDevices();
const microphones = devices.filter((d) => d.kind === "audioinput");
const cameras = devices.filter((d) => d.kind === "videoinput");
const audioTracks = stream.getAudioTracks();
const videoTracks = stream.getVideoTracks();
if (audioTracks.length) {
const audioMediaStream = new MediaStream(audioTracks);
audioStreamRef.current = audioMediaStream;
setAudioStream(audioMediaStream);
setAudioDevices(microphones);
setSelectedAudioDeviceId(
audioDeviceId ?? microphones[0]?.deviceId ?? ""
);
setMicStatus("success");
} else {
setMicStatus("error");
}
if (videoTracks.length) {
const videoMediaStream = new MediaStream(videoTracks);
videoStreamRef.current = videoMediaStream;
setVideoStream(videoMediaStream);
setVideoDevices(cameras);
setSelectedVideoDeviceId(
videoDeviceId ?? cameras[0]?.deviceId ?? ""
);
setCameraStatus("success");
} else {
setCameraStatus("error");
}
} catch {
setMicStatus("error");
setCameraStatus("error");
}
}
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 +249,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...",
+67 -8
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,12 +127,55 @@ 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) {
const videoStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
});
mediaStream = new MediaStream([
...existingAudioStream!.getAudioTracks(),
...videoStream.getVideoTracks(),
]);
} else if (hasVideo) {
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
mediaStream = new MediaStream([
...audioStream.getAudioTracks(),
...existingVideoStream!.getVideoTracks(),
]);
} else {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: true,
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
});
}
if (!localVideoRef.current) return;
@@ -290,9 +337,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() {
+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",