Кликните для запуска видео
@@ -259,7 +319,7 @@ export default function UserCamera({
className={clsx(
"object-cover size-full",
isLocal && "scale-x-[-1]",
- !mediaStream && "hidden"
+ (!mediaStream || isVideoOff) && "hidden"
)}
autoPlay
muted={isLocal ? isMuted : isAudioMuted}
@@ -270,17 +330,27 @@ export default function UserCamera({
loop={false}
onLoadedData={() => {
if (!isLocal && ref.current) {
- console.log(`[UserCamera] onLoadedData for ${name}, attempting play, readyState: ${ref.current.readyState}`);
+ console.log(
+ `[UserCamera] onLoadedData for ${name}, attempting play, readyState: ${ref.current.readyState}`
+ );
ref.current.play().catch((error) => {
- console.error(`[UserCamera] onLoadedData play failed for ${name}:`, error);
+ console.error(
+ `[UserCamera] onLoadedData play failed for ${name}:`,
+ error
+ );
});
}
}}
onLoadedMetadata={() => {
if (!isLocal && ref.current) {
- console.log(`[UserCamera] onLoadedMetadata for ${name}, attempting play, readyState: ${ref.current.readyState}`);
+ console.log(
+ `[UserCamera] onLoadedMetadata for ${name}, attempting play, readyState: ${ref.current.readyState}`
+ );
ref.current.play().catch((error) => {
- console.error(`[UserCamera] onLoadedMetadata play failed for ${name}:`, error);
+ console.error(
+ `[UserCamera] onLoadedMetadata play failed for ${name}:`,
+ error
+ );
});
}
}}
@@ -288,12 +358,17 @@ export default function UserCamera({
if (!isLocal && ref.current) {
console.log(`[UserCamera] onCanPlay for ${name}, attempting play`);
ref.current.play().catch((error) => {
- console.error(`[UserCamera] onCanPlay play failed for ${name}:`, error);
+ console.error(
+ `[UserCamera] onCanPlay play failed for ${name}:`,
+ error
+ );
});
}
}}
onPlaying={() => {
- console.log(`[UserCamera] onPlaying for ${name} - video is actually playing!`);
+ console.log(
+ `[UserCamera] onPlaying for ${name} - video is actually playing!`
+ );
}}
onClick={(e) => {
e.stopPropagation();
@@ -302,7 +377,7 @@ export default function UserCamera({
/>
{/* Кнопка управления звуком для удаленных участников */}
- {!isLocal && mediaStream && (
+ {!isLocal && mediaStream && !isVideoOff && (
@@ -118,7 +116,7 @@ function HomePage() {
diff --git a/client/src/pages/NewSessionPage.tsx b/client/src/pages/SessionPage.tsx
similarity index 97%
rename from client/src/pages/NewSessionPage.tsx
rename to client/src/pages/SessionPage.tsx
index 67383fd..b11ed94 100644
--- a/client/src/pages/NewSessionPage.tsx
+++ b/client/src/pages/SessionPage.tsx
@@ -22,9 +22,9 @@ import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
import WarningIcon from "../components/icons/WarningIcon";
import Button from "../components/ui/Button";
import LoaderIcon from "../components/icons/LoaderIcon";
-import SessionUsersPanel2 from "../components/SessionUsersPanel2";
+import SessionUsersPanel from "../components/SessionUsersPanel";
-function NewSessionPage() {
+function SessionPage() {
const { setPopup } = usePopupStore();
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -232,9 +232,9 @@ function NewSessionPage() {
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
-
+
);
}
-export default NewSessionPage;
+export default SessionPage;
diff --git a/server/.env b/server/.env
index c9379e2..9f5c018 100644
--- a/server/.env
+++ b/server/.env
@@ -1,2 +1,4 @@
DATABASE_URL=postgres://postgres:v1sq3vD5faXL@194.26.138.94:5432/stream
-JWT_SECRET=b5cf2bd3894fb24191f13dc9dddaeecccc92d0ee298e7ee41c2d0aab51c28fa1
\ No newline at end of file
+JWT_SECRET=b5cf2bd3894fb24191f13dc9dddaeecccc92d0ee298e7ee41c2d0aab51c28fa1
+PORT=6000
+SOCKET_PORT=6001
\ No newline at end of file
diff --git a/server/pm2.config.cjs b/server/pm2.config.cjs
new file mode 100644
index 0000000..a3b4028
--- /dev/null
+++ b/server/pm2.config.cjs
@@ -0,0 +1,8 @@
+module.exports = {
+ apps: [
+ {
+ name: "stream.graff.estate-server",
+ script: "bun ./dist",
+ },
+ ],
+};
diff --git a/server/src/controllers/chat.ts b/server/src/controllers/chat.ts
new file mode 100644
index 0000000..f35f07b
--- /dev/null
+++ b/server/src/controllers/chat.ts
@@ -0,0 +1,38 @@
+import { Elysia, t } from "elysia";
+import { getChatHistory } from "../services/chat";
+
+export const chatController = new Elysia({ prefix: "/sessions" })
+ .get(
+ "/:id/messages",
+ async ({ params, query }) => {
+ const { id } = params;
+ const limit = query.limit ? parseInt(query.limit) : 100;
+
+ try {
+ const messages = await getChatHistory(id, limit);
+
+ return {
+ success: true,
+ messages,
+ count: messages.length,
+ };
+ } catch (error) {
+ console.error("[Chat API] Error fetching chat history:", error);
+ return {
+ success: false,
+ error: "Failed to fetch chat history",
+ messages: [],
+ count: 0,
+ };
+ }
+ },
+ {
+ params: t.Object({
+ id: t.String(),
+ }),
+ query: t.Object({
+ limit: t.Optional(t.String()),
+ }),
+ }
+ );
+
diff --git a/server/src/db/schema/chatMessages.ts b/server/src/db/schema/chatMessages.ts
new file mode 100644
index 0000000..dd31e0a
--- /dev/null
+++ b/server/src/db/schema/chatMessages.ts
@@ -0,0 +1,42 @@
+import { pgTable, uuid, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+import { createInsertSchema, createSelectSchema } from "drizzle-zod";
+import { serverSessions } from "./serverSessions";
+import { users } from "./users";
+
+// Enum для типа сообщения
+export const messageTypeEnum = pgEnum("message_type", ["text", "system"]);
+
+export const chatMessages = pgTable("chat_messages", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ sessionId: uuid("session_id")
+ .notNull()
+ .references(() => serverSessions.id, { onDelete: "cascade" }),
+ userId: uuid("user_id").references(() => users.id), // Nullable для системных сообщений или анонимных пользователей
+ content: text("content").notNull(),
+ type: messageTypeEnum("type").notNull().default("text"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+});
+
+// Relations
+export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
+ session: one(serverSessions, {
+ fields: [chatMessages.sessionId],
+ references: [serverSessions.id],
+ }),
+ user: one(users, {
+ fields: [chatMessages.userId],
+ references: [users.id],
+ }),
+}));
+
+// Zod schemas for validation
+export const insertChatMessageSchema = createInsertSchema(chatMessages);
+export const selectChatMessageSchema = createSelectSchema(chatMessages);
+
+// Type exports
+export type ChatMessage = typeof chatMessages.$inferSelect;
+export type NewChatMessage = typeof chatMessages.$inferInsert;
+
diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts
index 237277a..758d497 100644
--- a/server/src/db/schema/index.ts
+++ b/server/src/db/schema/index.ts
@@ -9,6 +9,7 @@ export * from "./userBranches";
export * from "./serverSessions";
export * from "./authSessions";
export * from "./protectedRoutes";
+export * from "./chatMessages";
// Relations (defined here to avoid circular dependencies)
import { relations } from "drizzle-orm";
diff --git a/server/src/index.ts b/server/src/index.ts
index dc13dcc..3641463 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -5,9 +5,12 @@ import { sessionController } from "./controllers/session";
import { companyController } from "./controllers/company";
import { branchController } from "./controllers/branch";
import { serverController } from "./controllers/server";
+import { chatController } from "./controllers/chat";
import { serverSessionService } from "./services/serverSession";
+import { saveChatMessage } from "./services/chat";
import { Server } from "socket.io";
import { createServer } from "http";
+import { AddressInfo } from "net";
const app = new Elysia();
@@ -23,8 +26,9 @@ app.use(sessionController);
app.use(companyController);
app.use(branchController);
app.use(serverController);
+app.use(chatController);
-app.listen(3000);
+app.listen(process.env.PORT || 3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
@@ -38,8 +42,12 @@ const io = new Server(httpServer, {
},
});
-httpServer.listen(3001, () => {
- console.log("🎥 WebRTC Socket.IO server running on port 3001");
+httpServer.listen(process.env.SOCKET_PORT || 3001, () => {
+ console.log(
+ `🎥 WebRTC Socket.IO server running on port ${
+ (httpServer.address() as AddressInfo).port
+ }`
+ );
});
interface Room {
@@ -75,8 +83,10 @@ io.on("connection", (socket) => {
console.log(`[WebRTC] User connected: ${socket.id}`);
// Присоединение к комнате
- socket.on("join-room", ({ roomId, userId }) => {
- console.log(`[WebRTC] User ${userId} (socket: ${socket.id}) joining room ${roomId}`);
+ socket.on("join-room", async ({ roomId, userId, isAudioEnabled, isVideoEnabled }) => {
+ console.log(
+ `[WebRTC] User ${userId} (socket: ${socket.id}) joining room ${roomId}, audio: ${isAudioEnabled}, video: ${isVideoEnabled}`
+ );
// Покинуть предыдущую комнату если была
const existingUser = users.get(userId);
@@ -121,20 +131,29 @@ io.on("connection", (socket) => {
// Уведомить других участников
socket.to(roomId).emit("user-joined", userId);
- console.log(`[WebRTC] Notified room ${roomId} about user ${userId} joining`);
+ console.log(
+ `[WebRTC] Notified room ${roomId} about user ${userId} joining`
+ );
+
+ // Отправить состояние аудио/видео нового пользователя всем в комнате
+ socket.to(roomId).emit("audio-toggle", { userId, isEnabled: isAudioEnabled !== false });
+ socket.to(roomId).emit("video-toggle", { userId, isEnabled: isVideoEnabled !== false });
// Отправить список участников новому пользователю
const participants = Array.from(room.participants).filter(
(id) => id !== userId
);
- console.log(`[WebRTC] Sending participant list to ${userId}:`, participants);
+ console.log(
+ `[WebRTC] Sending participant list to ${userId}:`,
+ participants
+ );
socket.emit("room-participants", participants);
});
// Покидание комнаты
socket.on("leave-room", ({ roomId, userId }) => {
console.log(`[WebRTC] User ${userId} leaving room ${roomId}`);
-
+
socket.leave(roomId);
const room = rooms.get(roomId);
if (room) {
@@ -194,6 +213,65 @@ io.on("connection", (socket) => {
}
});
+ // Обработка audio/video toggle
+ socket.on("audio-toggle", ({ roomId, userId, isEnabled }) => {
+ console.log(`[WebRTC] Audio toggle from ${userId} in room ${roomId}: ${isEnabled}`);
+ // Отправляем всем в комнате (кроме отправителя)
+ socket.to(roomId).emit("audio-toggle", { userId, isEnabled });
+ });
+
+ socket.on("video-toggle", ({ roomId, userId, isEnabled }) => {
+ console.log(`[WebRTC] Video toggle from ${userId} in room ${roomId}: ${isEnabled}`);
+ // Отправляем всем в комнате (кроме отправителя)
+ socket.to(roomId).emit("video-toggle", { userId, isEnabled });
+ });
+
+ // Обработка speaking state
+ socket.on("speaking-state", ({ roomId, userId, isSpeaking }) => {
+ // Отправляем всем в комнате (кроме отправителя)
+ socket.to(roomId).emit("speaking-state", { userId, isSpeaking });
+ });
+
+ // Обработка сообщений чата
+ socket.on("chat-message", async ({ roomId, userId, content, userName }) => {
+ console.log(`[Chat] Message from ${userId} in room ${roomId}: ${content}`);
+
+ const user = findUserBySocketId(socket.id);
+ if (!user || user.roomId !== roomId) {
+ console.warn(`[Chat] User ${socket.id} is not in room ${roomId}`);
+ return;
+ }
+
+ try {
+ // Сохраняем сообщение в БД
+ const savedMessage = await saveChatMessage({
+ sessionId: roomId,
+ userId: userId || null, // null для анонимных пользователей
+ content,
+ type: "text",
+ });
+
+ // Формируем сообщение для отправки клиентам
+ const messageToSend = {
+ id: savedMessage.id,
+ senderId: userId,
+ senderName: userName,
+ content: savedMessage.content,
+ timestamp: savedMessage.createdAt,
+ type: savedMessage.type,
+ };
+
+ // Отправляем всем в комнате (включая отправителя)
+ io.to(roomId).emit("chat-message", messageToSend);
+ console.log(`[Chat] Message broadcasted to room ${roomId}`);
+ } catch (error) {
+ console.error(`[Chat] Error saving message:`, error);
+ socket.emit("chat-error", {
+ message: "Failed to save message",
+ });
+ }
+ });
+
// Отключение
socket.on("disconnect", () => {
console.log(`[WebRTC] User disconnected: ${socket.id}`);
@@ -215,7 +293,9 @@ io.on("connection", (socket) => {
// Удалить пустую комнату
if (room.participants.size === 0) {
rooms.delete(disconnectedUser.roomId);
- console.log(`[WebRTC] Deleted empty room ${disconnectedUser.roomId}`);
+ console.log(
+ `[WebRTC] Deleted empty room ${disconnectedUser.roomId}`
+ );
}
}
}
diff --git a/server/src/services/chat/index.ts b/server/src/services/chat/index.ts
new file mode 100644
index 0000000..14e9f5c
--- /dev/null
+++ b/server/src/services/chat/index.ts
@@ -0,0 +1,42 @@
+import db from "../../db";
+import {
+ chatMessages,
+ type NewChatMessage,
+} from "../../db/schema/chatMessages";
+import { eq, desc } from "drizzle-orm";
+
+/**
+ * Сохранить новое сообщение в чате
+ */
+export async function saveChatMessage(message: NewChatMessage) {
+ const [newMessage] = await db
+ .insert(chatMessages)
+ .values(message)
+ .returning();
+ return newMessage;
+}
+
+/**
+ * Получить историю сообщений для сессии
+ * @param sessionId ID сессии
+ * @param limit Максимальное количество сообщений (по умолчанию 100)
+ */
+export async function getChatHistory(sessionId: string, limit = 100) {
+ const messages = await db
+ .select()
+ .from(chatMessages)
+ .where(eq(chatMessages.sessionId, sessionId))
+ .orderBy(desc(chatMessages.createdAt))
+ .limit(limit);
+
+ // Возвращаем в правильном порядке (старые сначала)
+ return messages.reverse();
+}
+
+/**
+ * Удалить все сообщения для сессии
+ * @param sessionId ID сессии
+ */
+export async function deleteChatHistory(sessionId: string) {
+ await db.delete(chatMessages).where(eq(chatMessages.sessionId, sessionId));
+}
diff --git a/stream.graff.estate.conf b/stream.graff.estate.conf
new file mode 100644
index 0000000..040bccb
--- /dev/null
+++ b/stream.graff.estate.conf
@@ -0,0 +1,51 @@
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name stream.graff.estate;
+ root /var/www/stream.graff.estate/client/dist;
+
+ # SSL
+ ssl_certificate /etc/letsencrypt/live/stream.graff.estate/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/stream.graff.estate/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/stream.graff.estate/chain.pem;
+
+ # security
+ include nginxconfig.io/security.conf;
+
+ # logging
+ access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
+ error_log /var/log/nginx/error.log warn;
+
+ # index.html fallback
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api {
+ rewrite ^/api/(.*)$ /$1 break;
+ proxy_pass http://127.0.0.1:6000;
+ proxy_set_header Host $host;
+ include nginxconfig.io/proxy.conf;
+ }
+
+ location /socket.io {
+ proxy_pass http://127.0.0.1:6001;
+ proxy_set_header Host $host;
+ include nginxconfig.io/proxy.conf;
+ }
+
+ # additional config
+ include nginxconfig.io/general.conf;
+}
+
+# HTTP redirect
+server {
+ listen 80;
+ listen [::]:80;
+ server_name stream.graff.estate;
+ include nginxconfig.io/letsencrypt.conf;
+
+ location / {
+ return 301 https://stream.graff.estate$request_uri;
+ }
+}
\ No newline at end of file