diff --git a/apps/web/package.json b/apps/web/package.json index 63d28c7..710fa8b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,14 +16,9 @@ "@tanstack/react-query": "5.73.3", "@tanstack/react-router": "^1.128.8", "@xipkg/button": "3.2.0", - "common.api": "*", - "common.auth": "*", "common.config": "*", - "common.sockets": "*", - "common.theme": "*", "common.ui": "*", "common.utils": "*", - "common.services": "*", "common.env": "*", "common.types": "*", "i18next": "24.2.2", diff --git a/apps/web/src/config/i18n.ts b/apps/web/src/config/i18n.ts index 8ca7e31..17aa38b 100644 --- a/apps/web/src/config/i18n.ts +++ b/apps/web/src/config/i18n.ts @@ -5,17 +5,17 @@ import LocalStorageBackend from 'i18next-localstorage-backend'; // Динамические импорты переводов для уменьшения размера основного бандла // Добавьте импорты переводов из ваших пакетов здесь -const loadTranslations = async () => { - // Пример: const [{ commonEn, commonRu }] = await Promise.all([import('common.ui')]); - - return { - // Добавьте ваши переводы здесь - }; -}; +// const loadTranslations = async () => { +// // Пример: const [{ commonEn, commonRu }] = await Promise.all([import('common.ui')]); + +// return { +// // Добавьте ваши переводы здесь +// }; +// }; // Инициализация i18n с динамической загрузкой переводов const initI18n = async () => { - const translations = await loadTranslations(); + // const translations = await loadTranslations(); const resources = { en: { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f8385a9..26f183c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -9,9 +9,9 @@ @source "../src"; @source "../node_modules/@xipkg"; -// Добавьте @source директивы для ваших пакетов здесь +/* // Добавьте @source директивы для ваших пакетов здесь */ @source "../../../packages/common.ui/src"; -// @source "../../../packages/your-package/src"; +/* // @source "../../../packages/your-package/src"; */ /* app.css или globals.css */ html { diff --git a/apps/web/src/pages/about.tsx b/apps/web/src/pages/about.tsx index fd7958e..cc6b58c 100644 --- a/apps/web/src/pages/about.tsx +++ b/apps/web/src/pages/about.tsx @@ -14,8 +14,8 @@ export const Route = createFileRoute('/about')({ function AboutPage() { return (
-
-

About

+
+

About

This is a starter template built with modern web technologies.

