Update environment configurations for local development, add socket.io and uuid dependencies, and refactor session management to support guest IDs for unauthorized users. Enhance ParticipantsPopup and UserCamera components to handle local media permissions and improve user session handling. Update optional authentication middleware to manage guest IDs and session validation.

This commit is contained in:
2025-10-28 16:58:38 +05:00
parent 2378ed1ff4
commit 4b81b22a1d
17 changed files with 375 additions and 203 deletions
@@ -72,6 +72,7 @@ function SessionUsersPanel({
isControlDisabled={true}
isAdmin={true} // Локальный пользователь - админ своей сессии
mediaStream={participant.stream}
hasLocalMediaPermission={hasLocalStream}
onMute={() => console.log(`Mute user ${participant.id}`)}
onVideoOff={() => console.log(`Video off user ${participant.id}`)}
onCanControl={() =>
@@ -12,11 +12,24 @@ import { Fragment, useRef } from "react";
import DraggableContainer from "../DraggableContainer";
import { useWebRTC } from "../../hooks/useWebRTC";
import type { Participant } from "../../lib/webrtc";
import type { Session } from "../../types/Session";
import { getGuestId } from "../../lib/guestId";
import { useMe } from "../../hooks/useAuth";
export default function ParticipantsPopup() {
interface ParticipantsPopupProps {
session: Session;
}
export default function ParticipantsPopup({ session }: ParticipantsPopupProps) {
const { participants, currentUserId, localStream } = useWebRTC();
const { data: user } = useMe();
const headerRef = useRef<HTMLDivElement>(null);
// Определяем, является ли текущий пользователь организатором
const isOrganizer =
!!(session.userId && user?.id === session.userId) ||
!!(session.guestId && getGuestId() === session.guestId);
// Добавляем локального пользователя в начало списка
const allParticipants: (Participant & { isLocal?: boolean })[] = [
{
@@ -39,15 +52,17 @@ export default function ParticipantsPopup() {
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:-mt-[1.389vw] -mt-5">
<div className="flex flex-col gap-4 2xl:gap-[1.111vw] 2xl:max-h-[calc(11.944vw+1.389vw)] max-h-[73.75dvh] overflow-y-auto 2xl:pt-[1.389vw] pt-5">
{allParticipants.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<div className="py-8 text-center text-gray-500">
Нет участников
</div>
) : (
allParticipants.map((participant) => (
<Fragment key={participant.id}>
<ParticipantItem
participant={participant}
<ParticipantItem
participant={participant}
isLocal={participant.isLocal || false}
isOrganizer={isOrganizer}
session={session}
/>
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
</Fragment>
@@ -79,23 +94,38 @@ export default function ParticipantsPopup() {
);
}
function ParticipantItem({
participant,
isLocal
}: {
participant: Participant & { isLocal?: boolean };
function ParticipantItem({
participant,
isLocal,
isOrganizer,
session,
}: {
participant: Participant & { isLocal?: boolean };
isLocal: boolean;
isOrganizer: boolean;
session: Session;
}) {
const parentRef = useRef<HTMLDivElement>(null);
// Проверяем наличие аудио/видео треков
const hasAudio = participant.stream?.getAudioTracks().some(track => track.enabled) ?? false;
const hasVideo = participant.stream?.getVideoTracks().some(track => track.enabled) ?? false;
const hasAudio =
participant.stream?.getAudioTracks().some((track) => track.enabled) ??
false;
const hasVideo =
participant.stream?.getVideoTracks().some((track) => track.enabled) ??
false;
const isMuted = !hasAudio;
const isVideoOff = !hasVideo;
// Определяем статус участника
const status: "admin" | "caution" | undefined = participant.stream ? "admin" : "caution";
// Определяем, является ли этот конкретный участник организатором сессии
const isThisParticipantOrganizer =
(session.userId && participant.id === session.userId) ||
(session.guestId && participant.id === session.guestId);
// Определяем статус участника для аватара
const status: "admin" | "caution" | undefined = isThisParticipantOrganizer
? "admin"
: undefined;
return (
<div ref={parentRef} className="flex justify-between items-center w-full">
@@ -103,10 +133,12 @@ function ParticipantItem({
<Avatar size="medium" status={status} />
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1">
<span className="button-m">
{isLocal ? "Вы" : participant.name || `Участник ${participant.id.slice(0, 8)}`}
{isLocal
? "Вы"
: participant.name || `Участник ${participant.id.slice(0, 8)}`}
</span>
<span className="caption-s text-[#CCCCCC]">
{isLocal ? "Организатор" : "Участник"}
{isThisParticipantOrganizer ? "Организатор" : "Участник"}
</span>
</div>
</div>
@@ -123,8 +155,8 @@ function ParticipantItem({
</div>
)}
{/* Действия только для удаленных участников и только для администратора */}
{!isLocal && (
{/* Действия только для удаленных участников и только для организатора */}
{!isLocal && isOrganizer && (
<ActionsPopover
options={[
{
+12 -25
View File
@@ -35,6 +35,7 @@ interface UserCameraProps {
isLocal?: boolean;
isSpeaking?: boolean; // Для удаленных участников - получаем по Socket.IO
onSpeakingChange?: (isSpeaking: boolean) => void; // Для локального - отправляем изменения
hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа
}
export default function UserCamera({
@@ -50,9 +51,11 @@ export default function UserCamera({
isLocal = false,
isSpeaking: remoteSpeaking,
onSpeakingChange,
hasLocalMediaPermission = false,
}: UserCameraProps) {
const ref = useRef<HTMLVideoElement>(null);
const [isAudioMuted, setIsAudioMuted] = useState(true); // Для удаленных участников - начинаем с muted
// Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay)
const [isAudioMuted, setIsAudioMuted] = useState(!hasLocalMediaPermission);
// Детекция голосовой активности (только для локального пользователя)
const { isSpeaking: isVoiceActive } = useVoiceActivity(
@@ -62,7 +65,7 @@ export default function UserCamera({
// Для локального - используем локальную детекцию
// Для удаленных - используем полученное состояние через Socket.IO
const localSpeaking = !isMuted && isVoiceActive;
const isSpeaking = isLocal ? localSpeaking : (remoteSpeaking || false);
const isSpeaking = isLocal ? localSpeaking : remoteSpeaking || false;
// Отправляем изменения состояния для локального пользователя
useEffect(() => {
@@ -78,7 +81,11 @@ export default function UserCamera({
// Логируем для отладки
useEffect(() => {
console.log(
`[${name}${isLocal ? " (local)" : ""}] isSpeaking: ${isSpeaking}, ringOpacity: ${ringOpacity.toFixed(2)}, isMuted: ${isMuted}`
`[${name}${
isLocal ? " (local)" : ""
}] isSpeaking: ${isSpeaking}, ringOpacity: ${ringOpacity.toFixed(
2
)}, isMuted: ${isMuted}`
);
}, [isSpeaking, ringOpacity, name, isMuted, isLocal]);
@@ -90,12 +97,6 @@ export default function UserCamera({
);
ref.current.srcObject = mediaStream;
// Убеждаемся что видео muted для autoplay
if (!isLocal) {
ref.current.muted = true;
console.log(`[UserCamera] Set muted=true for remote video ${name}`);
}
// Принудительно запускаем воспроизведение
ref.current.play().catch((error) => {
console.error(`[UserCamera] Failed to play video for ${name}:`, error);
@@ -237,14 +238,13 @@ export default function UserCamera({
}, [name]);
const toggleRemoteAudio = () => {
if (!isLocal && ref.current) {
if (!isLocal) {
const newMutedState = !isAudioMuted;
ref.current.muted = newMutedState;
setIsAudioMuted(newMutedState);
console.log(
`[UserCamera] ${name} audio ${
newMutedState ? "muted" : "unmuted"
}, video element muted: ${ref.current.muted}`
}`
);
}
};
@@ -305,15 +305,6 @@ export default function UserCamera({
</div>
)}
{/* Подсказка для запуска видео */}
{!isLocal && mediaStream && !isVideoOff && (
<div className="flex absolute inset-0 justify-center items-center opacity-0 transition-opacity duration-300 pointer-events-none bg-black/50 group-hover:opacity-100">
<div className="px-2 py-1 text-xs text-white rounded bg-black/70">
Кликните для запуска видео
</div>
</div>
)}
<video
ref={ref}
className={clsx(
@@ -324,10 +315,6 @@ export default function UserCamera({
autoPlay
muted={isLocal ? isMuted : isAudioMuted}
playsInline
webkit-playsinline="true"
controls={false}
preload="metadata"
loop={false}
onLoadedData={() => {
if (!isLocal && ref.current) {
console.log(
+8 -10
View File
@@ -28,7 +28,7 @@ export function useVoiceActivity(
const [audioLevel, setAudioLevel] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastSpeakingTimeRef = useRef<number>(0);
const speakingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -132,7 +132,7 @@ export function useVoiceActivity(
}
}
// Логируем каждые 30 кадров (~500ms при 60fps)
// Логируем каждые 30 вызовов (~500ms при частоте 60 Hz)
frameCount++;
if (frameCount % 30 === 0) {
console.log(
@@ -145,13 +145,11 @@ export function useVoiceActivity(
}`
);
}
// Запланировать следующую проверку
animationFrameRef.current = requestAnimationFrame(checkVoiceActivity);
};
// Запускаем проверку
checkVoiceActivity();
// Запускаем проверку с интервалом ~16ms (приблизительно 60 FPS)
// setInterval работает стабильно даже когда окно неактивно
intervalRef.current = setInterval(checkVoiceActivity, 16);
console.log(
`[useVoiceActivity] Started voice activity detection - Threshold: ${threshold}, FFT: ${fftSize}, Smoothing: ${smoothingTimeConstant}, Debounce: ${debounceTime}ms`
@@ -167,9 +165,9 @@ export function useVoiceActivity(
// Cleanup
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (speakingTimeoutRef.current !== null) {
+5
View File
@@ -1,4 +1,5 @@
import ky from "ky";
import { getOrCreateGuestId } from "./guestId";
// Базовый API клиент
export const api = ky.create({
@@ -15,6 +16,10 @@ export const api = ky.create({
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
// Автоматически добавляем guestId для всех запросов
const guestId = getOrCreateGuestId();
request.headers.set("X-Guest-Id", guestId);
},
],
},
+42
View File
@@ -0,0 +1,42 @@
import { v4 as uuidv4 } from "uuid";
const GUEST_ID_KEY = "guestId";
/**
* Получает или создает guestId для неавторизованного пользователя
* guestId генерируется один раз и сохраняется в localStorage
* @returns UUID v4 строка
*/
export function getOrCreateGuestId(): string {
// Пытаемся получить существующий guestId
let guestId = localStorage.getItem(GUEST_ID_KEY);
// Если нет - генерируем новый UUID v4
if (!guestId) {
guestId = uuidv4();
localStorage.setItem(GUEST_ID_KEY, guestId);
console.log("Generated new guestId:", guestId);
} else {
console.log("Using existing guestId:", guestId);
}
return guestId;
}
/**
* Очищает guestId из localStorage
* Используется при логине пользователя, если нужно
*/
export function clearGuestId(): void {
localStorage.removeItem(GUEST_ID_KEY);
console.log("Cleared guestId from localStorage");
}
/**
* Получает текущий guestId без создания нового
* @returns UUID v4 строка или null если не существует
*/
export function getGuestId(): string | null {
return localStorage.getItem(GUEST_ID_KEY);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { io, Socket } from "socket.io-client";
import { v4 as uuidv4 } from "uuid";
import { getOrCreateGuestId } from "./guestId";
export interface ChatMessage {
id: string;
@@ -121,8 +121,8 @@ export function createWebRTCService(callbacks: WebRTCCallbacks = {}) {
const socket = io(serverUrl, {
transports: ["websocket", "polling"],
});
const userId = uuidv4();
console.log("Generated user ID:", userId);
const userId = getOrCreateGuestId();
console.log("Using guest ID:", userId);
state = {
socket,
+1 -1
View File
@@ -81,7 +81,7 @@ function SessionPage() {
}
function handleParticipantsOpen() {
setPopup(<ParticipantsPopup />);
setPopup(<ParticipantsPopup session={session} />);
}
function handleShareOpen() {
+1
View File
@@ -2,6 +2,7 @@ export interface Session {
id: string;
appId: string;
userId: string | null;
guestId: string | null;
mode: "stream" | "local";
status: "starting" | "started" | "ending" | "ended";
tier: "demo" | "prod" | null;