@@ -23,4 +23,3 @@ function AboutPage() {
); } - diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 3990e97..d3e95d9 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -15,12 +15,12 @@ function HomePage() { return (
-

Welcome to Your Starter

+

Welcome to Your Starter

- This is a starter template for React applications with TypeScript, Vite, and TanStack Router. + This is a starter template for React applications with TypeScript, Vite, and TanStack + Router.

); } - diff --git a/apps/web/src/providers/RouterWithAuth.tsx b/apps/web/src/providers/RouterWithAuth.tsx index c2597a6..1d607aa 100644 --- a/apps/web/src/providers/RouterWithAuth.tsx +++ b/apps/web/src/providers/RouterWithAuth.tsx @@ -18,8 +18,8 @@ export const RouterWithAuth = () => { - - + + diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c3b75ed..308f8e9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,606 +9,68 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './pages/__root' -import { Route as commonLayoutRouteImport } from './pages/(common)/_layout' -import { Route as appLayoutRouteImport } from './pages/(app)/_layout' -import { Route as authSignupIndexRouteImport } from './pages/(auth)/signup/index' -import { Route as authSigninIndexRouteImport } from './pages/(auth)/signin/index' -import { Route as authResetPasswordIndexRouteImport } from './pages/(auth)/reset-password/index' -import { Route as appLayoutIndexRouteImport } from './pages/(app)/_layout/index' -import { Route as DeploymentsDeploymentIdEnableRouteImport } from './pages/deployments/$deploymentId.enable' -import { Route as authResetPasswordResetTokenRouteImport } from './pages/(auth)/reset-password/$resetToken' -import { Route as commonWelcomeUserIndexRouteImport } from './pages/(common)/welcome/user/index' -import { Route as commonWelcomeSocialsIndexRouteImport } from './pages/(common)/welcome/socials/index' -import { Route as commonWelcomeRoleIndexRouteImport } from './pages/(common)/welcome/role/index' -import { Route as appLayoutPaymentsIndexRouteImport } from './pages/(app)/_layout/payments/index' -import { Route as appLayoutMaterialsIndexRouteImport } from './pages/(app)/_layout/materials/index' -import { Route as appLayoutClassroomsIndexRouteImport } from './pages/(app)/_layout/classrooms/index' -import { Route as appLayoutCallIndexRouteImport } from './pages/(app)/_layout/call/index' -import { Route as appLayoutCalendarIndexRouteImport } from './pages/(app)/_layout/calendar/index' -import { Route as commonWelcomeEmailEmailIdRouteImport } from './pages/(common)/welcome/email/$emailId' -import { Route as commonLayoutInviteInviteIdRouteImport } from './pages/(common)/_layout/invite/$inviteId' -import { Route as commonLayoutConfirmEmailEmailIdRouteImport } from './pages/(common)/_layout/confirm-email/$emailId' -import { Route as appLayoutCallCallIdRouteImport } from './pages/(app)/_layout/call/$callId' -import { Route as appLayoutBoardBoardIdRouteImport } from './pages/(app)/_layout/board/$boardId' -import { Route as appLayoutClassroomsClassroomIdIndexRouteImport } from './pages/(app)/_layout/classrooms/$classroomId/index' -import { Route as appLayoutMaterialsMaterialIdNoteIndexRouteImport } from './pages/(app)/_layout/materials/$materialId/note/index' -import { Route as appLayoutMaterialsMaterialIdBoardIndexRouteImport } from './pages/(app)/_layout/materials/$materialId/board/index' -import { Route as appLayoutClassroomsClassroomIdNotesNoteIdRouteImport } from './pages/(app)/_layout/classrooms/$classroomId/notes/$noteId' -import { Route as appLayoutClassroomsClassroomIdBoardsBoardIdRouteImport } from './pages/(app)/_layout/classrooms/$classroomId/boards/$boardId' +import { Route as AboutRouteImport } from './pages/about' +import { Route as IndexRouteImport } from './pages/index' -const commonLayoutRoute = commonLayoutRouteImport.update({ - id: '/(common)/_layout', +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', getParentRoute: () => rootRouteImport, } as any) -const appLayoutRoute = appLayoutRouteImport.update({ - id: '/(app)/_layout', - getParentRoute: () => rootRouteImport, -} as any) -const authSignupIndexRoute = authSignupIndexRouteImport.update({ - id: '/(auth)/signup/', - path: '/signup/', - getParentRoute: () => rootRouteImport, -} as any) -const authSigninIndexRoute = authSigninIndexRouteImport.update({ - id: '/(auth)/signin/', - path: '/signin/', - getParentRoute: () => rootRouteImport, -} as any) -const authResetPasswordIndexRoute = authResetPasswordIndexRouteImport.update({ - id: '/(auth)/reset-password/', - path: '/reset-password/', - getParentRoute: () => rootRouteImport, -} as any) -const appLayoutIndexRoute = appLayoutIndexRouteImport.update({ +const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', - getParentRoute: () => appLayoutRoute, -} as any) -const DeploymentsDeploymentIdEnableRoute = - DeploymentsDeploymentIdEnableRouteImport.update({ - id: '/deployments/$deploymentId/enable', - path: '/deployments/$deploymentId/enable', - getParentRoute: () => rootRouteImport, - } as any) -const authResetPasswordResetTokenRoute = - authResetPasswordResetTokenRouteImport.update({ - id: '/(auth)/reset-password/$resetToken', - path: '/reset-password/$resetToken', - getParentRoute: () => rootRouteImport, - } as any) -const commonWelcomeUserIndexRoute = commonWelcomeUserIndexRouteImport.update({ - id: '/(common)/welcome/user/', - path: '/welcome/user/', - getParentRoute: () => rootRouteImport, -} as any) -const commonWelcomeSocialsIndexRoute = - commonWelcomeSocialsIndexRouteImport.update({ - id: '/(common)/welcome/socials/', - path: '/welcome/socials/', - getParentRoute: () => rootRouteImport, - } as any) -const commonWelcomeRoleIndexRoute = commonWelcomeRoleIndexRouteImport.update({ - id: '/(common)/welcome/role/', - path: '/welcome/role/', getParentRoute: () => rootRouteImport, } as any) -const appLayoutPaymentsIndexRoute = appLayoutPaymentsIndexRouteImport.update({ - id: '/payments/', - path: '/payments/', - getParentRoute: () => appLayoutRoute, -} as any) -const appLayoutMaterialsIndexRoute = appLayoutMaterialsIndexRouteImport.update({ - id: '/materials/', - path: '/materials/', - getParentRoute: () => appLayoutRoute, -} as any) -const appLayoutClassroomsIndexRoute = - appLayoutClassroomsIndexRouteImport.update({ - id: '/classrooms/', - path: '/classrooms/', - getParentRoute: () => appLayoutRoute, - } as any) -const appLayoutCallIndexRoute = appLayoutCallIndexRouteImport.update({ - id: '/call/', - path: '/call/', - getParentRoute: () => appLayoutRoute, -} as any) -const appLayoutCalendarIndexRoute = appLayoutCalendarIndexRouteImport.update({ - id: '/calendar/', - path: '/calendar/', - getParentRoute: () => appLayoutRoute, -} as any) -const commonWelcomeEmailEmailIdRoute = - commonWelcomeEmailEmailIdRouteImport.update({ - id: '/(common)/welcome/email/$emailId', - path: '/welcome/email/$emailId', - getParentRoute: () => rootRouteImport, - } as any) -const commonLayoutInviteInviteIdRoute = - commonLayoutInviteInviteIdRouteImport.update({ - id: '/invite/$inviteId', - path: '/invite/$inviteId', - getParentRoute: () => commonLayoutRoute, - } as any) -const commonLayoutConfirmEmailEmailIdRoute = - commonLayoutConfirmEmailEmailIdRouteImport.update({ - id: '/confirm-email/$emailId', - path: '/confirm-email/$emailId', - getParentRoute: () => commonLayoutRoute, - } as any) -const appLayoutCallCallIdRoute = appLayoutCallCallIdRouteImport.update({ - id: '/call/$callId', - path: '/call/$callId', - getParentRoute: () => appLayoutRoute, -} as any) -const appLayoutBoardBoardIdRoute = appLayoutBoardBoardIdRouteImport.update({ - id: '/board/$boardId', - path: '/board/$boardId', - getParentRoute: () => appLayoutRoute, -} as any) -const appLayoutClassroomsClassroomIdIndexRoute = - appLayoutClassroomsClassroomIdIndexRouteImport.update({ - id: '/classrooms/$classroomId/', - path: '/classrooms/$classroomId/', - getParentRoute: () => appLayoutRoute, - } as any) -const appLayoutMaterialsMaterialIdNoteIndexRoute = - appLayoutMaterialsMaterialIdNoteIndexRouteImport.update({ - id: '/materials/$materialId/note/', - path: '/materials/$materialId/note/', - getParentRoute: () => appLayoutRoute, - } as any) -const appLayoutMaterialsMaterialIdBoardIndexRoute = - appLayoutMaterialsMaterialIdBoardIndexRouteImport.update({ - id: '/materials/$materialId/board/', - path: '/materials/$materialId/board/', - getParentRoute: () => appLayoutRoute, - } as any) -const appLayoutClassroomsClassroomIdNotesNoteIdRoute = - appLayoutClassroomsClassroomIdNotesNoteIdRouteImport.update({ - id: '/classrooms/$classroomId/notes/$noteId', - path: '/classrooms/$classroomId/notes/$noteId', - getParentRoute: () => appLayoutRoute, - } as any) -const appLayoutClassroomsClassroomIdBoardsBoardIdRoute = - appLayoutClassroomsClassroomIdBoardsBoardIdRouteImport.update({ - id: '/classrooms/$classroomId/boards/$boardId', - path: '/classrooms/$classroomId/boards/$boardId', - getParentRoute: () => appLayoutRoute, - } as any) export interface FileRoutesByFullPath { - '/reset-password/$resetToken': typeof authResetPasswordResetTokenRoute - '/deployments/$deploymentId/enable': typeof DeploymentsDeploymentIdEnableRoute - '/': typeof appLayoutIndexRoute - '/reset-password': typeof authResetPasswordIndexRoute - '/signin': typeof authSigninIndexRoute - '/signup': typeof authSignupIndexRoute - '/board/$boardId': typeof appLayoutBoardBoardIdRoute - '/call/$callId': typeof appLayoutCallCallIdRoute - '/confirm-email/$emailId': typeof commonLayoutConfirmEmailEmailIdRoute - '/invite/$inviteId': typeof commonLayoutInviteInviteIdRoute - '/welcome/email/$emailId': typeof commonWelcomeEmailEmailIdRoute - '/calendar': typeof appLayoutCalendarIndexRoute - '/call': typeof appLayoutCallIndexRoute - '/classrooms': typeof appLayoutClassroomsIndexRoute - '/materials': typeof appLayoutMaterialsIndexRoute - '/payments': typeof appLayoutPaymentsIndexRoute - '/welcome/role': typeof commonWelcomeRoleIndexRoute - '/welcome/socials': typeof commonWelcomeSocialsIndexRoute - '/welcome/user': typeof commonWelcomeUserIndexRoute - '/classrooms/$classroomId': typeof appLayoutClassroomsClassroomIdIndexRoute - '/classrooms/$classroomId/boards/$boardId': typeof appLayoutClassroomsClassroomIdBoardsBoardIdRoute - '/classrooms/$classroomId/notes/$noteId': typeof appLayoutClassroomsClassroomIdNotesNoteIdRoute - '/materials/$materialId/board': typeof appLayoutMaterialsMaterialIdBoardIndexRoute - '/materials/$materialId/note': typeof appLayoutMaterialsMaterialIdNoteIndexRoute + '/': typeof IndexRoute + '/about': typeof AboutRoute } export interface FileRoutesByTo { - '/reset-password/$resetToken': typeof authResetPasswordResetTokenRoute - '/deployments/$deploymentId/enable': typeof DeploymentsDeploymentIdEnableRoute - '/': typeof appLayoutIndexRoute - '/reset-password': typeof authResetPasswordIndexRoute - '/signin': typeof authSigninIndexRoute - '/signup': typeof authSignupIndexRoute - '/board/$boardId': typeof appLayoutBoardBoardIdRoute - '/call/$callId': typeof appLayoutCallCallIdRoute - '/confirm-email/$emailId': typeof commonLayoutConfirmEmailEmailIdRoute - '/invite/$inviteId': typeof commonLayoutInviteInviteIdRoute - '/welcome/email/$emailId': typeof commonWelcomeEmailEmailIdRoute - '/calendar': typeof appLayoutCalendarIndexRoute - '/call': typeof appLayoutCallIndexRoute - '/classrooms': typeof appLayoutClassroomsIndexRoute - '/materials': typeof appLayoutMaterialsIndexRoute - '/payments': typeof appLayoutPaymentsIndexRoute - '/welcome/role': typeof commonWelcomeRoleIndexRoute - '/welcome/socials': typeof commonWelcomeSocialsIndexRoute - '/welcome/user': typeof commonWelcomeUserIndexRoute - '/classrooms/$classroomId': typeof appLayoutClassroomsClassroomIdIndexRoute - '/classrooms/$classroomId/boards/$boardId': typeof appLayoutClassroomsClassroomIdBoardsBoardIdRoute - '/classrooms/$classroomId/notes/$noteId': typeof appLayoutClassroomsClassroomIdNotesNoteIdRoute - '/materials/$materialId/board': typeof appLayoutMaterialsMaterialIdBoardIndexRoute - '/materials/$materialId/note': typeof appLayoutMaterialsMaterialIdNoteIndexRoute + '/': typeof IndexRoute + '/about': typeof AboutRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/(app)/_layout': typeof appLayoutRouteWithChildren - '/(common)/_layout': typeof commonLayoutRouteWithChildren - '/(auth)/reset-password/$resetToken': typeof authResetPasswordResetTokenRoute - '/deployments/$deploymentId/enable': typeof DeploymentsDeploymentIdEnableRoute - '/(app)/_layout/': typeof appLayoutIndexRoute - '/(auth)/reset-password/': typeof authResetPasswordIndexRoute - '/(auth)/signin/': typeof authSigninIndexRoute - '/(auth)/signup/': typeof authSignupIndexRoute - '/(app)/_layout/board/$boardId': typeof appLayoutBoardBoardIdRoute - '/(app)/_layout/call/$callId': typeof appLayoutCallCallIdRoute - '/(common)/_layout/confirm-email/$emailId': typeof commonLayoutConfirmEmailEmailIdRoute - '/(common)/_layout/invite/$inviteId': typeof commonLayoutInviteInviteIdRoute - '/(common)/welcome/email/$emailId': typeof commonWelcomeEmailEmailIdRoute - '/(app)/_layout/calendar/': typeof appLayoutCalendarIndexRoute - '/(app)/_layout/call/': typeof appLayoutCallIndexRoute - '/(app)/_layout/classrooms/': typeof appLayoutClassroomsIndexRoute - '/(app)/_layout/materials/': typeof appLayoutMaterialsIndexRoute - '/(app)/_layout/payments/': typeof appLayoutPaymentsIndexRoute - '/(common)/welcome/role/': typeof commonWelcomeRoleIndexRoute - '/(common)/welcome/socials/': typeof commonWelcomeSocialsIndexRoute - '/(common)/welcome/user/': typeof commonWelcomeUserIndexRoute - '/(app)/_layout/classrooms/$classroomId/': typeof appLayoutClassroomsClassroomIdIndexRoute - '/(app)/_layout/classrooms/$classroomId/boards/$boardId': typeof appLayoutClassroomsClassroomIdBoardsBoardIdRoute - '/(app)/_layout/classrooms/$classroomId/notes/$noteId': typeof appLayoutClassroomsClassroomIdNotesNoteIdRoute - '/(app)/_layout/materials/$materialId/board/': typeof appLayoutMaterialsMaterialIdBoardIndexRoute - '/(app)/_layout/materials/$materialId/note/': typeof appLayoutMaterialsMaterialIdNoteIndexRoute + '/': typeof IndexRoute + '/about': typeof AboutRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/reset-password/$resetToken' - | '/deployments/$deploymentId/enable' - | '/' - | '/reset-password' - | '/signin' - | '/signup' - | '/board/$boardId' - | '/call/$callId' - | '/confirm-email/$emailId' - | '/invite/$inviteId' - | '/welcome/email/$emailId' - | '/calendar' - | '/call' - | '/classrooms' - | '/materials' - | '/payments' - | '/welcome/role' - | '/welcome/socials' - | '/welcome/user' - | '/classrooms/$classroomId' - | '/classrooms/$classroomId/boards/$boardId' - | '/classrooms/$classroomId/notes/$noteId' - | '/materials/$materialId/board' - | '/materials/$materialId/note' + fullPaths: '/' | '/about' fileRoutesByTo: FileRoutesByTo - to: - | '/reset-password/$resetToken' - | '/deployments/$deploymentId/enable' - | '/' - | '/reset-password' - | '/signin' - | '/signup' - | '/board/$boardId' - | '/call/$callId' - | '/confirm-email/$emailId' - | '/invite/$inviteId' - | '/welcome/email/$emailId' - | '/calendar' - | '/call' - | '/classrooms' - | '/materials' - | '/payments' - | '/welcome/role' - | '/welcome/socials' - | '/welcome/user' - | '/classrooms/$classroomId' - | '/classrooms/$classroomId/boards/$boardId' - | '/classrooms/$classroomId/notes/$noteId' - | '/materials/$materialId/board' - | '/materials/$materialId/note' - id: - | '__root__' - | '/(app)/_layout' - | '/(common)/_layout' - | '/(auth)/reset-password/$resetToken' - | '/deployments/$deploymentId/enable' - | '/(app)/_layout/' - | '/(auth)/reset-password/' - | '/(auth)/signin/' - | '/(auth)/signup/' - | '/(app)/_layout/board/$boardId' - | '/(app)/_layout/call/$callId' - | '/(common)/_layout/confirm-email/$emailId' - | '/(common)/_layout/invite/$inviteId' - | '/(common)/welcome/email/$emailId' - | '/(app)/_layout/calendar/' - | '/(app)/_layout/call/' - | '/(app)/_layout/classrooms/' - | '/(app)/_layout/materials/' - | '/(app)/_layout/payments/' - | '/(common)/welcome/role/' - | '/(common)/welcome/socials/' - | '/(common)/welcome/user/' - | '/(app)/_layout/classrooms/$classroomId/' - | '/(app)/_layout/classrooms/$classroomId/boards/$boardId' - | '/(app)/_layout/classrooms/$classroomId/notes/$noteId' - | '/(app)/_layout/materials/$materialId/board/' - | '/(app)/_layout/materials/$materialId/note/' + to: '/' | '/about' + id: '__root__' | '/' | '/about' fileRoutesById: FileRoutesById } export interface RootRouteChildren { - appLayoutRoute: typeof appLayoutRouteWithChildren - commonLayoutRoute: typeof commonLayoutRouteWithChildren - authResetPasswordResetTokenRoute: typeof authResetPasswordResetTokenRoute - DeploymentsDeploymentIdEnableRoute: typeof DeploymentsDeploymentIdEnableRoute - authResetPasswordIndexRoute: typeof authResetPasswordIndexRoute - authSigninIndexRoute: typeof authSigninIndexRoute - authSignupIndexRoute: typeof authSignupIndexRoute - commonWelcomeEmailEmailIdRoute: typeof commonWelcomeEmailEmailIdRoute - commonWelcomeRoleIndexRoute: typeof commonWelcomeRoleIndexRoute - commonWelcomeSocialsIndexRoute: typeof commonWelcomeSocialsIndexRoute - commonWelcomeUserIndexRoute: typeof commonWelcomeUserIndexRoute + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/(common)/_layout': { - id: '/(common)/_layout' - path: '' - fullPath: '' - preLoaderRoute: typeof commonLayoutRouteImport - parentRoute: typeof rootRouteImport - } - '/(app)/_layout': { - id: '/(app)/_layout' - path: '' - fullPath: '' - preLoaderRoute: typeof appLayoutRouteImport - parentRoute: typeof rootRouteImport - } - '/(auth)/signup/': { - id: '/(auth)/signup/' - path: '/signup' - fullPath: '/signup' - preLoaderRoute: typeof authSignupIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/(auth)/signin/': { - id: '/(auth)/signin/' - path: '/signin' - fullPath: '/signin' - preLoaderRoute: typeof authSigninIndexRouteImport + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport parentRoute: typeof rootRouteImport } - '/(auth)/reset-password/': { - id: '/(auth)/reset-password/' - path: '/reset-password' - fullPath: '/reset-password' - preLoaderRoute: typeof authResetPasswordIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/(app)/_layout/': { - id: '/(app)/_layout/' + '/': { + id: '/' path: '/' fullPath: '/' - preLoaderRoute: typeof appLayoutIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/deployments/$deploymentId/enable': { - id: '/deployments/$deploymentId/enable' - path: '/deployments/$deploymentId/enable' - fullPath: '/deployments/$deploymentId/enable' - preLoaderRoute: typeof DeploymentsDeploymentIdEnableRouteImport - parentRoute: typeof rootRouteImport - } - '/(auth)/reset-password/$resetToken': { - id: '/(auth)/reset-password/$resetToken' - path: '/reset-password/$resetToken' - fullPath: '/reset-password/$resetToken' - preLoaderRoute: typeof authResetPasswordResetTokenRouteImport - parentRoute: typeof rootRouteImport - } - '/(common)/welcome/user/': { - id: '/(common)/welcome/user/' - path: '/welcome/user' - fullPath: '/welcome/user' - preLoaderRoute: typeof commonWelcomeUserIndexRouteImport + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/(common)/welcome/socials/': { - id: '/(common)/welcome/socials/' - path: '/welcome/socials' - fullPath: '/welcome/socials' - preLoaderRoute: typeof commonWelcomeSocialsIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/(common)/welcome/role/': { - id: '/(common)/welcome/role/' - path: '/welcome/role' - fullPath: '/welcome/role' - preLoaderRoute: typeof commonWelcomeRoleIndexRouteImport - parentRoute: typeof rootRouteImport - } - '/(app)/_layout/payments/': { - id: '/(app)/_layout/payments/' - path: '/payments' - fullPath: '/payments' - preLoaderRoute: typeof appLayoutPaymentsIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/materials/': { - id: '/(app)/_layout/materials/' - path: '/materials' - fullPath: '/materials' - preLoaderRoute: typeof appLayoutMaterialsIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/classrooms/': { - id: '/(app)/_layout/classrooms/' - path: '/classrooms' - fullPath: '/classrooms' - preLoaderRoute: typeof appLayoutClassroomsIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/call/': { - id: '/(app)/_layout/call/' - path: '/call' - fullPath: '/call' - preLoaderRoute: typeof appLayoutCallIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/calendar/': { - id: '/(app)/_layout/calendar/' - path: '/calendar' - fullPath: '/calendar' - preLoaderRoute: typeof appLayoutCalendarIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(common)/welcome/email/$emailId': { - id: '/(common)/welcome/email/$emailId' - path: '/welcome/email/$emailId' - fullPath: '/welcome/email/$emailId' - preLoaderRoute: typeof commonWelcomeEmailEmailIdRouteImport - parentRoute: typeof rootRouteImport - } - '/(common)/_layout/invite/$inviteId': { - id: '/(common)/_layout/invite/$inviteId' - path: '/invite/$inviteId' - fullPath: '/invite/$inviteId' - preLoaderRoute: typeof commonLayoutInviteInviteIdRouteImport - parentRoute: typeof commonLayoutRoute - } - '/(common)/_layout/confirm-email/$emailId': { - id: '/(common)/_layout/confirm-email/$emailId' - path: '/confirm-email/$emailId' - fullPath: '/confirm-email/$emailId' - preLoaderRoute: typeof commonLayoutConfirmEmailEmailIdRouteImport - parentRoute: typeof commonLayoutRoute - } - '/(app)/_layout/call/$callId': { - id: '/(app)/_layout/call/$callId' - path: '/call/$callId' - fullPath: '/call/$callId' - preLoaderRoute: typeof appLayoutCallCallIdRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/board/$boardId': { - id: '/(app)/_layout/board/$boardId' - path: '/board/$boardId' - fullPath: '/board/$boardId' - preLoaderRoute: typeof appLayoutBoardBoardIdRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/classrooms/$classroomId/': { - id: '/(app)/_layout/classrooms/$classroomId/' - path: '/classrooms/$classroomId' - fullPath: '/classrooms/$classroomId' - preLoaderRoute: typeof appLayoutClassroomsClassroomIdIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/materials/$materialId/note/': { - id: '/(app)/_layout/materials/$materialId/note/' - path: '/materials/$materialId/note' - fullPath: '/materials/$materialId/note' - preLoaderRoute: typeof appLayoutMaterialsMaterialIdNoteIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/materials/$materialId/board/': { - id: '/(app)/_layout/materials/$materialId/board/' - path: '/materials/$materialId/board' - fullPath: '/materials/$materialId/board' - preLoaderRoute: typeof appLayoutMaterialsMaterialIdBoardIndexRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/classrooms/$classroomId/notes/$noteId': { - id: '/(app)/_layout/classrooms/$classroomId/notes/$noteId' - path: '/classrooms/$classroomId/notes/$noteId' - fullPath: '/classrooms/$classroomId/notes/$noteId' - preLoaderRoute: typeof appLayoutClassroomsClassroomIdNotesNoteIdRouteImport - parentRoute: typeof appLayoutRoute - } - '/(app)/_layout/classrooms/$classroomId/boards/$boardId': { - id: '/(app)/_layout/classrooms/$classroomId/boards/$boardId' - path: '/classrooms/$classroomId/boards/$boardId' - fullPath: '/classrooms/$classroomId/boards/$boardId' - preLoaderRoute: typeof appLayoutClassroomsClassroomIdBoardsBoardIdRouteImport - parentRoute: typeof appLayoutRoute - } } } -interface appLayoutRouteChildren { - appLayoutIndexRoute: typeof appLayoutIndexRoute - appLayoutBoardBoardIdRoute: typeof appLayoutBoardBoardIdRoute - appLayoutCallCallIdRoute: typeof appLayoutCallCallIdRoute - appLayoutCalendarIndexRoute: typeof appLayoutCalendarIndexRoute - appLayoutCallIndexRoute: typeof appLayoutCallIndexRoute - appLayoutClassroomsIndexRoute: typeof appLayoutClassroomsIndexRoute - appLayoutMaterialsIndexRoute: typeof appLayoutMaterialsIndexRoute - appLayoutPaymentsIndexRoute: typeof appLayoutPaymentsIndexRoute - appLayoutClassroomsClassroomIdIndexRoute: typeof appLayoutClassroomsClassroomIdIndexRoute - appLayoutClassroomsClassroomIdBoardsBoardIdRoute: typeof appLayoutClassroomsClassroomIdBoardsBoardIdRoute - appLayoutClassroomsClassroomIdNotesNoteIdRoute: typeof appLayoutClassroomsClassroomIdNotesNoteIdRoute - appLayoutMaterialsMaterialIdBoardIndexRoute: typeof appLayoutMaterialsMaterialIdBoardIndexRoute - appLayoutMaterialsMaterialIdNoteIndexRoute: typeof appLayoutMaterialsMaterialIdNoteIndexRoute -} - -const appLayoutRouteChildren: appLayoutRouteChildren = { - appLayoutIndexRoute: appLayoutIndexRoute, - appLayoutBoardBoardIdRoute: appLayoutBoardBoardIdRoute, - appLayoutCallCallIdRoute: appLayoutCallCallIdRoute, - appLayoutCalendarIndexRoute: appLayoutCalendarIndexRoute, - appLayoutCallIndexRoute: appLayoutCallIndexRoute, - appLayoutClassroomsIndexRoute: appLayoutClassroomsIndexRoute, - appLayoutMaterialsIndexRoute: appLayoutMaterialsIndexRoute, - appLayoutPaymentsIndexRoute: appLayoutPaymentsIndexRoute, - appLayoutClassroomsClassroomIdIndexRoute: - appLayoutClassroomsClassroomIdIndexRoute, - appLayoutClassroomsClassroomIdBoardsBoardIdRoute: - appLayoutClassroomsClassroomIdBoardsBoardIdRoute, - appLayoutClassroomsClassroomIdNotesNoteIdRoute: - appLayoutClassroomsClassroomIdNotesNoteIdRoute, - appLayoutMaterialsMaterialIdBoardIndexRoute: - appLayoutMaterialsMaterialIdBoardIndexRoute, - appLayoutMaterialsMaterialIdNoteIndexRoute: - appLayoutMaterialsMaterialIdNoteIndexRoute, -} - -const appLayoutRouteWithChildren = appLayoutRoute._addFileChildren( - appLayoutRouteChildren, -) - -interface commonLayoutRouteChildren { - commonLayoutConfirmEmailEmailIdRoute: typeof commonLayoutConfirmEmailEmailIdRoute - commonLayoutInviteInviteIdRoute: typeof commonLayoutInviteInviteIdRoute -} - -const commonLayoutRouteChildren: commonLayoutRouteChildren = { - commonLayoutConfirmEmailEmailIdRoute: commonLayoutConfirmEmailEmailIdRoute, - commonLayoutInviteInviteIdRoute: commonLayoutInviteInviteIdRoute, -} - -const commonLayoutRouteWithChildren = commonLayoutRoute._addFileChildren( - commonLayoutRouteChildren, -) - const rootRouteChildren: RootRouteChildren = { - appLayoutRoute: appLayoutRouteWithChildren, - commonLayoutRoute: commonLayoutRouteWithChildren, - authResetPasswordResetTokenRoute: authResetPasswordResetTokenRoute, - DeploymentsDeploymentIdEnableRoute: DeploymentsDeploymentIdEnableRoute, - authResetPasswordIndexRoute: authResetPasswordIndexRoute, - authSigninIndexRoute: authSigninIndexRoute, - authSignupIndexRoute: authSignupIndexRoute, - commonWelcomeEmailEmailIdRoute: commonWelcomeEmailEmailIdRoute, - commonWelcomeRoleIndexRoute: commonWelcomeRoleIndexRoute, - commonWelcomeSocialsIndexRoute: commonWelcomeSocialsIndexRoute, - commonWelcomeUserIndexRoute: commonWelcomeUserIndexRoute, + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/calls.hooks/README.md b/packages/calls.hooks/README.md new file mode 100644 index 0000000..4d2417e --- /dev/null +++ b/packages/calls.hooks/README.md @@ -0,0 +1 @@ +Основной компонент приложения diff --git a/packages/common.api/eslint.config.js b/packages/calls.hooks/eslint.config.js similarity index 100% rename from packages/common.api/eslint.config.js rename to packages/calls.hooks/eslint.config.js diff --git a/packages/calls.hooks/index.ts b/packages/calls.hooks/index.ts new file mode 100644 index 0000000..f904dd3 --- /dev/null +++ b/packages/calls.hooks/index.ts @@ -0,0 +1,23 @@ +export { + useAdaptiveGrid, + useCannotUseDevice, + useChat, + useEmptyItemContainerOfUser, + useHandFocus, + useInitUserDevices, + useLiveKitDataChannel, + useLiveKitDataChannelListener, + useModeSync, + useParticipantJoinSync, + usePersistentUserChoices, + useRaisedHands, + useResponsiveGrid, + useResolveInitiallyDefaultDeviceId, + useScreenShareCleanup, + useSize, + useSpeakingParticipant, + useStartCall, + useVideoBlur, + useVideoSecurity, + useWatchPermissions, +} from './src'; diff --git a/packages/common.theme/package.json b/packages/calls.hooks/package.json similarity index 81% rename from packages/common.theme/package.json rename to packages/calls.hooks/package.json index 7217f89..7e61a0e 100644 --- a/packages/common.theme/package.json +++ b/packages/calls.hooks/package.json @@ -1,5 +1,5 @@ { - "name": "common.theme", + "name": "calls.hooks", "version": "0.0.0", "type": "module", "exports": { @@ -11,9 +11,12 @@ "dev": "tsc --watch" }, "dependencies": { - "common.services": "*" + "zustand": "5.0.3", + "calls.store": "*", + "common.types": "*" }, "devDependencies": { + "@tanstack/react-query-devtools": "5.73.3", "@eslint/js": "^9.19.0", "common.typescript": "*", "@types/node": "^20.3.1", @@ -32,6 +35,6 @@ "peerDependencies": { "react": "19" }, - "description": "Theme context and provider", + "description": "calls.hooks", "author": "xi.effect" -} +} diff --git a/packages/calls.hooks/src/hooks/index.ts b/packages/calls.hooks/src/hooks/index.ts new file mode 100644 index 0000000..4a3f092 --- /dev/null +++ b/packages/calls.hooks/src/hooks/index.ts @@ -0,0 +1,19 @@ +export { useSize } from './useSize'; +export { useInitUserDevices } from './useInitUserDevices'; +export { useLiveKitDataChannel, useLiveKitDataChannelListener } from './useLiveKitDataChannel'; +export { useModeSync } from './useModeSync'; +export { useChat } from './useChat'; +export { useRaisedHands } from './useRaisedHands'; +export { useHandFocus } from './useHandFocus'; +export { usePersistentUserChoices } from './usePersistentUserChoices'; +export { useResolveInitiallyDefaultDeviceId } from './useResolveInitiallyDefaultDeviceId'; +export { useCannotUseDevice } from './useCannotUseDevice'; +export { useWatchPermissions } from './useWatchPermissions'; +export { useStartCall } from './useStartCall'; +export { useResponsiveGrid, useAdaptiveGrid } from './useResponsiveGrid'; +export { useSpeakingParticipant } from './useSpeakingParticipant'; +export { useVideoSecurity } from './useVideoSecurity'; +export { useScreenShareCleanup } from './useScreenShareCleanup'; +export { useVideoBlur } from './useVideoBlur'; +export { useEmptyItemContainerOfUser } from './useEmptyItemContainerOfUser'; +export { useParticipantJoinSync } from './useParticipantJoinSync'; diff --git a/packages/calls.hooks/src/hooks/useCannotUseDevice.ts b/packages/calls.hooks/src/hooks/useCannotUseDevice.ts new file mode 100644 index 0000000..fba2807 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useCannotUseDevice.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; +import { usePermissionsStore } from '../store/permissions'; + +export const useCannotUseDevice = (kind: MediaDeviceKind) => { + const { isLoading, isMicrophoneDenied, isMicrophonePrompted, isCameraDenied, isCameraPrompted } = + usePermissionsStore(); + + return useMemo(() => { + if (isLoading) return true; + + switch (kind) { + case 'audioinput': + case 'audiooutput': // audiooutput uses microphone permissions + return isMicrophoneDenied || isMicrophonePrompted; + case 'videoinput': + return isCameraDenied || isCameraPrompted; + + default: + return false; + } + }, [kind, isLoading, isMicrophoneDenied, isMicrophonePrompted, isCameraDenied, isCameraPrompted]); +}; diff --git a/packages/calls.hooks/src/hooks/useChat.ts b/packages/calls.hooks/src/hooks/useChat.ts new file mode 100644 index 0000000..6648b79 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useChat.ts @@ -0,0 +1,124 @@ +import { useCallback } from 'react'; +import { useLiveKitDataChannel, useLiveKitDataChannelListener } from './useLiveKitDataChannel'; +import { useCallStore } from '../store/callStore'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; + +const CHAT_MESSAGE_TYPE = 'chat_message'; + +type ChatMessagePayload = { + id: string; + text: string; + senderId: string; + senderName: string; + timestamp: number; +}; + +export const useChat = () => { + const { sendMessage } = useLiveKitDataChannel(); + const { addChatMessage, clearUnreadMessages, updateStore } = useCallStore(); + const { room } = useRoom(); + + // Получаем информацию о текущем участнике из LiveKit + const getCurrentParticipantInfo = useCallback(() => { + if (!room?.localParticipant) { + return { + senderId: 'unknown', + senderName: 'Unknown User', + }; + } + + const participant = room.localParticipant; + + try { + // Парсим метаданные участника + const metadata = participant.metadata; + if (metadata) { + const userInfo = JSON.parse(metadata); + return { + senderId: userInfo?.user_id || userInfo?.id || participant.identity, + senderName: + userInfo?.display_name || + userInfo?.name || + userInfo?.username || + participant.name || + participant.identity, + }; + } + } catch (error) { + console.warn('⚠️ Failed to parse participant metadata:', error); + } + + // Fallback на стандартные поля LiveKit + return { + senderId: participant.identity, + senderName: participant.name || participant.identity, + }; + }, [room]); + + const handleChatMessage = useCallback( + (message: { type: string; payload: unknown }) => { + if (message.type === CHAT_MESSAGE_TYPE) { + const payload = message.payload as ChatMessagePayload; + + // Проверяем, что это не наше собственное сообщение + const currentParticipantInfo = getCurrentParticipantInfo(); + if (payload.senderId === currentParticipantInfo.senderId) { + console.log('💬 Ignoring own message:', payload); + return; + } + + console.log('💬 Received chat message:', payload); + addChatMessage(payload); + } + }, + [addChatMessage, getCurrentParticipantInfo], + ); + + // Слушаем сообщения чата + useLiveKitDataChannelListener(handleChatMessage); + + const sendChatMessage = useCallback( + (text: string) => { + if (!text.trim()) return; + + const participantInfo = getCurrentParticipantInfo(); + const message: ChatMessagePayload = { + id: `${Date.now()}-${Math.random()}`, + text: text.trim(), + senderId: participantInfo.senderId, + senderName: participantInfo.senderName, + timestamp: Date.now(), + }; + + console.log('📤 Sending chat message:', message); + + // Добавляем сообщение в локальный store отправителя + addChatMessage(message); + + // Отправляем через DataChannel + sendMessage(CHAT_MESSAGE_TYPE, message); + }, + [sendMessage, getCurrentParticipantInfo, addChatMessage], + ); + + const toggleChat = useCallback(() => { + updateStore('isChatOpen', !useCallStore.getState().isChatOpen); + clearUnreadMessages(); + }, [updateStore, clearUnreadMessages]); + + const openChat = useCallback(() => { + updateStore('isChatOpen', true); + clearUnreadMessages(); + }, [updateStore, clearUnreadMessages]); + + const closeChat = useCallback(() => { + updateStore('isChatOpen', false); + }, [updateStore]); + + return { + sendChatMessage, + toggleChat, + openChat, + closeChat, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useCompactNavigation.ts b/packages/calls.hooks/src/hooks/useCompactNavigation.ts new file mode 100644 index 0000000..cd040fd --- /dev/null +++ b/packages/calls.hooks/src/hooks/useCompactNavigation.ts @@ -0,0 +1,68 @@ +import { useState, useEffect } from 'react'; +import { Track } from 'livekit-client'; +import { useTracks } from '@livekit/components-react'; +import { useScreenShareCleanup } from './useScreenShareCleanup'; + +export const useCompactNavigation = () => { + const [currentParticipantIndex, setCurrentParticipantIndex] = useState(0); + + // Получаем треки через useTracks (как в VideoGrid) для автоматического обновления + const participants = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { + onlySubscribed: false, // Получаем все треки, включая неподписанные для корректного подсчета участников + }, + ); + + // Автоматическое удаление треков демонстрации экрана при их завершении + useScreenShareCleanup(participants); + + const currentParticipant = participants[currentParticipantIndex] || null; + const totalParticipants = participants.length; + + const canGoNext = currentParticipantIndex < totalParticipants - 1; + const canGoPrev = currentParticipantIndex > 0; + + const goToNext = () => { + if (canGoNext) { + setCurrentParticipantIndex((prev) => prev + 1); + } + }; + + const goToPrev = () => { + if (canGoPrev) { + setCurrentParticipantIndex((prev) => prev - 1); + } + }; + + const goToParticipant = (index: number) => { + if (index >= 0 && index < totalParticipants) { + setCurrentParticipantIndex(index); + } + }; + + // Сброс индекса при изменении количества участников + useEffect(() => { + if (currentParticipantIndex >= totalParticipants && totalParticipants > 0) { + setCurrentParticipantIndex(Math.max(0, totalParticipants - 1)); + } + }, [totalParticipants, currentParticipantIndex]); + + // useTracks автоматически обновляется при изменениях треков, + // поэтому дополнительные обработчики событий не нужны + + return { + currentParticipant, + participants, + currentIndex: currentParticipantIndex, + totalParticipants, + canGoNext, + canGoPrev, + goToNext, + goToPrev, + goToParticipant, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useEmptyItemContainerOfUser.ts b/packages/calls.hooks/src/hooks/useEmptyItemContainerOfUser.ts new file mode 100644 index 0000000..e132d4a --- /dev/null +++ b/packages/calls.hooks/src/hooks/useEmptyItemContainerOfUser.ts @@ -0,0 +1,28 @@ +import { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { useEffect, useState } from 'react'; + +export const useEmptyItemContainerOfUser = ( + tracksLength: number, + tracks: TrackReferenceOrPlaceholder[], +) => { + const [isOneItem, setIsOneItem] = useState(false); + + useEffect(() => { + // Подсчитываем уникальных участников (не треков) + const uniqueParticipants = new Set( + tracks + .filter((track) => track.participant && track.participant.identity) + .map((track) => track.participant.identity), + ); + + // Показываем placeholder если только один участник + // Дополнительная проверка: если tracks пустой, но tracksLength > 0, + // это может означать, что участники еще не загрузились + const shouldShowPlaceholder = + uniqueParticipants.size === 1 || (tracks.length === 0 && tracksLength > 0); + + setIsOneItem(shouldShowPlaceholder); + }, [tracksLength, tracks]); + + return isOneItem; +}; diff --git a/packages/calls.hooks/src/hooks/useHandFocus.ts b/packages/calls.hooks/src/hooks/useHandFocus.ts new file mode 100644 index 0000000..d666d53 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useHandFocus.ts @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import { Track } from 'livekit-client'; +import { useCallStore } from '../store/callStore'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; +import { useMaybeLayoutContext } from '@livekit/components-react'; + +export const useHandFocus = () => { + const { raisedHands } = useCallStore(); + const { room } = useRoom(); + const layoutContext = useMaybeLayoutContext(); + + useEffect(() => { + if (!room || raisedHands.length === 0 || !layoutContext?.pin.dispatch) return; + + // Находим участника с самой ранней поднятой рукой + const earliestHand = raisedHands.reduce((earliest, current) => + current.timestamp < earliest.timestamp ? current : earliest, + ); + + // Ищем участника в комнате + const participant = room.getParticipantByIdentity(earliestHand.participantId); + + if (participant) { + console.log( + '🎯 Auto-focusing on participant with raised hand:', + earliestHand.participantName, + ); + + // Находим трек камеры участника для фокуса + const cameraTrack = Array.from(participant.videoTrackPublications.values()).find( + (track) => track.source === 'camera', + ); + + if (cameraTrack) { + // Устанавливаем фокус на участника с поднятой рукой + layoutContext.pin.dispatch({ + msg: 'set_pin', + trackReference: { + participant, + source: Track.Source.Camera, + publication: cameraTrack, + }, + }); + } + } else { + console.log('⚠️ Participant not found:', earliestHand.participantId); + } + }, [raisedHands, room, layoutContext]); +}; diff --git a/packages/calls.hooks/src/hooks/useInitUserDevices.ts b/packages/calls.hooks/src/hooks/useInitUserDevices.ts new file mode 100644 index 0000000..256db11 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useInitUserDevices.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useCallStore } from '../store/callStore'; +import { useMediaDevices } from '@livekit/components-react'; + +export const useInitUserDevices = () => { + const audioDeviceId = useCallStore((state) => state.audioDeviceId); + const videoDeviceId = useCallStore((state) => state.videoDeviceId); + + const videoDevices = useMediaDevices({ kind: 'videoinput' }); + const audioDevices = useMediaDevices({ kind: 'audioinput' }); + + const updateStore = useCallStore((state) => state.updateStore); + + useEffect(() => { + if (!audioDeviceId && audioDevices?.length > 0) { + console.log('Setting initial audio device:', audioDevices[0].deviceId); + updateStore('audioDeviceId', audioDevices[0].deviceId); + } + }, [audioDeviceId, audioDevices, updateStore]); + + useEffect(() => { + if (!videoDeviceId && videoDevices?.length > 0) { + console.log('Setting initial video device:', videoDevices[0].deviceId); + updateStore('videoDeviceId', videoDevices[0].deviceId); + } + }, [videoDeviceId, videoDevices, updateStore]); +}; diff --git a/packages/calls.hooks/src/hooks/useLiveKitDataChannel.ts b/packages/calls.hooks/src/hooks/useLiveKitDataChannel.ts new file mode 100644 index 0000000..1495788 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useLiveKitDataChannel.ts @@ -0,0 +1,195 @@ +import { useEffect, useCallback } from 'react'; +import { RoomEvent, RemoteParticipant } from 'livekit-client'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; + +type DataMessage = { + type: string; + payload: unknown; + timestamp: number; +}; + +type UseLiveKitDataChannelReturn = { + sendMessage: (type: string, payload: unknown) => void; + sendMessageToParticipant: (participantId: string, type: string, payload: unknown) => void; +}; + +export const useLiveKitDataChannel = (): UseLiveKitDataChannelReturn => { + const { room } = useRoom(); + + const sendMessage = useCallback( + (type: string, payload: unknown) => { + if (!room) { + console.warn('⚠️ Room is not available for sending data message'); + return; + } + + // Проверяем, что комната подключена + if (room.state !== 'connected') { + console.warn( + '⚠️ Room is not connected, cannot send data message. Current state:', + room.state, + ); + return; + } + + // Проверяем, что localParticipant существует + if (!room.localParticipant) { + console.warn('⚠️ Local participant is not available'); + return; + } + + const message: DataMessage = { + type, + payload, + timestamp: Date.now(), + }; + + try { + // Валидируем данные перед отправкой + const messageString = JSON.stringify(message); + if (messageString.length > 16384) { + // LiveKit ограничение на размер сообщения + console.error('❌ Data message too large:', messageString.length, 'bytes'); + return; + } + + console.log('📤 Sending data message:', message); + room.localParticipant.publishData(new TextEncoder().encode(messageString), { + reliable: true, + }); + console.log('✅ Data message sent successfully'); + } catch (error) { + console.error('❌ Failed to send data message:', error); + // Не выбрасываем ошибку, чтобы не нарушить соединение + } + }, + [room], + ); + + const sendMessageToParticipant = useCallback( + (participantId: string, type: string, payload: unknown) => { + if (!room) { + console.warn('⚠️ Room is not available for sending data message'); + return; + } + + // Проверяем, что комната подключена + if (room.state !== 'connected') { + console.warn( + '⚠️ Room is not connected, cannot send data message. Current state:', + room.state, + ); + return; + } + + // Проверяем, что localParticipant существует + if (!room.localParticipant) { + console.warn('⚠️ Local participant is not available'); + return; + } + + // Валидируем participantId + if (!participantId || typeof participantId !== 'string') { + console.error('❌ Invalid participant ID:', participantId); + return; + } + + const message: DataMessage = { + type, + payload, + timestamp: Date.now(), + }; + + try { + // Валидируем данные перед отправкой + const messageString = JSON.stringify(message); + if (messageString.length > 16384) { + // LiveKit ограничение на размер сообщения + console.error('❌ Data message too large:', messageString.length, 'bytes'); + return; + } + + console.log('📤 Sending data message to participant:', participantId, message); + room.localParticipant.publishData(new TextEncoder().encode(messageString), { + reliable: true, + destinationIdentities: [participantId], + }); + console.log('✅ Data message sent to participant successfully'); + } catch (error) { + console.error('❌ Failed to send data message to participant:', error); + // Не выбрасываем ошибку, чтобы не нарушить соединение + } + }, + [room], + ); + + return { + sendMessage, + sendMessageToParticipant, + }; +}; + +export const useLiveKitDataChannelListener = ( + onMessage: (message: DataMessage, participant?: RemoteParticipant) => void, +) => { + const { room } = useRoom(); + + useEffect(() => { + if (!room) return; + + const handleDataReceived = (payload: Uint8Array, participant?: RemoteParticipant) => { + try { + // Проверяем размер payload + if (payload.length === 0) { + console.warn('⚠️ Received empty data message'); + return; + } + + if (payload.length > 16384) { + console.error('❌ Received data message too large:', payload.length, 'bytes'); + return; + } + + // Декодируем данные + const messageString = new TextDecoder().decode(payload); + + // Валидируем JSON + let message: DataMessage; + try { + message = JSON.parse(messageString); + } catch (parseError) { + console.error('❌ Failed to parse JSON from data message:', parseError); + return; + } + + // Валидируем структуру сообщения + if (!message || typeof message !== 'object') { + console.error('❌ Invalid message structure:', message); + return; + } + + if (!message.type || typeof message.type !== 'string') { + console.error('❌ Invalid message type:', message.type); + return; + } + + if (typeof message.timestamp !== 'number') { + console.error('❌ Invalid message timestamp:', message.timestamp); + return; + } + + console.log('📥 Data message received:', message, 'from:', participant?.identity); + onMessage(message, participant); + } catch (error) { + console.error('❌ Failed to process data message:', error); + // Не выбрасываем ошибку, чтобы не нарушить соединение + } + }; + + room.on(RoomEvent.DataReceived, handleDataReceived); + + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived); + }; + }, [room, onMessage]); +}; diff --git a/packages/calls.hooks/src/hooks/useModeSync.ts b/packages/calls.hooks/src/hooks/useModeSync.ts new file mode 100644 index 0000000..2898270 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useModeSync.ts @@ -0,0 +1,145 @@ +import { useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useCallStore } from '../store/callStore'; +import { useLiveKitDataChannel, useLiveKitDataChannelListener } from './useLiveKitDataChannel'; + +const MODE_SYNC_MESSAGE_TYPE = 'mode_sync'; + +type ModeSyncPayload = { + mode: 'compact' | 'full'; + boardId?: string; + classroom?: string; +}; + +export const useModeSync = () => { + const navigate = useNavigate(); + const updateStore = useCallStore((state) => state.updateStore); + const { sendMessage } = useLiveKitDataChannel(); + + const handleModeSyncMessage = useCallback( + (message: { type: string; payload: unknown }) => { + try { + if (message.type === MODE_SYNC_MESSAGE_TYPE) { + const payload = message.payload as ModeSyncPayload; + + // Валидируем payload + if (!payload || typeof payload !== 'object') { + console.error('❌ Invalid mode sync payload:', payload); + return; + } + + if (!payload.mode || !['compact', 'full'].includes(payload.mode)) { + console.error('❌ Invalid mode value:', payload.mode); + return; + } + + // Получаем текущее состояние доски + const currentActiveBoardId = useCallStore.getState().activeBoardId; + + // Если пользователь находится на доске (есть activeBoardId), + // проверяем, есть ли boardId в сообщении + // Если boardId отсутствует в сообщении о full mode - это означает, + // что репетитор хочет переключить всех на full (завершить работу с доской) + if (payload.mode === 'full' && currentActiveBoardId && payload.boardId) { + // Пользователь на доске, но в сообщении есть boardId - игнорируем + // (это сообщение от другого участника, который переключился сам) + return; + } + + // Обновляем режим в store + updateStore('mode', payload.mode); + + // Сохраняем информацию о доске в store + if (payload.mode === 'compact' && payload.boardId) { + updateStore('activeBoardId', payload.boardId); + updateStore('activeClassroom', payload.classroom); + } else if (payload.mode === 'full' && !payload.boardId) { + // Если получаем full mode без boardId - это означает завершение работы с доской для всех + // Сохраняем activeClassroom перед очисткой для навигации + const currentActiveClassroom = useCallStore.getState().activeClassroom; + const classroomId = payload.classroom || currentActiveClassroom; + + // Очищаем информацию о доске + updateStore('activeBoardId', undefined); + updateStore('activeClassroom', undefined); + + // Переходим на страницу конференции, если есть classroomId + if (classroomId) { + navigate({ + to: '/call/$callId', + params: { callId: classroomId }, + }); + } + } + // Если получаем full mode с boardId (не должно происходить) или compact mode без boardId, + // не изменяем activeBoardId и activeClassroom + + // Если есть boardId, переходим на доску + if (payload.boardId && typeof payload.boardId === 'string') { + if (payload.classroom) { + navigate({ + to: '/classrooms/$classroomId/boards/$boardId', + params: { classroomId: payload.classroom, boardId: payload.boardId }, + search: { call: payload.classroom }, + }); + } else { + navigate({ + to: '/board/$boardId', + params: { boardId: payload.boardId }, + search: { call: payload.classroom }, + }); + } + } + } + } catch (error) { + console.error('❌ Error handling mode sync message:', error); + // Не выбрасываем ошибку, чтобы не нарушить соединение + } + }, + [updateStore, navigate], + ); + + // Слушаем сообщения о синхронизации режима + useLiveKitDataChannelListener(handleModeSyncMessage); + + const syncModeToOthers = useCallback( + (mode: 'compact' | 'full', boardId?: string, classroom?: string) => { + try { + // Валидируем входные параметры + if (!mode || !['compact', 'full'].includes(mode)) { + console.error('❌ Invalid mode for sync:', mode); + return; + } + + if (boardId && typeof boardId !== 'string') { + console.error('❌ Invalid boardId for sync:', boardId); + return; + } + + const payload: ModeSyncPayload = { + mode, + boardId, + classroom, + }; + + // Сохраняем информацию о доске в store при отправке сообщения + if (mode === 'compact' && boardId) { + updateStore('activeBoardId', boardId); + updateStore('activeClassroom', classroom); + } + // Не очищаем activeBoardId и activeClassroom при переключении на full mode, + // чтобы пользователь мог вернуться на доску + + sendMessage(MODE_SYNC_MESSAGE_TYPE, payload); + } catch (error) { + console.error('❌ Error sending mode sync message:', error); + // Не выбрасываем ошибку, чтобы не нарушить соединение + } + }, + [sendMessage, updateStore], + ); + + return { + syncModeToOthers, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useParticipantJoinSync.ts b/packages/calls.hooks/src/hooks/useParticipantJoinSync.ts new file mode 100644 index 0000000..75c0ceb --- /dev/null +++ b/packages/calls.hooks/src/hooks/useParticipantJoinSync.ts @@ -0,0 +1,335 @@ +import { useEffect, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { RoomEvent, RemoteParticipant } from 'livekit-client'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; +import { useCallStore } from '../store/callStore'; +import { useLiveKitDataChannel, useLiveKitDataChannelListener } from './useLiveKitDataChannel'; +import { useCurrentUser } from 'common.services'; + +const STATE_REQUEST_MESSAGE_TYPE = 'state_request'; +const STATE_RESPONSE_MESSAGE_TYPE = 'state_response'; + +type StateRequestPayload = { + participantId: string; +}; + +type StateResponsePayload = { + mode: 'compact' | 'full'; + boardId?: string; + classroom?: string; +}; + +/** + * Хук для синхронизации состояния при подключении новых участников + * - Репетитор отправляет текущее состояние новым участникам, если он в compact mode на доске + * - Новые участники могут запросить текущее состояние у репетитора + */ +export const useParticipantJoinSync = () => { + const { room } = useRoom(); + const navigate = useNavigate(); + const { sendMessageToParticipant } = useLiveKitDataChannel(); + const { data: user } = useCurrentUser(); + const isTutor = user?.default_layout === 'tutor'; + const mode = useCallStore((state) => state.mode); + const activeBoardId = useCallStore((state) => state.activeBoardId); + const activeClassroom = useCallStore((state) => state.activeClassroom); + const updateStore = useCallStore((state) => state.updateStore); + + // Обработка запроса состояния от нового участника + const handleStateRequest = useCallback( + (message: { type: string; payload: unknown }) => { + try { + if (message.type === STATE_REQUEST_MESSAGE_TYPE && isTutor) { + const payload = message.payload as StateRequestPayload; + + // Валидируем payload + if (!payload || typeof payload !== 'object' || !payload.participantId) { + console.error('❌ Invalid state request payload:', payload); + return; + } + + // Если репетитор в compact mode на доске, отправляем состояние новому участнику + if (mode === 'compact' && activeBoardId) { + const responsePayload: StateResponsePayload = { + mode: 'compact', + boardId: activeBoardId, + classroom: activeClassroom, + }; + + sendMessageToParticipant( + payload.participantId, + STATE_RESPONSE_MESSAGE_TYPE, + responsePayload, + ); + } + } + } catch (error) { + console.error('❌ Error handling state request:', error); + } + }, + [isTutor, mode, activeBoardId, activeClassroom, sendMessageToParticipant], + ); + + // Обработка ответа на запрос состояния (для новых участников) + const handleStateResponse = useCallback( + (message: { type: string; payload: unknown }) => { + try { + if (message.type === STATE_RESPONSE_MESSAGE_TYPE && !isTutor) { + const payload = message.payload as StateResponsePayload; + + // Валидируем payload + if (!payload || typeof payload !== 'object') { + console.error('❌ Student: Invalid state response payload:', payload); + return; + } + + if (!payload.mode || !['compact', 'full'].includes(payload.mode)) { + console.error('❌ Student: Invalid mode value in state response:', payload.mode); + return; + } + + // Получаем текущий режим и состояние доски + const currentMode = useCallStore.getState().mode; + const currentActiveBoardId = useCallStore.getState().activeBoardId; + const isOnBoardPage = window.location.pathname.includes('/board'); + + // Если студент уже в full mode и НЕ на доске, проверяем, был ли он на доске ранее + // Если был activeBoardId (студент был на доске и переключился на full), + // то НЕ переключаем его обратно на доску + // Если НЕТ activeBoardId (студент только что подключился), то переключаемся на доску + const wasOnBoard = currentActiveBoardId !== undefined; + + if (currentMode === 'full' && !isOnBoardPage && wasOnBoard) { + // Студент сам переключился на full mode с доски - игнорируем ответ о compact mode + return; + } + + // Если репетитор в compact mode на доске, переключаемся на эту доску + if (payload.mode === 'compact' && payload.boardId) { + // Обновляем режим в store + updateStore('mode', 'compact'); + updateStore('activeBoardId', payload.boardId); + updateStore('activeClassroom', payload.classroom); + + // Переходим на доску с обязательным параметром call для сохранения ВКС + const classroomId = payload.classroom || activeClassroom; + + if (payload.classroom) { + navigate({ + to: '/classrooms/$classroomId/boards/$boardId', + params: { classroomId: payload.classroom, boardId: payload.boardId }, + search: { call: payload.classroom }, + replace: false, + }); + } else if (payload.boardId && classroomId) { + navigate({ + to: '/board/$boardId', + params: { boardId: payload.boardId }, + search: { call: classroomId }, + replace: false, + }); + } else if (payload.boardId) { + navigate({ + to: '/board/$boardId', + params: { boardId: payload.boardId }, + replace: false, + }); + } + } + } + } catch (error) { + console.error('❌ Student: Error handling state response:', error); + } + }, + [isTutor, updateStore, navigate, activeClassroom], + ); + + // Объединенный обработчик для всех сообщений состояния + const handleDataMessage = useCallback( + (message: { type: string; payload: unknown }) => { + if (message.type === STATE_REQUEST_MESSAGE_TYPE) { + handleStateRequest(message); + } else if (message.type === STATE_RESPONSE_MESSAGE_TYPE) { + handleStateResponse(message); + } + }, + [handleStateRequest, handleStateResponse], + ); + + // Слушаем сообщения о запросе и ответе состояния + useLiveKitDataChannelListener(handleDataMessage); + + // Отслеживание подключения новых участников (для репетитора) + useEffect(() => { + if (!room || !isTutor) { + return; + } + + const handleParticipantConnected = (participant: RemoteParticipant) => { + // Пропускаем локального участника + if (participant.identity === room.localParticipant?.identity) { + return; + } + + // Если репетитор в compact mode на доске, отправляем состояние новому участнику + if (mode === 'compact' && activeBoardId) { + // Небольшая задержка, чтобы убедиться, что участник полностью подключен + setTimeout(() => { + const responsePayload: StateResponsePayload = { + mode: 'compact', + boardId: activeBoardId, + classroom: activeClassroom, + }; + + sendMessageToParticipant( + participant.identity, + STATE_RESPONSE_MESSAGE_TYPE, + responsePayload, + ); + }, 1500); + } + }; + + room.on(RoomEvent.ParticipantConnected, handleParticipantConnected); + + // Функция для отправки состояния всем подключенным участникам + const sendStateToAllParticipants = () => { + if (room.state !== 'connected' || mode !== 'compact' || !activeBoardId) { + return; + } + + const existingParticipants = Array.from(room.remoteParticipants.values()); + + existingParticipants.forEach((participant) => { + if (participant.identity !== room.localParticipant?.identity) { + const responsePayload: StateResponsePayload = { + mode: 'compact', + boardId: activeBoardId, + classroom: activeClassroom, + }; + + sendMessageToParticipant( + participant.identity, + STATE_RESPONSE_MESSAGE_TYPE, + responsePayload, + ); + } + }); + }; + + // Проверяем уже подключенных участников при монтировании хука + if (room.state === 'connected') { + const existingParticipants = Array.from(room.remoteParticipants.values()); + existingParticipants.forEach((participant) => { + if (participant.identity !== room.localParticipant?.identity) { + handleParticipantConnected(participant); + } + }); + } + + // Также отправляем состояние при изменении mode или activeBoardId + // (на случай, если репетитор переключился на доску после подключения студентов) + if (room.state === 'connected' && mode === 'compact' && activeBoardId) { + // Небольшая задержка для стабильности + const timeoutId = setTimeout(() => { + sendStateToAllParticipants(); + }, 500); + + return () => { + room.off(RoomEvent.ParticipantConnected, handleParticipantConnected); + clearTimeout(timeoutId); + }; + } + + return () => { + room.off(RoomEvent.ParticipantConnected, handleParticipantConnected); + }; + }, [room, isTutor, mode, activeBoardId, activeClassroom, sendMessageToParticipant]); + + // Запрос состояния при подключении (для новых участников, не репетиторов) + useEffect(() => { + if (!room || isTutor) return; + + const requestState = () => { + if (room.state !== 'connected') { + return; + } + + // Всегда запрашиваем состояние при подключении + // Защита от автоматического переключения обратно на доску реализована в handleStateResponse + const localParticipant = room.localParticipant; + if (!localParticipant) { + return; + } + + // Находим репетитора среди участников + const participants = Array.from(room.remoteParticipants.values()); + + if (participants.length === 0) { + return; + } + + const tutor = participants.find((p) => { + try { + if (!p.metadata || p.metadata.trim() === '') { + return false; + } + const metadata = JSON.parse(p.metadata); + return metadata?.default_layout === 'tutor'; + } catch { + return false; + } + }); + + // Если репетитор не найден по метаданным, отправляем запрос всем участникам + if (!tutor && participants.length > 0) { + const requestPayload: StateRequestPayload = { + participantId: localParticipant.identity, + }; + + participants.forEach((participant) => { + sendMessageToParticipant( + participant.identity, + STATE_REQUEST_MESSAGE_TYPE, + requestPayload, + ); + }); + return; + } + + if (tutor) { + const requestPayload: StateRequestPayload = { + participantId: localParticipant.identity, + }; + + sendMessageToParticipant(tutor.identity, STATE_REQUEST_MESSAGE_TYPE, requestPayload); + } + }; + + // Если комната уже подключена, запрашиваем сразу + if (room.state === 'connected') { + const timeoutId = setTimeout(() => { + requestState(); + }, 2000); // Задержка 2 секунды после подключения + + return () => { + clearTimeout(timeoutId); + }; + } + + // Подписываемся на событие изменения состояния подключения + const handleConnectionStateChanged = (state: string) => { + if (state === 'connected') { + setTimeout(() => { + requestState(); + }, 2000); + } + }; + + room.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged); + + return () => { + room.off(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged); + }; + }, [room, isTutor, sendMessageToParticipant, mode]); +}; diff --git a/packages/calls.hooks/src/hooks/usePersistentUserChoices.ts b/packages/calls.hooks/src/hooks/usePersistentUserChoices.ts new file mode 100644 index 0000000..3c4d30f --- /dev/null +++ b/packages/calls.hooks/src/hooks/usePersistentUserChoices.ts @@ -0,0 +1,41 @@ +import { useUserChoicesStore } from '../store/userChoices'; +import type { VideoResolution } from '../store/userChoices'; +import type { VideoQuality } from 'livekit-client'; + +export function usePersistentUserChoices() { + const userChoices = useUserChoicesStore(); + + return { + userChoices, + saveAudioInputEnabled: (isEnabled: boolean) => { + useUserChoicesStore.setState({ audioEnabled: isEnabled }); + }, + saveVideoInputEnabled: (isEnabled: boolean) => { + useUserChoicesStore.setState({ videoEnabled: isEnabled }); + }, + saveAudioInputDeviceId: (deviceId: string) => { + useUserChoicesStore.setState({ audioDeviceId: deviceId }); + }, + saveAudioOutputDeviceId: (deviceId: string) => { + useUserChoicesStore.setState({ audioOutputDeviceId: deviceId }); + }, + saveVideoInputDeviceId: (deviceId: string) => { + useUserChoicesStore.setState({ videoDeviceId: deviceId }); + }, + saveVideoPublishResolution: (resolution: VideoResolution) => { + useUserChoicesStore.setState({ videoPublishResolution: resolution }); + }, + saveVideoSubscribeQuality: (quality: VideoQuality) => { + useUserChoicesStore.setState({ videoSubscribeQuality: quality }); + }, + saveUsername: (username: string) => { + useUserChoicesStore.setState({ username }); + }, + saveNoiseReductionEnabled: (enabled: boolean) => { + useUserChoicesStore.setState({ noiseReductionEnabled: enabled }); + }, + saveBlurEnabled: (enabled: boolean) => { + useUserChoicesStore.setState({ blurEnabled: enabled }); + }, + }; +} diff --git a/packages/calls.hooks/src/hooks/useRaisedHands.ts b/packages/calls.hooks/src/hooks/useRaisedHands.ts new file mode 100644 index 0000000..ba149e6 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useRaisedHands.ts @@ -0,0 +1,130 @@ +import { useCallback } from 'react'; +import { useLiveKitDataChannel, useLiveKitDataChannelListener } from './useLiveKitDataChannel'; +import { useCallStore } from '../store/callStore'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; + +const RAISE_HAND_MESSAGE_TYPE = 'raise_hand'; +const LOWER_HAND_MESSAGE_TYPE = 'lower_hand'; + +type HandMessagePayload = { + participantId: string; + participantName: string; + timestamp: number; +}; + +export const useRaisedHands = () => { + const { sendMessage } = useLiveKitDataChannel(); + const { addRaisedHand, removeRaisedHand, toggleHandRaised } = useCallStore(); + const { room } = useRoom(); + + // Получаем информацию о текущем участнике из LiveKit + const getCurrentParticipantInfo = useCallback(() => { + if (!room?.localParticipant) { + return { + participantId: 'unknown', + participantName: 'Unknown User', + }; + } + + const participant = room.localParticipant; + + try { + // Парсим метаданные участника + const metadata = participant.metadata; + if (metadata) { + const userInfo = JSON.parse(metadata); + return { + participantId: userInfo?.user_id || userInfo?.id || participant.identity, + participantName: + userInfo?.display_name || + userInfo?.name || + userInfo?.username || + participant.name || + participant.identity, + }; + } + } catch (error) { + console.warn('⚠️ Failed to parse participant metadata:', error); + } + + // Fallback на стандартные поля LiveKit + return { + participantId: participant.identity, + participantName: participant.name || participant.identity, + }; + }, [room]); + + const handleHandMessage = useCallback( + (message: { type: string; payload: unknown }) => { + try { + if (message.type === RAISE_HAND_MESSAGE_TYPE) { + const payload = message.payload as HandMessagePayload; + + // Проверяем, не от текущего пользователя ли сообщение + const currentParticipantInfo = getCurrentParticipantInfo(); + if (payload.participantId !== currentParticipantInfo.participantId) { + addRaisedHand(payload); + } + } else if (message.type === LOWER_HAND_MESSAGE_TYPE) { + const payload = message.payload as HandMessagePayload; + console.log('🤚 Received lower hand message:', payload); + + // Проверяем, не от текущего пользователя ли сообщение + const currentParticipantInfo = getCurrentParticipantInfo(); + if (payload.participantId !== currentParticipantInfo.participantId) { + removeRaisedHand(payload.participantId); + } + } + } catch (error) { + console.error('❌ Error handling hand message:', error); + } + }, + [addRaisedHand, removeRaisedHand, getCurrentParticipantInfo], + ); + + // Слушаем сообщения о поднятых руках + useLiveKitDataChannelListener(handleHandMessage); + + const raiseHand = useCallback(() => { + const participantInfo = getCurrentParticipantInfo(); + const message: HandMessagePayload = { + participantId: participantInfo.participantId, + participantName: participantInfo.participantName, + timestamp: Date.now(), + }; + + sendMessage(RAISE_HAND_MESSAGE_TYPE, message); + // Добавляем руку в локальный store для текущего пользователя + addRaisedHand(message); + toggleHandRaised(); + }, [sendMessage, getCurrentParticipantInfo, addRaisedHand, toggleHandRaised]); + + const lowerHand = useCallback(() => { + const participantInfo = getCurrentParticipantInfo(); + const message: HandMessagePayload = { + participantId: participantInfo.participantId, + participantName: participantInfo.participantName, + timestamp: Date.now(), + }; + + sendMessage(LOWER_HAND_MESSAGE_TYPE, message); + // Удаляем руку из локального store для текущего пользователя + removeRaisedHand(participantInfo.participantId); + toggleHandRaised(); + }, [sendMessage, getCurrentParticipantInfo, removeRaisedHand, toggleHandRaised]); + + const toggleHand = useCallback(() => { + const { isHandRaised } = useCallStore.getState(); + if (isHandRaised) { + lowerHand(); + } else { + raiseHand(); + } + }, [raiseHand, lowerHand]); + + return { + raiseHand, + lowerHand, + toggleHand, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useResolveInitiallyDefaultDeviceId.ts b/packages/calls.hooks/src/hooks/useResolveInitiallyDefaultDeviceId.ts new file mode 100644 index 0000000..5a3c459 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useResolveInitiallyDefaultDeviceId.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react'; + +export const useResolveInitiallyDefaultDeviceId = < + T extends { getDeviceId(): Promise }, +>( + currentId: string, + track: T | undefined, + save: (id: string) => void, +) => { + const isInitiated = useRef(false); + useEffect(() => { + if (currentId !== 'default' || !track || isInitiated.current) return; + const resolveDefaultDeviceId = async () => { + const actualDeviceId = await track.getDeviceId(); + if (actualDeviceId && actualDeviceId !== 'default') { + isInitiated.current = true; + save(actualDeviceId); + } + }; + resolveDefaultDeviceId(); + }, [currentId, track, save]); +}; diff --git a/packages/calls.hooks/src/hooks/useResponsiveGrid.ts b/packages/calls.hooks/src/hooks/useResponsiveGrid.ts new file mode 100644 index 0000000..e6349ce --- /dev/null +++ b/packages/calls.hooks/src/hooks/useResponsiveGrid.ts @@ -0,0 +1,156 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + GRID_CONFIG, + type GridBreakpoint, + getGridLayoutsForScreen, + getOptimalGridLayout, +} from '../../../calls/src/config/grid'; + +/** + * Улучшенный хук для адаптивных настроек сетки с поддержкой кастомных gridLayouts + */ +export const useResponsiveGrid = () => { + const [breakpoint, setBreakpoint] = useState('lg'); + const [maxTiles, setMaxTiles] = useState(GRID_CONFIG.MAX_TILES_PER_PAGE.lg); + const [screenWidth, setScreenWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 1024, + ); + const [isMobile, setIsMobile] = useState(false); + const [isTablet, setIsTablet] = useState(false); + const [isDesktop, setIsDesktop] = useState(true); + + useEffect(() => { + const updateBreakpoint = () => { + const width = window.innerWidth; + setScreenWidth(width); + + if (width < GRID_CONFIG.BREAKPOINTS.sm) { + setBreakpoint('xs'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE.xs); + setIsMobile(true); + setIsTablet(false); + setIsDesktop(false); + } else if (width < GRID_CONFIG.BREAKPOINTS.md) { + setBreakpoint('sm'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE.sm); + setIsMobile(true); + setIsTablet(false); + setIsDesktop(false); + } else if (width < GRID_CONFIG.BREAKPOINTS.lg) { + setBreakpoint('md'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE.md); + setIsMobile(false); + setIsTablet(true); + setIsDesktop(false); + } else if (width < GRID_CONFIG.BREAKPOINTS.xl) { + setBreakpoint('lg'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE.lg); + setIsMobile(false); + setIsTablet(false); + setIsDesktop(true); + } else if (width < GRID_CONFIG.BREAKPOINTS['2xl']) { + setBreakpoint('xl'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE.xl); + setIsMobile(false); + setIsTablet(false); + setIsDesktop(true); + } else { + setBreakpoint('2xl'); + setMaxTiles(GRID_CONFIG.MAX_TILES_PER_PAGE['2xl']); + setIsMobile(false); + setIsTablet(false); + setIsDesktop(true); + } + }; + + updateBreakpoint(); + window.addEventListener('resize', updateBreakpoint); + + return () => window.removeEventListener('resize', updateBreakpoint); + }, []); + + // Получение подходящих конфигураций для текущего размера экрана + const getLayoutsForCurrentScreen = useCallback(() => { + return getGridLayoutsForScreen(screenWidth); + }, [screenWidth]); + + // Получение оптимальной конфигурации для конкретного количества участников + const getOptimalLayout = useCallback( + (participantCount: number) => { + return getOptimalGridLayout(participantCount, screenWidth); + }, + [screenWidth], + ); + + return { + breakpoint, + maxTiles, + screenWidth, + isMobile, + isTablet, + isDesktop, + getLayoutsForCurrentScreen, + getOptimalLayout, + }; +}; + +/** + * Хук для управления адаптивной сеткой с кастомными конфигурациями + */ +export const useAdaptiveGrid = ( + gridRef: React.RefObject, + participantCount: number, +) => { + const { + screenWidth, + isMobile, + isTablet, + isDesktop, + getLayoutsForCurrentScreen, + getOptimalLayout, + } = useResponsiveGrid(); + + // Получение оптимальной конфигурации + const optimalLayout = getOptimalLayout(participantCount); + const availableLayouts = getLayoutsForCurrentScreen(); + + // Вычисление размера плитки для соотношения сторон 1:1 + const calculateTileSize = useCallback(() => { + if (!gridRef.current || !optimalLayout) { + return { width: 200, height: 200 }; + } + + const containerRect = gridRef.current.getBoundingClientRect(); + const gap = 16; // Отступ между плитками + const availableWidth = containerRect.width - (optimalLayout.columns - 1) * gap; + const availableHeight = containerRect.height - (optimalLayout.rows - 1) * gap; + + const tileWidth = Math.max( + GRID_CONFIG.TILE.MIN_WIDTH, + Math.min(GRID_CONFIG.TILE.WIDTH, availableWidth / optimalLayout.columns), + ); + const tileHeight = Math.max( + GRID_CONFIG.TILE.MIN_HEIGHT, + Math.min(GRID_CONFIG.TILE.HEIGHT, availableHeight / optimalLayout.rows), + ); + + // Обеспечиваем соотношение сторон 1:1 + const size = Math.min(tileWidth, tileHeight); + + return { width: size, height: size }; + }, [optimalLayout, gridRef]); + + const tileSize = calculateTileSize(); + const needsPagination = participantCount > (optimalLayout?.maxTiles || 16); + + return { + layout: optimalLayout, + screenWidth, + isMobile, + isTablet, + isDesktop, + needsPagination, + tileSize, + availableLayouts, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useScreenShareCleanup.ts b/packages/calls.hooks/src/hooks/useScreenShareCleanup.ts new file mode 100644 index 0000000..c1bfb54 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useScreenShareCleanup.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { Track } from 'livekit-client'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { usePinnedTracks, useCreateLayoutContext } from '@livekit/components-react'; + +/** + * Хук для автоматического удаления треков демонстрации экрана при их завершении + * Очищает закрепление когда пользователь отключает демонстрацию экрана + */ +export const useScreenShareCleanup = (tracks: TrackReferenceOrPlaceholder[]) => { + const layoutContext = useCreateLayoutContext(); + const focusTrack = usePinnedTracks(layoutContext)?.[0]; + + useEffect(() => { + const handleTrackUnpublished = (publication: { + source: Track.Source; + isSubscribed: boolean; + }) => { + if (publication.source === Track.Source.ScreenShare && !publication.isSubscribed) { + // Если трек демонстрации экрана больше не активен, очищаем закрепление + if (focusTrack && focusTrack.source === Track.Source.ScreenShare) { + layoutContext.pin.dispatch?.({ msg: 'clear_pin' }); + } + } + }; + + // Слушаем события отмены публикации треков + tracks.forEach((track) => { + if (track.publication && track.publication.source === Track.Source.ScreenShare) { + track.publication.on('unsubscribed', () => handleTrackUnpublished(track.publication)); + } + }); + + return () => { + tracks.forEach((track) => { + if (track.publication && track.publication.source === Track.Source.ScreenShare) { + track.publication.off('unsubscribed', () => handleTrackUnpublished(track.publication)); + } + }); + }; + }, [tracks, focusTrack, layoutContext.pin]); +}; diff --git a/packages/calls.hooks/src/hooks/useSize.ts b/packages/calls.hooks/src/hooks/useSize.ts new file mode 100644 index 0000000..74e0ae9 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useSize.ts @@ -0,0 +1,115 @@ +import * as React from 'react'; +import useLatest from '@react-hook/latest'; + +/** + * A React hook that fires a callback whenever ResizeObserver detects a change to its size + * code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer + * + * @internal + */ +export const useResizeObserver = ( + target: React.RefObject, + callback: UseResizeObserverCallback, +) => { + const resizeObserver = getResizeObserver(); + const storedCallback = useLatest(callback); + + React.useLayoutEffect(() => { + let didUnsubscribe = false; + + const targetEl = target.current; + if (!targetEl) return; + + function cb(entry: ResizeObserverEntry, observer: ResizeObserver) { + if (didUnsubscribe) return; + storedCallback.current(entry, observer); + } + + resizeObserver?.subscribe(targetEl as HTMLElement, cb); + + return () => { + didUnsubscribe = true; + resizeObserver?.unsubscribe(targetEl as HTMLElement, cb); + }; + }, [target.current, resizeObserver, storedCallback]); + + return resizeObserver?.observer; +}; + +const createResizeObserver = () => { + let ticking = false; + let allEntries: ResizeObserverEntry[] = []; + + const callbacks: Map> = new Map(); + + if (typeof window === 'undefined') { + return; + } + + const observer = new ResizeObserver((entries: ResizeObserverEntry[], obs: ResizeObserver) => { + allEntries = allEntries.concat(entries); + if (!ticking) { + window.requestAnimationFrame(() => { + const triggered = new Set(); + for (let i = 0; i < allEntries.length; i += 1) { + if (triggered.has(allEntries[i].target)) continue; + triggered.add(allEntries[i].target); + const cbs = callbacks.get(allEntries[i].target); + cbs?.forEach((cb) => cb(allEntries[i], obs)); + } + allEntries = []; + ticking = false; + }); + } + ticking = true; + }); + + return { + observer, + subscribe(target: HTMLElement, callback: UseResizeObserverCallback) { + observer.observe(target); + const cbs = callbacks.get(target) ?? []; + cbs.push(callback); + callbacks.set(target, cbs); + }, + unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) { + const cbs = callbacks.get(target) ?? []; + if (cbs.length === 1) { + observer.unobserve(target); + callbacks.delete(target); + return; + } + const cbIndex = cbs.indexOf(callback); + if (cbIndex !== -1) cbs.splice(cbIndex, 1); + callbacks.set(target, cbs); + }, + }; +}; + +let _resizeObserver: ReturnType; + +const getResizeObserver = () => + !_resizeObserver ? (_resizeObserver = createResizeObserver()) : _resizeObserver; + +export type UseResizeObserverCallback = ( + entry: ResizeObserverEntry, + observer: ResizeObserver, +) => unknown; + +export const useSize = (target: React.RefObject) => { + const [size, setSize] = React.useState({ width: 0, height: 0 }); + React.useLayoutEffect(() => { + if (target.current) { + const { width, height } = target.current.getBoundingClientRect(); + setSize({ width, height }); + } + }, [target.current]); + + const resizeCallback = React.useCallback( + (entry: ResizeObserverEntry) => setSize(entry.contentRect), + [], + ); + // Where the magic happens + useResizeObserver(target, resizeCallback); + return size; +}; diff --git a/packages/calls.hooks/src/hooks/useSpeakingParticipant.ts b/packages/calls.hooks/src/hooks/useSpeakingParticipant.ts new file mode 100644 index 0000000..13851ff --- /dev/null +++ b/packages/calls.hooks/src/hooks/useSpeakingParticipant.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { Track, RoomEvent } from 'livekit-client'; +import { useTracks } from '@livekit/components-react'; +import { useRoom } from '../../../calls/src/providers/RoomProvider'; +import type { TrackReference } from '@livekit/components-core'; +import { isTrackReference } from '@livekit/components-core'; + +export const useSpeakingParticipant = (): TrackReference | null => { + const { room } = useRoom(); + + // Получаем все треки камеры + const cameraTracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: false }], { + updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], + onlySubscribed: true, + }); + + // Находим говорящего участника + const speakingParticipant = useMemo(() => { + if (!room || cameraTracks.length === 0) return null; + + // Фильтруем только реальные треки (не placeholder) + const realTracks = cameraTracks.filter(isTrackReference); + + // Получаем активных спикеров из комнаты + const activeSpeakers = room.activeSpeakers; + + if (activeSpeakers.length === 0) { + // Если нет активных спикеров, возвращаем первого участника с видео + const firstVideoTrack = realTracks.find( + (track) => track.publication?.isSubscribed && !track.publication?.track?.isMuted, + ); + return firstVideoTrack || null; + } + + // Находим трек первого активного спикера + const speakingTrack = realTracks.find( + (track) => + track.participant.identity === activeSpeakers[0].identity && + track.publication?.isSubscribed && + !track.publication?.track?.isMuted, + ); + + return speakingTrack || null; + }, [room, cameraTracks]); + + return speakingParticipant; +}; diff --git a/packages/calls.hooks/src/hooks/useStartCall.ts b/packages/calls.hooks/src/hooks/useStartCall.ts new file mode 100644 index 0000000..721260c --- /dev/null +++ b/packages/calls.hooks/src/hooks/useStartCall.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useCallStore } from 'calls.store'; +import { useNavigate } from '@tanstack/react-router'; +import { useCalls } from '../../../calls/src/providers'; +import { StartCallDataT } from 'common.types'; + +export const useStartCall = () => { + const navigate = useNavigate(); + + const { useCurrentUser } = useCalls().auth; + const { createTokenByStudent, createTokenByTutor, reactivateCall, isLoading, error } = + useCalls().callAuth; + + const { data: user } = useCurrentUser(); + const isTutor = user?.default_layout === 'tutor'; + const { updateStore } = useCallStore(); + + const handleTokenToStartCall = async (data: StartCallDataT) => { + const tokenResponse = isTutor + ? await createTokenByTutor(data) + : await createTokenByStudent(data); + + if (tokenResponse) { + updateStore('token', tokenResponse); + + navigate({ + to: '/call/$callId', + params: { callId: data.classroom_id }, + }); + } + + return null; + }; + + const startCall = async (data: StartCallDataT) => { + try { + await handleTokenToStartCall(data); + } catch (error: any) { + if (error.response?.status === 409 && isTutor) { + try { + await reactivateCall(data); + await handleTokenToStartCall(data); + } catch (error: any) { + console.error('Ошибка при реактивировании комнаты:', error); + throw error; + } + } + } + }; + + return { + startCall, + isLoading, + error, + }; +}; diff --git a/packages/calls.hooks/src/hooks/useVideoBlur.ts b/packages/calls.hooks/src/hooks/useVideoBlur.ts new file mode 100644 index 0000000..0cedfa5 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useVideoBlur.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react'; +import { LocalVideoTrack } from 'livekit-client'; +import { BackgroundProcessor, supportsBackgroundProcessors } from '@livekit/track-processors'; +import { useUserChoicesStore } from '../store/userChoices'; + +export function useVideoBlur(videoTrack: LocalVideoTrack | null | undefined) { + const blurEnabled = useUserChoicesStore((state) => state.blurEnabled); + const processorRef = useRef | null>(null); + const isProcessingRef = useRef(false); + + useEffect(() => { + if (!videoTrack || !supportsBackgroundProcessors()) { + // Останавливаем процессор, если трек или поддержка отсутствуют + if (processorRef.current && videoTrack) { + videoTrack.stopProcessor().catch(console.error); + processorRef.current = null; + } + return; + } + + const applyBlur = async () => { + // Предотвращаем параллельные вызовы + if (isProcessingRef.current) { + return; + } + + isProcessingRef.current = true; + + try { + // Сначала останавливаем старый процессор, если он есть + if (processorRef.current) { + await videoTrack.stopProcessor(); + processorRef.current = null; + } + + if (blurEnabled) { + // Создаем новый процессор только если блюр включен + const processor = BackgroundProcessor({ + mode: 'background-blur', + blurRadius: 25, + } as Parameters[0]); + + processorRef.current = processor; + await videoTrack.setProcessor(processor); + } else { + // Если блюр выключен, убеждаемся, что процессор остановлен + await videoTrack.stopProcessor(); + processorRef.current = null; + } + } catch (error) { + console.error('Возникла ошибка, связанная с размытием фона:', error); + processorRef.current = null; + } finally { + isProcessingRef.current = false; + } + }; + + applyBlur(); + + return () => { + // Cleanup: останавливаем процессор при размонтировании или изменении зависимостей + if (videoTrack && processorRef.current) { + videoTrack.stopProcessor().catch(console.error); + processorRef.current = null; + } + isProcessingRef.current = false; + }; + }, [videoTrack, blurEnabled]); +} diff --git a/packages/calls.hooks/src/hooks/useVideoSecurity.ts b/packages/calls.hooks/src/hooks/useVideoSecurity.ts new file mode 100644 index 0000000..c84a172 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useVideoSecurity.ts @@ -0,0 +1,100 @@ +import { useEffect } from 'react'; + +/** + * Хук для глобальной блокировки браузерных элементов управления видео + * Применяется ко всему контейнеру ВКС + */ +export const useVideoSecurity = () => { + useEffect(() => { + const container = document.getElementById('videoConferenceContainer'); + if (!container) return; + + // Блокируем контекстное меню для всего контейнера + const handleContextMenu = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + // Блокируем выделение текста + const handleSelectStart = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + // Блокируем drag & drop + const handleDragStart = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + // Блокируем копирование + const handleCopy = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + // Блокируем печать + const handlePrint = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + // Блокируем F12 и другие dev tools shortcuts + const handleKeyDown = (e: KeyboardEvent) => { + // Блокируем F12, Ctrl+Shift+I, Ctrl+Shift+J, Ctrl+U, Ctrl+S + if ( + e.key === 'F12' || + (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'J')) || + (e.ctrlKey && e.key === 'u') || + (e.ctrlKey && e.key === 's') + ) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + // Применяем все обработчики к контейнеру + container.addEventListener('contextmenu', handleContextMenu); + container.addEventListener('selectstart', handleSelectStart); + container.addEventListener('dragstart', handleDragStart); + container.addEventListener('copy', handleCopy); + container.addEventListener('beforeprint', handlePrint); + container.addEventListener('keydown', handleKeyDown); + + // Дополнительные атрибуты для блокировки + container.setAttribute('oncontextmenu', 'return false'); + container.setAttribute('ondragstart', 'return false'); + container.setAttribute('onselectstart', 'return false'); + container.setAttribute('oncopy', 'return false'); + container.setAttribute('onbeforeprint', 'return false'); + + // Применяем CSS стили для блокировки + container.style.userSelect = 'none'; + container.style.webkitUserSelect = 'none'; + // @ts-expect-error - moz префиксы не типизированы в TypeScript + container.style.mozUserSelect = 'none'; + // @ts-expect-error - ms префиксы не типизированы в TypeScript + container.style.msUserSelect = 'none'; + // @ts-expect-error - webkit префиксы не типизированы в TypeScript + container.style.webkitTouchCallout = 'none'; + // @ts-expect-error - webkit префиксы не типизированы в TypeScript + container.style.webkitUserDrag = 'none'; + // @ts-expect-error - khtml префиксы не типизированы в TypeScript + container.style.khtmlUserSelect = 'none'; + + return () => { + container.removeEventListener('contextmenu', handleContextMenu); + container.removeEventListener('selectstart', handleSelectStart); + container.removeEventListener('dragstart', handleDragStart); + container.removeEventListener('copy', handleCopy); + container.removeEventListener('beforeprint', handlePrint); + container.removeEventListener('keydown', handleKeyDown); + }; + }, []); +}; diff --git a/packages/calls.hooks/src/hooks/useWatchPermissions.ts b/packages/calls.hooks/src/hooks/useWatchPermissions.ts new file mode 100644 index 0000000..fea41a3 --- /dev/null +++ b/packages/calls.hooks/src/hooks/useWatchPermissions.ts @@ -0,0 +1,112 @@ +import { useEffect } from 'react'; +import { usePermissionsStore } from '../store/permissions'; +import { isSafari } from '../../../calls/src/utils/livekit'; + +const POLLING_TIME = 500; + +export const useWatchPermissions = () => { + useEffect(() => { + let cleanup: (() => void) | undefined; + let intervalId: number | undefined; + let isCancelled = false; + + const checkPermissions = async () => { + try { + if (!navigator.permissions) { + if (!isCancelled) { + usePermissionsStore.setState({ + cameraPermission: 'unavailable', + microphonePermission: 'unavailable', + }); + } + return; + } + + const [cameraPermission, microphonePermission] = await Promise.all([ + navigator.permissions.query({ name: 'camera' as PermissionName }), + navigator.permissions.query({ name: 'microphone' as PermissionName }), + ]); + + if (isCancelled) return; + + if (isSafari()) { + if (cameraPermission.state === 'prompt' || microphonePermission.state === 'prompt') { + intervalId = setInterval(async () => { + if (isCancelled) return; + + const [cameraPermission, microphonePermission] = await Promise.all([ + navigator.permissions.query({ name: 'camera' as PermissionName }), + navigator.permissions.query({ name: 'microphone' as PermissionName }), + ]); + + if (isCancelled) return; + + usePermissionsStore.setState({ + cameraPermission: cameraPermission.state, + microphonePermission: microphonePermission.state, + }); + + if (cameraPermission.state !== 'prompt' && microphonePermission.state !== 'prompt') { + if (intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + } + }, POLLING_TIME); + } + } + + usePermissionsStore.setState({ + cameraPermission: cameraPermission.state, + microphonePermission: microphonePermission.state, + }); + + const handleCameraChange = (e: Event) => { + const target = e.target as PermissionStatus; + usePermissionsStore.setState({ cameraPermission: target.state }); + + if (intervalId && target.state !== 'prompt' && microphonePermission.state !== 'prompt') { + clearInterval(intervalId); + intervalId = undefined; + } + }; + + const handleMicrophoneChange = (e: Event) => { + const target = e.target as PermissionStatus; + usePermissionsStore.setState({ microphonePermission: target.state }); + + if (intervalId && target.state !== 'prompt' && microphonePermission.state !== 'prompt') { + clearInterval(intervalId); + intervalId = undefined; + } + }; + + cameraPermission.addEventListener('change', handleCameraChange); + microphonePermission.addEventListener('change', handleMicrophoneChange); + + cleanup = () => { + cameraPermission.removeEventListener('change', handleCameraChange); + microphonePermission.removeEventListener('change', handleMicrophoneChange); + if (intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + }; + } catch (error) { + if (!isCancelled) { + console.error('Error checking permissions:', error); + } + } finally { + if (!isCancelled) { + usePermissionsStore.setState({ isLoading: false }); + } + } + }; + checkPermissions(); + + return () => { + isCancelled = true; + cleanup?.(); + }; + }, []); +}; diff --git a/packages/calls.hooks/src/index.ts b/packages/calls.hooks/src/index.ts new file mode 100644 index 0000000..4cc90d0 --- /dev/null +++ b/packages/calls.hooks/src/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/packages/common.services/tsconfig.json b/packages/calls.hooks/tsconfig.json similarity index 61% rename from packages/common.services/tsconfig.json rename to packages/calls.hooks/tsconfig.json index 059d61e..f88e892 100644 --- a/packages/common.services/tsconfig.json +++ b/packages/calls.hooks/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src/**/*"], + "include": ["src/**/*", "../common.ui/src/config", "src/hooks"], "extends": ["common.typescript/tsconfig.app.json"], "exclude": ["dist", "build", "node_modules"] } diff --git a/packages/calls.store/README.md b/packages/calls.store/README.md new file mode 100644 index 0000000..5123e09 --- /dev/null +++ b/packages/calls.store/README.md @@ -0,0 +1 @@ +компонент модержащий store приложения diff --git a/packages/common.auth/eslint.config.js b/packages/calls.store/eslint.config.js similarity index 100% rename from packages/common.auth/eslint.config.js rename to packages/calls.store/eslint.config.js diff --git a/packages/calls.store/index.ts b/packages/calls.store/index.ts new file mode 100644 index 0000000..03ce20d --- /dev/null +++ b/packages/calls.store/index.ts @@ -0,0 +1 @@ +export { useCallStore, usePermissionsStore, useUserChoicesStore } from './src'; diff --git a/packages/common.api/package.json b/packages/calls.store/package.json similarity index 86% rename from packages/common.api/package.json rename to packages/calls.store/package.json index b4a1bc6..ddedd61 100644 --- a/packages/common.api/package.json +++ b/packages/calls.store/package.json @@ -1,5 +1,5 @@ { - "name": "common.api", + "name": "calls.store", "version": "0.0.0", "type": "module", "exports": { @@ -11,8 +11,8 @@ "dev": "tsc --watch" }, "dependencies": { - "axios": "1.8.1", - "common.env": "*" + "zustand": "5.0.3", + "common.types": "*" }, "devDependencies": { "@tanstack/react-query-devtools": "5.73.3", @@ -34,6 +34,6 @@ "peerDependencies": { "react": "19" }, - "description": "Common API configuration and examples", + "description": "calls.store", "author": "xi.effect" } diff --git a/packages/calls.store/src/index.ts b/packages/calls.store/src/index.ts new file mode 100644 index 0000000..d406816 --- /dev/null +++ b/packages/calls.store/src/index.ts @@ -0,0 +1 @@ +export * from './store'; diff --git a/packages/calls.store/src/store/callStore.ts b/packages/calls.store/src/store/callStore.ts new file mode 100644 index 0000000..ea07fd5 --- /dev/null +++ b/packages/calls.store/src/store/callStore.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type ChatMessage = { + id: string; + text: string; + senderId: string; + senderName: string; + timestamp: number; +}; + +type RaisedHand = { + participantId: string; + participantName: string; + timestamp: number; +}; + +export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +type useCallStoreT = { + // разрешение от браузера на использование камеры + isCameraPermission: boolean | null; + isMicroPermission: boolean | null; + // включён ли у пользователя микро + audioEnabled: boolean; + videoEnabled: boolean; + // id-выбранного устройства + audioDeviceId: ConstrainDOMString | undefined; + audioOutputDeviceId: ConstrainDOMString | undefined; + videoDeviceId: ConstrainDOMString | undefined; + // подключена ли конференция + connect: boolean | undefined; + // началась ли ВКС для пользователя + isStarted: boolean | undefined; + // состояние подключения + isConnecting: boolean; + + mode: 'compact' | 'full'; + carouselType: 'grid' | 'horizontal' | 'vertical'; + activeCorner: Corner; + + // Текущая активная доска (для синхронизации с новыми участниками) + activeBoardId: string | undefined; + activeClassroom: string | undefined; + + // токен для конференции + token: string | undefined; + + // Чат + isChatOpen: boolean; + chatMessages: ChatMessage[]; + unreadMessagesCount: number; + + // Поднятые руки + raisedHands: RaisedHand[]; + isHandRaised: boolean; + + updateStore: (type: keyof useCallStoreT, value: any) => void; + addChatMessage: (message: ChatMessage) => void; + clearUnreadMessages: () => void; + addRaisedHand: (hand: RaisedHand) => void; + removeRaisedHand: (participantId: string) => void; + toggleHandRaised: () => void; + clearAllRaisedHands: () => void; + isHandRaisedByParticipant: (participantId: string) => boolean; +}; + +export const useCallStore = create()( + persist( + (set, get) => ({ + isCameraPermission: null, + isMicroPermission: null, + audioEnabled: false, + videoEnabled: false, + audioDeviceId: undefined, + audioOutputDeviceId: undefined, + videoDeviceId: undefined, + connect: undefined, + isStarted: undefined, + isConnecting: false, + mode: 'full', + carouselType: 'grid', + activeCorner: 'top-left', + + // Текущая активная доска + activeBoardId: undefined, + activeClassroom: undefined, + + // токен для конференции + token: undefined, + + // Чат + isChatOpen: false, + chatMessages: [], + unreadMessagesCount: 0, + + // Поднятые руки + raisedHands: [], + isHandRaised: false, + + updateStore: (type: keyof useCallStoreT, value: any) => set({ [type]: value }), + + addChatMessage: (message: ChatMessage) => { + const { isChatOpen, unreadMessagesCount, chatMessages } = get(); + + // Проверяем, нет ли уже сообщения с таким ID (дедупликация) + const messageExists = chatMessages.some((msg) => msg.id === message.id); + if (messageExists) { + return; + } + + set((state: useCallStoreT) => ({ + chatMessages: [...state.chatMessages, message], + unreadMessagesCount: isChatOpen ? unreadMessagesCount : unreadMessagesCount + 1, + })); + }, + + clearUnreadMessages: () => set({ unreadMessagesCount: 0 }), + + // Поднятые руки + addRaisedHand: (hand: RaisedHand) => + set((state: useCallStoreT) => { + // Проверяем, есть ли уже рука от этого участника + const existingHand = state.raisedHands.find( + (h) => h.participantId === hand.participantId, + ); + if (existingHand) { + // Обновляем существующую руку + return { + raisedHands: state.raisedHands.map((h) => + h.participantId === hand.participantId ? hand : h, + ), + }; + } + // Добавляем новую руку + return { raisedHands: [...state.raisedHands, hand] }; + }), + removeRaisedHand: (participantId: string) => + set((state: useCallStoreT) => ({ + raisedHands: state.raisedHands.filter((hand) => hand.participantId !== participantId), + })), + toggleHandRaised: () => + set((state: useCallStoreT) => ({ isHandRaised: !state.isHandRaised })), + clearAllRaisedHands: () => set({ raisedHands: [], isHandRaised: false }), + isHandRaisedByParticipant: (participantId: string) => { + const state = get(); + return state.raisedHands.some((hand) => hand.participantId === participantId); + }, + }), + { + name: 'call-store', // Название ключа в localStorage + partialize: (state: useCallStoreT) => ({ + isCameraPermission: state.isCameraPermission, + isMicroPermission: state.isMicroPermission, + audioEnabled: state.audioEnabled, + videoEnabled: state.videoEnabled, + audioDeviceId: state.audioDeviceId, + videoDeviceId: state.videoDeviceId, + carouselType: state.carouselType, + activeCorner: state.activeCorner, + }), // Сохраняем только нужные ключи + }, + ), +); diff --git a/packages/calls.store/src/store/index.ts b/packages/calls.store/src/store/index.ts new file mode 100644 index 0000000..ddae733 --- /dev/null +++ b/packages/calls.store/src/store/index.ts @@ -0,0 +1,3 @@ +export { useCallStore } from './callStore'; +export { usePermissionsStore } from './permissions'; +export { useUserChoicesStore } from './userChoices'; diff --git a/packages/calls.store/src/store/permissions.ts b/packages/calls.store/src/store/permissions.ts new file mode 100644 index 0000000..f864075 --- /dev/null +++ b/packages/calls.store/src/store/permissions.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type PermissionState = undefined | 'granted' | 'prompt' | 'denied' | 'unavailable'; + +type BaseState = { + cameraPermission: PermissionState; + microphonePermission: PermissionState; + isLoading: boolean; + isPermissionDialogOpen: boolean; +}; + +type DerivedState = { + isCameraGranted: boolean; + isMicrophoneGranted: boolean; + isCameraDenied: boolean; + isMicrophoneDenied: boolean; + isCameraPrompted: boolean; + isMicrophonePrompted: boolean; +}; + +type State = BaseState & DerivedState; + +export const usePermissionsStore = create()( + persist( + (_, get) => ({ + cameraPermission: undefined, + microphonePermission: undefined, + isLoading: true, + isPermissionDialogOpen: false, + + get isCameraGranted() { + return get().cameraPermission === 'granted'; + }, + get isMicrophoneGranted() { + return get().microphonePermission === 'granted'; + }, + get isCameraDenied() { + return get().cameraPermission === 'denied'; + }, + get isMicrophoneDenied() { + return get().microphonePermission === 'denied'; + }, + get isCameraPrompted() { + return get().cameraPermission === 'prompt'; + }, + get isMicrophonePrompted() { + return get().microphonePermission === 'prompt'; + }, + }), + { + name: 'permissions-storage', + partialize: (state) => ({ + cameraPermission: state.cameraPermission, + microphonePermission: state.microphonePermission, + }), + }, + ), +); + +export const openPermissionsDialog = () => { + usePermissionsStore.setState({ isPermissionDialogOpen: true }); +}; + +export const closePermissionsDialog = () => { + usePermissionsStore.setState({ isPermissionDialogOpen: false }); +}; diff --git a/packages/calls.store/src/store/userChoices.ts b/packages/calls.store/src/store/userChoices.ts new file mode 100644 index 0000000..3252507 --- /dev/null +++ b/packages/calls.store/src/store/userChoices.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { + loadUserChoices, + saveUserChoices, + LocalUserChoices as LocalUserChoicesLK, +} from '@livekit/components-core'; +import { VideoQuality } from 'livekit-client'; + +export type VideoResolution = 'h720' | 'h360' | 'h180'; + +export type LocalUserChoices = LocalUserChoicesLK & { + noiseReductionEnabled?: boolean; + blurEnabled?: boolean; + audioOutputDeviceId?: string; + videoPublishResolution?: VideoResolution; + videoSubscribeQuality?: VideoQuality; +}; + +function getUserChoicesState(): LocalUserChoices { + return { + noiseReductionEnabled: false, + blurEnabled: false, + audioOutputDeviceId: 'default', + videoPublishResolution: 'h720', + videoSubscribeQuality: VideoQuality.HIGH, + ...loadUserChoices(), + }; +} + +export const useUserChoicesStore = create()( + persist( + () => ({ + ...getUserChoicesState(), + }), + { + name: 'user-choices-storage', + onRehydrateStorage: () => (state) => { + if (state) { + saveUserChoices(state, false); + } + }, + }, + ), +); diff --git a/packages/calls.store/tsconfig.json b/packages/calls.store/tsconfig.json new file mode 100644 index 0000000..f88e892 --- /dev/null +++ b/packages/calls.store/tsconfig.json @@ -0,0 +1,5 @@ +{ + "include": ["src/**/*", "../common.ui/src/config", "src/hooks"], + "extends": ["common.typescript/tsconfig.app.json"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/calls/README.md b/packages/calls/README.md new file mode 100644 index 0000000..4d2417e --- /dev/null +++ b/packages/calls/README.md @@ -0,0 +1 @@ +Основной компонент приложения diff --git a/packages/common.entities/eslint.config.js b/packages/calls/eslint.config.js similarity index 100% rename from packages/common.entities/eslint.config.js rename to packages/calls/eslint.config.js diff --git a/packages/common.api/index.ts b/packages/calls/index.ts similarity index 100% rename from packages/common.api/index.ts rename to packages/calls/index.ts diff --git a/packages/calls/package.json b/packages/calls/package.json new file mode 100644 index 0000000..0b4bb93 --- /dev/null +++ b/packages/calls/package.json @@ -0,0 +1,75 @@ +{ + "name": "calls", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "license": "MIT", + "scripts": { + "lint": "eslint \"**/*.{ts,tsx}\"", + "dev": "tsc --watch" + }, + "dependencies": { + "axios": "1.8.1", + "common.env": "*", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", + "@dnd-kit/utilities": "3.2.2", + "@tanstack/react-router": "1.128.8", + "@livekit/components-core": "0.12.9", + "@livekit/components-react": "2.9.14", + "@livekit/components-styles": "1.1.6", + "@livekit/krisp-noise-filter": "^0.3.4", + "@livekit/track-processors": "^0.6.1", + "livekit-client": "2.15.6", + "@react-hook/latest": "1.0.3", + "@xipkg/aspect-ratio": "^2.0.11", + "@xipkg/breadcrumbs": "2.0.13", + "@xipkg/userprofile": "4.0.14", + "@xipkg/scrollarea": "2.2.0", + "@xipkg/dropdown": "3.0.12", + "@xipkg/textarea": "1.1.0", + "@xipkg/tooltip": "2.1.0", + "@xipkg/badge": "2.0.12", + "@xipkg/button": "3.2.0", + "@xipkg/icons": "^2.7.0", + "@xipkg/modal": "^4.3.1", + "@xipkg/select": "2.2.5", + "@xipkg/sheet": "2.0.12", + "@xipkg/alert": "1.1.0", + "@xipkg/utils": "1.8.0", + "common.ui": "*", + "common.utils": "*", + "common.types": "*", + "driver.js": "^1.3.6", + "framer-motion": "12.12.1", + "sonner": "^2.0.7", + "@xipkg/link": "2.0.12", + "zustand": "5.0.3", + "calls.store": "*", + "calls.hooks": "*" + }, + "devDependencies": { + "@tanstack/react-query-devtools": "5.73.3", + "@eslint/js": "^9.19.0", + "common.typescript": "*", + "@types/node": "^20.3.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@xipkg/eslint": "3.2.0", + "@xipkg/typescript": "latest", + "common.eslint": "*", + "eslint": "^9.19.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.22.0" + }, + "peerDependencies": { + "react": "19" + }, + "description": "calls package", + "author": "xi.effect" +} diff --git a/packages/calls/src/config/config.ts b/packages/calls/src/config/config.ts new file mode 100644 index 0000000..bfa5c1c --- /dev/null +++ b/packages/calls/src/config/config.ts @@ -0,0 +1,6 @@ +import { env } from 'common.env'; + +export const serverUrl = env.VITE_SERVER_URL_LIVEKIT; +export const serverUrlDev = env.VITE_SERVER_URL_LIVEKIT_DEV; +export const devToken = env.VITE_LIVEKIT_DEV_TOKEN; +export const isDevMode = env.VITE_LIVEKIT_DEV_MODE; diff --git a/packages/calls/src/config/grid.ts b/packages/calls/src/config/grid.ts new file mode 100644 index 0000000..68886e2 --- /dev/null +++ b/packages/calls/src/config/grid.ts @@ -0,0 +1,274 @@ +/** + * Конфигурация для адаптивной сетки пользователей в ВКС + * Оптимизирована для соотношения сторон 1:1 и всех устройств + */ + +export interface GridLayoutConfig { + columns: number; + rows: number; + minTiles: number; + maxTiles: number; + minWidth: number; + minHeight: number; + name: string; +} + +export const GRID_CONFIG = { + // Размеры тайлов с соотношением сторон 1:1 + TILE: { + HEIGHT: 200, + WIDTH: 200, + MIN_HEIGHT: 120, + MIN_WIDTH: 120, + ASPECT_RATIO: 1, // Квадратные плитки + }, + + // Брейкпоинты для адаптивности + BREAKPOINTS: { + xs: 360, // Мобильные телефоны + sm: 640, // Планшеты + md: 768, // Небольшие десктопы + lg: 1024, // Десктопы + xl: 1280, // Большие экраны + '2xl': 1536, // Очень большие экраны + }, + + // Максимальное количество тайлов на странице + MAX_TILES_PER_PAGE: { + xs: 1, + sm: 4, + md: 6, + lg: 9, + xl: 12, + '2xl': 16, + }, + + // Настройки пагинации + PAGINATION: { + SWIPE_THRESHOLD: 50, + AUTO_HIDE_DELAY: 2000, + }, + + // Настройки карусели + CAROUSEL: { + SCROLL_BAR_WIDTH: 17, + MIN_VISIBLE_TILES: 1, + }, +} as const; + +/** + * Кастомные конфигурации gridLayouts для адаптивной сетки + * Основано на рекомендациях LiveKit для соотношения сторон 1:1 + */ +export const CUSTOM_GRID_LAYOUTS: GridLayoutConfig[] = [ + // 1 участник - полноэкранный режим + { + columns: 1, + rows: 1, + minTiles: 1, + maxTiles: 1, + minWidth: 0, + minHeight: 0, + name: 'single-participant', + }, + + // 2 участника - горизонтальная линия + { + columns: 2, + rows: 1, + minTiles: 2, + maxTiles: 2, + minWidth: 0, + minHeight: 0, + name: 'two-participants', + }, + + // 3-4 участника - квадрат 2x2 + { + columns: 2, + rows: 2, + minTiles: 3, + maxTiles: 4, + minWidth: 0, + minHeight: 0, + name: 'small-grid', + }, + + // 5-6 участников - 3x2 (оптимально для средних экранов) + { + columns: 3, + rows: 2, + minTiles: 5, + maxTiles: 6, + minWidth: 0, + minHeight: 0, + name: 'medium-grid', + }, + + // 7-9 участников - квадрат 3x3 + { + columns: 3, + rows: 3, + minTiles: 7, + maxTiles: 9, + minWidth: 0, + minHeight: 0, + name: 'large-grid', + }, + + // 10-12 участников - 4x3 + { + columns: 4, + rows: 3, + minTiles: 10, + maxTiles: 12, + minWidth: 0, + minHeight: 0, + name: 'wide-grid', + }, + + // 13-16 участников - квадрат 4x4 + { + columns: 4, + rows: 4, + minTiles: 13, + maxTiles: 16, + minWidth: 0, + minHeight: 0, + name: 'full-grid', + }, +]; + +/** + * Конфигурации для мобильных устройств + */ +export const MOBILE_GRID_LAYOUTS: GridLayoutConfig[] = [ + // 1 участник + { + columns: 1, + rows: 1, + minTiles: 1, + maxTiles: 1, + minWidth: 0, + minHeight: 0, + name: 'mobile-single', + }, + + // 2-4 участника - вертикальная колонка + { + columns: 1, + rows: 4, + minTiles: 2, + maxTiles: 4, + minWidth: 0, + minHeight: 0, + name: 'mobile-vertical', + }, + + // 5-6 участников - 2x3 + { + columns: 2, + rows: 3, + minTiles: 5, + maxTiles: 6, + minWidth: 0, + minHeight: 0, + name: 'mobile-small-grid', + }, + + // 7-9 участников - 3x3 + { + columns: 3, + rows: 3, + minTiles: 7, + maxTiles: 9, + minWidth: 0, + minHeight: 0, + name: 'mobile-grid', + }, +]; + +/** + * Конфигурации для планшетов + */ +export const TABLET_GRID_LAYOUTS: GridLayoutConfig[] = [ + // 1-2 участника + { + columns: 2, + rows: 1, + minTiles: 1, + maxTiles: 2, + minWidth: 0, + minHeight: 0, + name: 'tablet-small', + }, + + // 3-4 участника - 2x2 + { + columns: 2, + rows: 2, + minTiles: 3, + maxTiles: 4, + minWidth: 0, + minHeight: 0, + name: 'tablet-medium', + }, + + // 5-6 участников - 3x2 + { + columns: 3, + rows: 2, + minTiles: 5, + maxTiles: 6, + minWidth: 0, + minHeight: 0, + name: 'tablet-large', + }, + + // 7-9 участников - 3x3 + { + columns: 3, + rows: 3, + minTiles: 7, + maxTiles: 9, + minWidth: 0, + minHeight: 0, + name: 'tablet-grid', + }, +]; + +/** + * Функция для выбора подходящей конфигурации на основе размера экрана + */ +export const getGridLayoutsForScreen = (screenWidth: number): GridLayoutConfig[] => { + if (screenWidth < 640) { + return MOBILE_GRID_LAYOUTS; + } else if (screenWidth < 1024) { + return TABLET_GRID_LAYOUTS; + } else { + return CUSTOM_GRID_LAYOUTS; + } +}; + +/** + * Функция для получения оптимальной конфигурации для конкретного количества участников + */ +export const getOptimalGridLayout = ( + participantCount: number, + screenWidth: number, +): GridLayoutConfig | null => { + const layouts = getGridLayoutsForScreen(screenWidth); + + const suitableLayout = layouts.find( + (layout) => participantCount >= layout.minTiles && participantCount <= layout.maxTiles, + ); + + if (suitableLayout) { + return suitableLayout; + } + + return layouts[layouts.length - 1] || null; +}; + +export type GridBreakpoint = keyof typeof GRID_CONFIG.BREAKPOINTS; +export type GridConfig = typeof GRID_CONFIG; diff --git a/packages/calls/src/config/index.ts b/packages/calls/src/config/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/calls/src/config/ports.ts b/packages/calls/src/config/ports.ts new file mode 100644 index 0000000..e23d123 --- /dev/null +++ b/packages/calls/src/config/ports.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { StartCallDataT } from 'common.types'; + +export type AuthPortT = { + useCurrentUser(disabled?: boolean): { + data?: any; + isLoading: boolean; + isError: boolean; + }; +}; + +export type ClassroomPortT = { + useGetClassroom( + id: number, + disabled?: boolean, + ): { + data?: any; + isLoading: boolean; + isError: boolean; + }; + useAddClassroomMaterials: () => void; + useGetClassroomMaterialsList(params: { + classroomId: string; + content_type: string; + disabled?: boolean; + }): { + data?: any[]; + isLoading: boolean; + isError: boolean; + }; +}; + +export type CallAuthPortT = { + createTokenByTutor(data: StartCallDataT): Promise; + createTokenByStudent(data: StartCallDataT): Promise; + reactivateCall(data: StartCallDataT): Promise; + + isLoading: boolean; + error?: any; +}; diff --git a/packages/calls/src/providers/CallsProvider.tsx b/packages/calls/src/providers/CallsProvider.tsx new file mode 100644 index 0000000..6270f6e --- /dev/null +++ b/packages/calls/src/providers/CallsProvider.tsx @@ -0,0 +1,21 @@ +import { createContext, FC, useContext } from 'react'; +import { AuthPortT, CallAuthPortT, ClassroomPortT } from '../config/ports'; + +export type CallsProviderDepsT = { + auth: AuthPortT; + room: ClassroomPortT; + callAuth: CallAuthPortT; +}; + +const CallsContext = createContext(null); + +export const CallsProvider: FC<{ deps: CallsProviderDepsT; children: React.ReactNode }> = ({ + deps, + children, +}) => {children}; + +export const useCalls = () => { + const ctx = useContext(CallsContext); + if (!ctx) throw new Error('CallsProvider is missing'); + return ctx; +}; diff --git a/packages/calls/src/providers/LiveKitProvider.tsx b/packages/calls/src/providers/LiveKitProvider.tsx new file mode 100644 index 0000000..b926010 --- /dev/null +++ b/packages/calls/src/providers/LiveKitProvider.tsx @@ -0,0 +1,244 @@ +import { LiveKitRoom } from '@livekit/components-react'; +import { serverUrl, serverUrlDev, isDevMode, devToken } from '../config/config'; +import { useCallStore } from '../store/callStore'; +import { useRoom } from './RoomProvider'; +import { useParams, useLocation, useNavigate, useSearch } from '@tanstack/react-router'; +import { useEffect, useRef } from 'react'; +import { Track } from 'livekit-client'; + +type LiveKitProviderPropsT = { + children: React.ReactNode; +}; + +export const LiveKitProvider = ({ children }: LiveKitProviderPropsT) => { + const { room } = useRoom(); + const { audioEnabled, videoEnabled, connect, token, updateStore } = useCallStore(); + const { callId } = useParams({ strict: false }); + + const { isStarted } = useCallStore(); + const wasConnectedRef = useRef(false); + const reconnectTimeoutRef = useRef(null); + + const handleConnect = () => { + wasConnectedRef.current = true; + updateStore('connect', true); + + // При подключении проверяем, соответствует ли activeClassroom текущему callId + // Если нет - очищаем информацию о доске (возможно, подключились к другой ВКС) + const { activeClassroom } = useCallStore.getState(); + + if (activeClassroom && callId && activeClassroom !== callId) { + // Подключились к другой ВКС - очищаем информацию о доске + updateStore('activeBoardId', undefined); + updateStore('activeClassroom', undefined); + } + }; + + const handleDisconnect = () => { + // Не очищаем состояние, если это временное отключение из-за сворачивания окна + // Проверяем, была ли страница скрыта в момент отключения + if (document.hidden && wasConnectedRef.current) { + console.log('Page hidden - will attempt to reconnect when visible'); + // Не очищаем состояние, чтобы можно было переподключиться + return; + } + + wasConnectedRef.current = false; + updateStore('connect', false); + updateStore('isStarted', false); + updateStore('mode', 'full'); + + // Очищаем все состояния интерфейса при отключении + const { clearAllRaisedHands, updateStore: updateCallStore } = useCallStore.getState(); + + // Очищаем поднятые руки + clearAllRaisedHands(); + + // Очищаем чат + updateCallStore('isChatOpen', false); + updateCallStore('chatMessages', []); + updateCallStore('unreadMessagesCount', 0); + + // Очищаем информацию о доске при отключении + updateCallStore('activeBoardId', undefined); + updateCallStore('activeClassroom', undefined); + + // Удаляем параметр call из URL при отключении + if (search.call) { + const searchWithoutCall = { ...search }; + delete searchWithoutCall.call; + navigate({ + to: location.pathname, + search: searchWithoutCall, + replace: true, + }); + } + + console.log('Disconnected from LiveKit room - all interface states cleared'); + }; + + const location = useLocation(); + const navigate = useNavigate(); + const search = useSearch({ strict: false }) as { call?: string }; + + useEffect(() => { + if (!token && callId && location.pathname.includes('/call/')) { + navigate({ + to: '/classrooms/$classroomId', + params: { classroomId: callId }, + search: { call: callId }, + }); + } + }, [location, token, callId, navigate]); + + // Обработка событий видимости страницы для переподключения + useEffect(() => { + if (!isStarted || !connect) { + return; + } + + // Функция для восстановления подписок на видеотреки + const restoreVideoSubscriptions = () => { + if (room.state !== 'connected') { + return; + } + + console.log('Restoring video subscriptions for all participants...'); + let restoredCount = 0; + + // Проходим по всем удаленным участникам + room.remoteParticipants.forEach((participant) => { + // Проходим по всем видеотрекам участника + participant.videoTrackPublications.forEach((publication) => { + // Подписываемся только на треки камеры и демонстрации экрана + if ( + (publication.source === Track.Source.Camera || + publication.source === Track.Source.ScreenShare) && + !publication.isSubscribed && + publication.isEnabled + ) { + try { + // Используем setSubscribed - метод существует в runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pub = publication as any; + if (typeof pub.setSubscribed === 'function') { + pub.setSubscribed(true); + restoredCount++; + console.log( + `Restored subscription for ${participant.identity} - ${publication.source}`, + ); + } else { + console.warn( + `setSubscribed method not available for ${participant.identity} - ${publication.source}`, + ); + } + } catch (error) { + console.error(`Failed to restore subscription for ${participant.identity}:`, error); + } + } + }); + }); + + if (restoredCount > 0) { + console.log(`Successfully restored ${restoredCount} video subscription(s)`); + } + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + // Страница скрыта - очищаем таймаут переподключения + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + console.log('Page hidden - connection may be paused'); + } else { + // Страница снова видна - проверяем соединение и переподключаемся при необходимости + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = window.setTimeout(() => { + const currentIsStarted = useCallStore.getState().isStarted; + + // Если мы были подключены, но соединение разорвалось, переподключаемся + if (currentIsStarted && room.state !== 'connected' && wasConnectedRef.current) { + console.log('Page visible - attempting to reconnect...'); + updateStore('connect', true); + } + + // Восстанавливаем подписки на видеотреки при возврате фокуса + if (room.state === 'connected') { + restoreVideoSubscriptions(); + } + }, 1000); // Задержка для стабилизации после возврата на страницу + } + }; + + // Обработка событий переподключения комнаты + const handleReconnecting = () => { + console.log('LiveKit: Room is reconnecting...'); + }; + + const handleReconnected = () => { + console.log('LiveKit: Room reconnected successfully'); + wasConnectedRef.current = true; + updateStore('connect', true); + }; + + // Обработка ошибок соединения + const handleConnectionStateChanged = (state: string) => { + console.log('LiveKit: Connection state changed:', state); + }; + + // Мониторинг качества соединения + let lastQuality: string | null = null; + const handleConnectionQualityChanged = (quality: string) => { + if (quality !== lastQuality) { + lastQuality = quality; + if (quality === 'poor' || quality === 'unknown') { + console.warn('LiveKit: Connection quality is poor'); + // Здесь можно добавить уведомление пользователю + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + room.on('reconnecting', handleReconnecting); + room.on('reconnected', handleReconnected); + room.on('connectionStateChanged', handleConnectionStateChanged); + room.on('connectionQualityChanged', handleConnectionQualityChanged); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + room.off('reconnecting', handleReconnecting); + room.off('reconnected', handleReconnected); + room.off('connectionStateChanged', handleConnectionStateChanged); + room.off('connectionQualityChanged', handleConnectionQualityChanged); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + }; + }, [isStarted, connect, room, updateStore]); + + if (!token || !room) { + if (isStarted) console.warn('No token available for LiveKit connection'); + + return <>{children}; + } + + return ( + + {children} + + ); +}; diff --git a/packages/calls/src/providers/ModeSyncProvider.tsx b/packages/calls/src/providers/ModeSyncProvider.tsx new file mode 100644 index 0000000..ba15ab1 --- /dev/null +++ b/packages/calls/src/providers/ModeSyncProvider.tsx @@ -0,0 +1,30 @@ +import { ReactNode, useEffect } from 'react'; +import { useModeSync } from '../hooks'; +import { useRoom } from './RoomProvider'; +import { useCallStore } from '../store/callStore'; + +type ModeSyncProviderProps = { + children: ReactNode; +}; + +export const ModeSyncProvider = ({ children }: ModeSyncProviderProps) => { + const { room } = useRoom(); + const connect = useCallStore((state) => state.connect); + + // Инициализируем хук для синхронизации режима + // Это автоматически подпишет нас на сообщения о смене режима + useModeSync(); + + useEffect(() => { + if (room && connect) { + // console.log('🔗 ModeSyncProvider: Room is connected and ready for data channel'); + } else { + // console.log('⏳ ModeSyncProvider: Waiting for room connection...', { + // hasRoom: !!room, + // connect, + // }); + } + }, [room, connect]); + + return <>{children}; +}; diff --git a/packages/calls/src/providers/RoomProvider.tsx b/packages/calls/src/providers/RoomProvider.tsx new file mode 100644 index 0000000..aecda2f --- /dev/null +++ b/packages/calls/src/providers/RoomProvider.tsx @@ -0,0 +1,89 @@ +import { createContext, useContext, ReactNode, useMemo } from 'react'; +import { Room, RoomOptions, ConnectionQuality, Track } from 'livekit-client'; + +type RoomContextType = { + room: Room; +}; + +const RoomContext = createContext(null); + +export const useRoom = () => { + const context = useContext(RoomContext); + if (!context) { + throw new Error('useRoom must be used within a RoomProvider'); + } + return context; +}; + +type RoomProviderProps = { + children: ReactNode; +}; + +// Определяем, является ли устройство мобильным +const isMobileDevice = () => { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +}; + +export const RoomProvider = ({ children }: RoomProviderProps) => { + // Создаем комнату только один раз при монтировании компонента + // с настройками для устойчивого соединения + const room = useMemo(() => { + const roomOptions: RoomOptions = { + // Не отключаемся при потере фокуса + stopLocalTrackOnUnpublish: false, + // Включаем адаптивный стриминг для оптимизации качества + adaptiveStream: false, + // Включаем dynacast для динамической подписки на треки + dynacast: true, + disconnectOnPageLeave: false, + }; + + const newRoom = new Room(roomOptions); + + // Обработка событий переподключения + newRoom.on('reconnecting', () => { + console.log('LiveKit: Attempting to reconnect...'); + }); + + newRoom.on('reconnected', () => { + console.log('LiveKit: Successfully reconnected'); + }); + + // Улучшенный мониторинг качества соединения + let lastQuality: ConnectionQuality | null = null; + newRoom.on('connectionQualityChanged', (quality: ConnectionQuality) => { + if (quality !== lastQuality) { + lastQuality = quality; + + if (quality === 'poor' || quality === 'unknown') { + console.warn('LiveKit: Connection quality degraded:', quality); + // Можно добавить уведомление пользователю через toast + } else if (quality === 'excellent' && lastQuality === 'poor') { + console.log('LiveKit: Connection quality improved'); + } + } + }); + + // Обработка ошибок соединения + newRoom.on('connectionStateChanged', (state) => { + console.log('LiveKit: Connection state changed:', state); + }); + + // Обработка публикации треков + newRoom.on('trackPublished', (publication, participant) => { + if (publication.kind === Track.Kind.Video) { + console.log('LiveKit: Video track published by', participant.identity); + } + }); + + // Оптимизация для мобильных устройств + if (isMobileDevice()) { + // На мобильных устройствах можно дополнительно оптимизировать + console.log('LiveKit: Mobile device detected - applying optimizations'); + } + + return newRoom; + }, []); + + return {children}; +}; diff --git a/packages/calls/src/providers/index.ts b/packages/calls/src/providers/index.ts new file mode 100644 index 0000000..d02da48 --- /dev/null +++ b/packages/calls/src/providers/index.ts @@ -0,0 +1,5 @@ +export { LiveKitProvider } from './LiveKitProvider'; +export { RoomProvider } from './RoomProvider'; +export { ModeSyncProvider } from './ModeSyncProvider'; +export { CallsProvider, useCalls } from './CallsProvider'; +export type { CallsProviderDepsT } from './CallsProvider'; diff --git a/packages/calls/src/ui/Bottom/BottomBar.tsx b/packages/calls/src/ui/Bottom/BottomBar.tsx new file mode 100644 index 0000000..81de054 --- /dev/null +++ b/packages/calls/src/ui/Bottom/BottomBar.tsx @@ -0,0 +1,167 @@ +import { + ControlBarProps, + useLocalParticipant, + // useLocalParticipantPermissions, + usePersistentUserChoices, + useTrackToggle, +} from '@livekit/components-react'; +import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'; +import { DevicesBar } from '../../../../common.ui/src/ui/shared/DevicesBar/DevicesBar'; +import { useCallback } from 'react'; +import { DisconnectButton } from './DisconnectButton'; +import { ScreenShareButton } from './ScreenShareButton'; +import { WhiteBoardButton } from './WhiteBoardButton'; +import { RaiseHandButton } from './RaiseHandButton'; +import { ChatButton } from './ChatButton'; +import { useCallStore } from 'calls.store'; +import { cn } from '@xipkg/utils'; +import { useNavigate } from '@tanstack/react-router'; +import { WhiteBoard } from '@xipkg/icons'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@xipkg/tooltip'; +import { Button } from '@xipkg/button'; +import { useModeSync } from 'calls.hooks'; +import { useRoom } from '../../providers/RoomProvider'; +import { useCalls } from '../../providers/CallsProvider'; + +export const BottomBar = ({ saveUserChoices = true }: ControlBarProps) => { + const { saveAudioInputEnabled, saveVideoInputEnabled } = usePersistentUserChoices({ + preventSave: !saveUserChoices, + }); + + const { isMicrophoneEnabled, isCameraEnabled, microphoneTrack, cameraTrack } = + useLocalParticipant(); + + // Используем useTrackToggle для правильного управления треками (как в Settings) + const microphoneToggle = useTrackToggle({ + source: Track.Source.Microphone, + onChange: (enabled: boolean, isUserInitiated: boolean) => { + if (isUserInitiated) { + saveAudioInputEnabled(enabled); + } + }, + }); + + const cameraToggle = useTrackToggle({ + source: Track.Source.Camera, + onChange: (enabled: boolean, isUserInitiated: boolean) => { + if (isUserInitiated) { + saveVideoInputEnabled(enabled); + } + }, + }); + + // Обработчики включения/выключения (как в Settings) + const handleMicrophoneToggle = useCallback(async () => { + microphoneToggle.toggle(); + }, [microphoneToggle]); + + const handleCameraToggle = useCallback(async () => { + cameraToggle.toggle(); + }, [cameraToggle]); + + const { isChatOpen, mode, activeBoardId, activeClassroom, token } = useCallStore(); + const updateStore = useCallStore((state) => state.updateStore); + const { room } = useRoom(); + + const { auth } = useCalls(); + const { data: user } = auth.useCurrentUser(); + const isTutor = user?.default_layout === 'tutor'; + + const navigate = useNavigate(); + const { syncModeToOthers } = useModeSync(); + + // Показываем кнопку "обратно к доске" только если: + // 1. Пользователь в full mode + // 2. Есть активная доска (activeBoardId и activeClassroom) + // 3. Комната подключена (чтобы не показывать кнопку при отключении или до подключения) + const showBackToBoardButton = + mode === 'full' && + activeBoardId && + activeClassroom && + room && + token && + room.state === 'connected'; + + const handleBackToBoard = () => { + if (!activeBoardId || !activeClassroom) { + return; + } + + // Проверяем, что комната подключена (чтобы не терять ВКС) + if (!room || !token || room.state !== 'connected') { + return; + } + + // Обновляем режим в store ПЕРЕД навигацией + updateStore('mode', 'compact'); + + // Синхронизируем режим с другими участниками (если был collaborative mode) + syncModeToOthers('compact', activeBoardId, activeClassroom); + + // Переходим на доску с обязательным параметром call для сохранения ВКС + navigate({ + to: '/classrooms/$classroomId/boards/$boardId', + params: { classroomId: activeClassroom, boardId: activeBoardId }, + search: { call: activeClassroom }, + replace: false, // Не заменяем историю, чтобы можно было вернуться + }); + }; + + return ( +
+
+
+
+
+ +
+
+ + {isTutor && } + + +
+
+
+ {showBackToBoardButton && ( + + + + + + Вернуться к доске для совместной работы + + + )} +
+ +
+
+
+
+ ); +}; diff --git a/packages/calls/src/ui/Bottom/ChatButton.tsx b/packages/calls/src/ui/Bottom/ChatButton.tsx new file mode 100644 index 0000000..357ab32 --- /dev/null +++ b/packages/calls/src/ui/Bottom/ChatButton.tsx @@ -0,0 +1,27 @@ +import { Button } from '@xipkg/button'; +import { Chat } from '@xipkg/icons'; +import { useChat } from 'calls.hooks'; +import { useCallStore } from 'calls.store'; + +export const ChatButton = () => { + const { toggleChat } = useChat(); + const { isChatOpen, unreadMessagesCount } = useCallStore(); + + return ( + + ); +}; diff --git a/packages/calls/src/ui/Bottom/DisconnectButton.tsx b/packages/calls/src/ui/Bottom/DisconnectButton.tsx new file mode 100644 index 0000000..4c19a12 --- /dev/null +++ b/packages/calls/src/ui/Bottom/DisconnectButton.tsx @@ -0,0 +1,32 @@ +import { useDisconnectButton } from '@livekit/components-react'; +import { Endcall } from '@xipkg/icons'; +import { useCallStore } from '../../store/callStore'; +import { Button } from '@xipkg/button'; +import { cn } from '@xipkg/utils'; + +export const DisconnectButton = ({ className }: { className?: string }) => { + const { buttonProps } = useDisconnectButton({}); + + const updateStore = useCallStore((state) => state.updateStore); + + const handleDisconnect = () => { + buttonProps.onClick?.(); + updateStore('isStarted', false); + updateStore('connect', false); + }; + + return ( + + ); +}; diff --git a/packages/calls/src/ui/Bottom/RaiseHandButton.tsx b/packages/calls/src/ui/Bottom/RaiseHandButton.tsx new file mode 100644 index 0000000..df0b69b --- /dev/null +++ b/packages/calls/src/ui/Bottom/RaiseHandButton.tsx @@ -0,0 +1,26 @@ +import { Button } from '@xipkg/button'; +import { Hand } from '@xipkg/icons'; +import { useRaisedHands } from '../../hooks/useRaisedHands'; +import { useCallStore } from '../../store/callStore'; +import { cn } from '@xipkg/utils'; + +export const RaiseHandButton = ({ className }: { className?: string }) => { + const { toggleHand } = useRaisedHands(); + const { isHandRaised } = useCallStore(); + + return ( + + ); +}; diff --git a/packages/calls/src/ui/Bottom/ScreenShareButton.tsx b/packages/calls/src/ui/Bottom/ScreenShareButton.tsx new file mode 100644 index 0000000..eee855c --- /dev/null +++ b/packages/calls/src/ui/Bottom/ScreenShareButton.tsx @@ -0,0 +1,44 @@ +import { Track, LocalVideoTrack } from 'livekit-client'; +import { supportsScreenSharing } from '@livekit/components-core'; +import { useTrackToggle } from '@livekit/components-react'; +import { TrackToggle } from '../../../../common.ui/src/ui/shared/TrackToggle/TrackToggle'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@xipkg/tooltip'; + +export const ScreenShareButton = ({ className }: { className?: string }) => { + const visibleControls = { leave: true, screenShare: true }; + const browserSupportsScreenSharing = supportsScreenSharing(); + + const { toggle, enabled, track } = useTrackToggle({ + source: Track.Source.ScreenShare, + captureOptions: { audio: true, selfBrowserSurface: 'include' }, + }); + + const handleScreenShareToggle = (_enabled: boolean, isUserInitiated: boolean) => { + if (isUserInitiated) { + toggle(); + } + }; + + return ( + <> + {visibleControls.screenShare && browserSupportsScreenSharing && ( + + +
+ +
+
+ + {enabled ? 'Остановить показ экрана' : 'Поделиться экраном'} + +
+ )} + + ); +}; diff --git a/packages/calls/src/ui/Bottom/WhiteBoardButton.tsx b/packages/calls/src/ui/Bottom/WhiteBoardButton.tsx new file mode 100644 index 0000000..b800151 --- /dev/null +++ b/packages/calls/src/ui/Bottom/WhiteBoardButton.tsx @@ -0,0 +1,37 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@xipkg/tooltip'; +import { WhiteBoard } from '@xipkg/icons'; +import { useState } from 'react'; +import { WhiteboardsModal } from './WhiteboardsModal'; +import { Button } from '@xipkg/button'; +import { ONBOARDING_IDS } from '../Onboarding/CallsOnboarding'; + +export const WhiteBoardButton = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleClick = () => { + setIsModalOpen(true); + }; + + return ( + <> + + + + + + Выбрать доску для совместной работы + + + + + + ); +}; diff --git a/packages/calls/src/ui/Bottom/WhiteboardsModal.tsx b/packages/calls/src/ui/Bottom/WhiteboardsModal.tsx new file mode 100644 index 0000000..3a9d987 --- /dev/null +++ b/packages/calls/src/ui/Bottom/WhiteboardsModal.tsx @@ -0,0 +1,227 @@ +import { + Modal, + ModalContent, + ModalHeader, + ModalTitle, + ModalFooter, + ModalCloseButton, +} from '@xipkg/modal'; +import { Input } from '@xipkg/input'; +import { Button } from '@xipkg/button'; +import { ScrollArea } from '@xipkg/scrollarea'; +import { Badge } from '@xipkg/badge'; +import { Checkbox } from '@xipkg/checkbox'; +import { useState } from 'react'; +import { Close, Search } from '@xipkg/icons'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { useCallStore } from '../../store'; +import { useModeSync } from '../../hooks'; +import { + useCurrentUser, + useGetClassroomMaterialsList, + useAddClassroomMaterials, +} from 'common.services'; + +// Типы материалов определены в common.types -> ClassroomMaterialsT + +type WhiteboardsModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const WhiteboardsModal = ({ open, onOpenChange }: WhiteboardsModalProps) => { + const navigate = useNavigate(); + const { callId } = useParams({ strict: false }); + const updateStore = useCallStore((state) => state.updateStore); + const { syncModeToOthers } = useModeSync(); + const { data: user } = useCurrentUser(); + const isTutor = user?.default_layout === 'tutor'; + const [searchQuery, setSearchQuery] = useState(''); + const [selectedBoardId, setSelectedBoardId] = useState(null); + const [isCollaborativeMode, setIsCollaborativeMode] = useState(true); + + // Хук для создания новой доски + const { addClassroomMaterials } = useAddClassroomMaterials(); + + // Загружаем список досок кабинета (classroomId == callId) + const { + data: boards, + isLoading, + isError, + } = useGetClassroomMaterialsList({ + classroomId: callId || '', + content_type: 'board', + disabled: !callId || !isTutor, + }); + + const filteredWhiteboards = (boards || []) + // только доски с доступом совместная работа (read_write) + .filter((b) => b.content_kind === 'board' && b.student_access_mode === 'read_write') + // фильтр по поисковой строке + .filter((b) => + searchQuery.trim() ? b.name.toLowerCase().includes(searchQuery.trim().toLowerCase()) : true, + ); + + const handleBoardSelect = (boardId: number) => { + setSelectedBoardId(boardId); + }; + + const handleCreateNewBoard = async () => { + if (!callId) return; + + try { + const result = await addClassroomMaterials.mutateAsync({ + classroomId: callId, + content_kind: 'board', + student_access_mode: 'read_write', // Режим совместного редактирования + }); + + if (result?.data?.id) { + const newBoardId = parseInt(result.data.id); + + // Выбираем новую доску + setSelectedBoardId(newBoardId); + + // Если включен режим совместной работы, отправляем сообщение всем участникам + if (isCollaborativeMode) { + syncModeToOthers('compact', newBoardId.toString(), callId); + } + } + } catch (error) { + console.error('❌ Error creating new board:', error); + } + }; + + const handleConfirm = () => { + if (selectedBoardId) { + // Обновляем локальный режим и сохраняем информацию о доске + updateStore('mode', 'compact'); + updateStore('activeBoardId', selectedBoardId.toString()); + updateStore('activeClassroom', callId); + + // Если включен режим совместной работы, отправляем сообщение всем участникам + if (isCollaborativeMode) { + syncModeToOthers('compact', selectedBoardId.toString(), callId); + } + + // Переходим на доску + if (callId) { + navigate({ + to: '/classrooms/$classroomId/boards/$boardId', + params: { classroomId: callId, boardId: selectedBoardId.toString() }, + search: { call: callId }, + }); + } else { + navigate({ + to: '/board/$boardId', + params: { boardId: selectedBoardId.toString() }, + }); + } + + onOpenChange(false); + } + }; + + return ( + + + + + + + Доска для совместной работы + } + placeholder="Поиск" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + + +
+ {isLoading ? ( +
+

Загрузка досок...

+
+ ) : isError ? ( +
+

Ошибка загрузки досок

+
+ ) : ( + +
+ {filteredWhiteboards.map((board) => ( +
handleBoardSelect(board.id)} + > + {/* Бейдж статуса доступа, как в CardMaterials */} + {board.student_access_mode && ( + + {board.student_access_mode === 'read_write' + ? 'совместная работа' + : board.student_access_mode === 'read_only' + ? 'только репетитор' + : 'черновик'} + + )} +

{board.name}

+

+ Изменено: {new Date(board.updated_at).toLocaleDateString()} +

+
+ ))} +
+

+ {addClassroomMaterials.isPending ? 'Создание...' : 'Создать новую'} +

+
+
+
+ )} +
+ + +
+ setIsCollaborativeMode(checked === true)} + /> + +
+
+ + +
+
+
+
+ ); +}; diff --git a/packages/calls/src/ui/Bottom/index.ts b/packages/calls/src/ui/Bottom/index.ts new file mode 100644 index 0000000..c642f45 --- /dev/null +++ b/packages/calls/src/ui/Bottom/index.ts @@ -0,0 +1 @@ +export { BottomBar } from './BottomBar'; diff --git a/packages/calls/src/ui/Call.tsx b/packages/calls/src/ui/Call.tsx new file mode 100644 index 0000000..1050461 --- /dev/null +++ b/packages/calls/src/ui/Call.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { PreJoin } from './PreJoin'; +import { useCallStore } from 'calls.store'; +import { useInitUserDevices, useVideoSecurity } from 'calls.hooks'; +import { useLocation } from '@tanstack/react-router'; +import './shared/VideoTrack/video-security.css'; +import { CallsProvider } from '../providers'; +import { ActiveRoom } from './Room'; +import { CallsProviderDepsT } from '../providers'; + +export const Call = ({ deps }: { deps: CallsProviderDepsT }) => { + const isStarted = useCallStore((state) => state.isStarted); + + useInitUserDevices(); + useVideoSecurity(); + + const pathname = useLocation().pathname; + const mode = useCallStore((state) => state.mode); + const updateStore = useCallStore((state) => state.updateStore); + + useEffect(() => { + // Проверяем, что мы находимся на странице /call/ (точное совпадение) + const isOnCallPage = /^\/call\/[^/]+$/.test(pathname); + + // Если мы на странице звонка и режим compact, переключаем на full + if (isOnCallPage && mode === 'compact') { + updateStore('mode', 'full'); + } + }, [pathname, mode, updateStore]); + + return ( + +
+
+ {isStarted ? : } +
+
+
+ ); +}; diff --git a/packages/calls/src/ui/Chat/Chat.tsx b/packages/calls/src/ui/Chat/Chat.tsx new file mode 100644 index 0000000..50a5095 --- /dev/null +++ b/packages/calls/src/ui/Chat/Chat.tsx @@ -0,0 +1,136 @@ +import { useState, useRef, useEffect } from 'react'; +import { Button } from '@xipkg/button'; +import { Textarea } from '@xipkg/textarea'; +import { Send, Close } from '@xipkg/icons'; +import { UserProfile } from '@xipkg/userprofile'; +import { ScrollArea } from '@xipkg/scrollarea'; +import { useChat } from '../../hooks/useChat'; +import { useCallStore } from '../../store/callStore'; +import { useCurrentUser } from 'common.services'; +import { cn } from '@xipkg/utils'; + +export const Chat = () => { + const [messageText, setMessageText] = useState(''); + const messagesEndRef = useRef(null); + const { sendChatMessage, closeChat } = useChat(); + const { chatMessages, isChatOpen } = useCallStore(); + const { data: currentUser } = useCurrentUser(); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const textareaRef = useRef(null); + + useEffect(() => { + requestAnimationFrame(scrollToBottom); + }, [chatMessages]); + + const handleSendMessage = () => { + if (messageText.trim()) { + sendChatMessage(messageText); + setMessageText(''); + scrollToBottom(); + } + + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }; + + const handleKeyDownSendMessage = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (!e.shiftKey || e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSendMessage(); + } + }; + + if (!isChatOpen) return null; + + return ( +
+ {/* Заголовок */} +
+

Чат

+ +
+ + {/* Сообщения */} + +
+ {chatMessages.length === 0 ? ( +
+

Начните общение в чате

+
+ ) : ( + chatMessages.map((message) => { + const isOwnMessage = Number(message.senderId) === Number(currentUser?.id); + return ( +
+
+
+ {!isOwnMessage && ( + + )} +
+ {new Date(message.timestamp).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + })} +
+
+
+ {message.text} +
+
+
+ ); + }) + )} +
+
+ + + {/* Поле ввода */} +
+
+