-
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 (
+
+
+ {unreadMessagesCount > 0 && (
+
+ {unreadMessagesCount > 99 ? '99+' : unreadMessagesCount}
+
+ )}
+
+ );
+};
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)}
+ />
+
+ Открыть доску в режиме совместной работы
+
+ для всех участников звонка
+
+
+
+
+ Выбрать
+
+ onOpenChange(false)}>
+ Отменить
+
+
+
+
+
+ );
+};
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 (
+
+
+
+ );
+};
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}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* Поле ввода */}
+
+
+ );
+};
diff --git a/packages/calls/src/ui/CompactView/CompactCall.tsx b/packages/calls/src/ui/CompactView/CompactCall.tsx
new file mode 100644
index 0000000..625e89e
--- /dev/null
+++ b/packages/calls/src/ui/CompactView/CompactCall.tsx
@@ -0,0 +1,261 @@
+import { useCallback } from 'react';
+import { useDraggable } from '@dnd-kit/core';
+import { CSS } from '@dnd-kit/utilities';
+import { DevicesBar } from '../shared';
+import {
+ useLocalParticipant,
+ usePersistentUserChoices,
+ useTrackToggle,
+} from '@livekit/components-react';
+import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client';
+import { DisconnectButton } from '../Bottom/DisconnectButton';
+import { useCompactNavigation } from '../../hooks/useCompactNavigation';
+import { Maximize } from '@xipkg/icons';
+import { Button } from '@xipkg/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@xipkg/tooltip';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@xipkg/dropdown';
+import { useNavigate, useSearch } from '@tanstack/react-router';
+import { useCallStore } from '../../store/callStore';
+import { CompactNavigationControls } from './CompactNavigationControls';
+import { ParticipantTile } from '../Participant';
+import { ScreenShareButton } from '../Bottom/ScreenShareButton';
+import { RaiseHandButton } from '../Bottom/RaiseHandButton';
+import { useVideoBlur, useModeSync } from '../../hooks';
+import { useRoom } from '../../providers/RoomProvider';
+
+export const CompactCall = ({ saveUserChoices = true }) => {
+ const { attributes, listeners, setNodeRef, transform } = useDraggable({
+ id: 'draggable-call',
+ });
+
+ const style = {
+ transform: CSS.Translate.toString(transform),
+ cursor: 'move',
+ };
+
+ const { saveAudioInputEnabled, saveVideoInputEnabled } = usePersistentUserChoices({
+ preventSave: !saveUserChoices,
+ });
+
+ const { isMicrophoneEnabled, isCameraEnabled, microphoneTrack, cameraTrack } =
+ useLocalParticipant();
+
+ const videoTrack = cameraTrack?.track as LocalVideoTrack | undefined;
+
+ // Применяем блюр только в компактном режиме
+ const mode = useCallStore((state) => state.mode);
+ const videoTrackForBlur = mode === 'compact' ? videoTrack : null;
+ useVideoBlur(videoTrackForBlur);
+
+ // Используем useTrackToggle для правильного управления треками (как в BottomBar)
+ 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);
+ }
+ },
+ });
+
+ // Обработчики включения/выключения (как в BottomBar)
+ const handleMicrophoneToggle = useCallback(async () => {
+ microphoneToggle.toggle();
+ }, [microphoneToggle]);
+
+ const handleCameraToggle = useCallback(async () => {
+ cameraToggle.toggle();
+ }, [cameraToggle]);
+
+ // Навигация по участникам (только если есть комната)
+ const navigation = useCompactNavigation();
+ const {
+ currentParticipant,
+ currentIndex,
+ totalParticipants,
+ canGoNext,
+ canGoPrev,
+ goToNext,
+ goToPrev,
+ } = navigation;
+
+ // Безопасно получаем параметры call из URL
+ const search = useSearch({ strict: false }) as { call?: string };
+ const { call } = search;
+
+ const navigate = useNavigate();
+ const updateStore = useCallStore((state) => state.updateStore);
+ const { syncModeToOthers } = useModeSync();
+ const activeBoardId = useCallStore((state) => state.activeBoardId);
+ const activeClassroom = useCallStore((state) => state.activeClassroom);
+ const { room } = useRoom();
+ const token = useCallStore((state) => state.token);
+ const { data: user } = useCurrentUser();
+ const isTutor = user?.default_layout === 'tutor';
+
+ const handleMaximize = (syncToAll: boolean = false) => {
+ // Проверяем, что комната подключена (чтобы не терять ВКС)
+ if (!room || !token || room.state !== 'connected') {
+ return;
+ }
+
+ // Переключаем режим на full
+ updateStore('mode', 'full');
+
+ if (isTutor && activeBoardId && activeClassroom) {
+ if (syncToAll) {
+ // Сохраняем activeClassroom перед очисткой для передачи в сообщении
+ const classroomId = activeClassroom;
+
+ // Если синхронизируем со всеми, очищаем информацию о доске
+ updateStore('activeBoardId', undefined);
+ updateStore('activeClassroom', undefined);
+ // Отправляем сообщение всем участникам о переключении на full (без boardId, но с classroom)
+ // Это сигнал для всех участников, что работа с доской завершена
+ // Передаем classroom, чтобы студенты могли перейти на страницу конференции
+ syncModeToOthers('full', undefined, classroomId);
+ }
+ // Если syncToAll = false, не отправляем сообщение другим участникам
+ // Они останутся на доске, а репетитор переключится только локально
+ }
+
+ // Переходим на страницу конференции с сохранением параметра call
+ navigate({
+ to: '/call/$callId',
+ params: { callId: call ?? activeClassroom ?? '' },
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ search: call || activeClassroom ? { call: call || activeClassroom } : undefined,
+ });
+ };
+
+ return (
+
+ {/* Видео текущего участника */}
+
+ {currentParticipant ? (
+
+ ) : (
+
+ Нет участников
+
+ )}
+
+ {/* Элементы управления навигацией - только если есть участники */}
+ {totalParticipants > 0 && (
+
+ )}
+
+
+
+
+
+
+
+ {/* */}
+
+
+
+ {isTutor ? (
+
+
+
+
+
+
+
+
+
+ Вернуться в конференцию
+
+
+
+ Вернуть в конференцию
+
+ handleMaximize(false)}
+ className="text-gray-80 cursor-pointer text-sm"
+ >
+ Только меня
+
+ handleMaximize(true)}
+ className="text-gray-80 cursor-pointer text-sm"
+ >
+ Всех участников
+
+
+
+ ) : (
+
+
+ handleMaximize(false)}
+ className="hover:bg-gray-5 relative m-0 h-8 w-8 rounded-xl p-0 text-gray-100"
+ >
+
+
+
+ Вернуться в конференцию
+
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/calls/src/ui/CompactView/CompactNavigationControls.tsx b/packages/calls/src/ui/CompactView/CompactNavigationControls.tsx
new file mode 100644
index 0000000..dc01358
--- /dev/null
+++ b/packages/calls/src/ui/CompactView/CompactNavigationControls.tsx
@@ -0,0 +1,77 @@
+import { ArrowLeft, ArrowRight } from '@xipkg/icons';
+
+interface CompactNavigationControlsProps {
+ canPrev: boolean;
+ canNext: boolean;
+ onPrev: () => void;
+ onNext: () => void;
+ currentIndex: number;
+ totalParticipants: number;
+}
+
+/**
+ * Элементы управления навигацией для компактного вида
+ * Показывает кнопки навигации и индикаторы участников только при наведении
+ */
+export function CompactNavigationControls({
+ canPrev,
+ canNext,
+ onPrev,
+ onNext,
+ currentIndex,
+ totalParticipants,
+}: CompactNavigationControlsProps) {
+ if (totalParticipants <= 1) {
+ return null;
+ }
+
+ return (
+
+ {/* Индикатор участников вверху */}
+
+
+ {Array.from({ length: totalParticipants }, (_, index) => (
+ {
+ // Здесь можно добавить логику для прямого перехода к участнику
+ // Пока оставляем только навигацию через кнопки
+ }}
+ className={`h-1.5 w-1.5 rounded-full transition-colors ${
+ index === currentIndex ? 'bg-gray-100' : 'bg-gray-100/50 hover:bg-gray-100/75'
+ }`}
+ aria-label={`Участник ${index + 1}`}
+ />
+ ))}
+
+
+
+ {/* Кнопка "Предыдущий участник" */}
+
+
+
+ Предыдущий участник
+
+
+
+ {/* Кнопка "Следующий участник" */}
+
+
+
+ Следующий участник
+
+
+
+ );
+}
diff --git a/packages/calls/src/ui/CompactView/CompactView.tsx b/packages/calls/src/ui/CompactView/CompactView.tsx
new file mode 100644
index 0000000..979d039
--- /dev/null
+++ b/packages/calls/src/ui/CompactView/CompactView.tsx
@@ -0,0 +1,194 @@
+import { FC, useEffect, useState } from 'react';
+import {
+ DndContext,
+ DragEndEvent,
+ useSensor,
+ useSensors,
+ PointerSensor,
+ useDroppable,
+ DragOverlay,
+ useDndMonitor,
+} from '@dnd-kit/core';
+import { restrictToWindowEdges } from '@dnd-kit/modifiers';
+import { RoomAudioRenderer } from '@livekit/components-react';
+import { CompactCall } from './CompactCall';
+import { useCallStore } from '../../store/callStore';
+import type { Corner } from '../../store/callStore';
+import { useNavigate, useRouter, useSearch, useLocation } from '@tanstack/react-router';
+import { useRoom } from '../../providers/RoomProvider';
+import { useParticipantJoinSync } from '../../hooks/useParticipantJoinSync';
+
+type CompactViewProps = {
+ children: React.ReactNode;
+};
+
+const DroppableCorner = ({ id, className }: { id: string; className: string }) => {
+ const { setNodeRef } = useDroppable({
+ id,
+ });
+ const [isDragging, setIsDragging] = useState(false);
+
+ useDndMonitor({
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDragEnd: () => {
+ setIsDragging(false);
+ },
+ });
+
+ return (
+
+ );
+};
+
+const DroppableAreas: FC = () => {
+ const [isDragging, setIsDragging] = useState(false);
+
+ useDndMonitor({
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDragEnd: () => {
+ setIsDragging(false);
+ },
+ });
+
+ return (
+ <>
+
+
+
+
+ {isDragging ? : null}
+ >
+ );
+};
+
+export const Compact: FC = ({ children }) => {
+ const router = useRouter();
+ const { activeCorner, updateStore } = useCallStore();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { over } = event;
+ if (over) {
+ updateStore('activeCorner', over.id as Corner);
+ }
+ };
+
+ const getCornerPosition = (corner: Corner) => {
+ const isBoardPage = router.state.location.pathname.includes('/board');
+ const bottomOffset = isBoardPage && corner === 'bottom-right' ? 'bottom-[72px]' : 'bottom-4';
+
+ switch (corner) {
+ case 'top-left':
+ return 'top-4 left-4';
+ case 'top-right':
+ return 'top-4 right-4';
+ case 'bottom-left':
+ return `${bottomOffset} left-4`;
+ case 'bottom-right':
+ return `${bottomOffset} right-4`;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+ {/* Чат в режиме compact */}
+ {/* {isChatOpen && (
+
+ )} */}
+
+ {/* Обработка аудио как в основном режиме ВКС */}
+
+
+
+ );
+};
+
+export const CompactView = ({ children }: CompactViewProps) => {
+ const { mode } = useCallStore();
+ const { room } = useRoom();
+ const { token } = useCallStore();
+
+ // Синхронизация состояния при подключении новых участников (работает и в compact mode)
+ useParticipantJoinSync();
+
+ const search = useSearch({ strict: false }) as { call?: string };
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ // Очищаем URL параметр call, только если комната действительно отключена
+ // Не очищаем при навигации, если есть activeClassroom в store (комната может быть в процессе подключения)
+ useEffect(() => {
+ const { activeClassroom } = useCallStore.getState();
+ const isOnBoardPage = location.pathname.includes('/board');
+
+ // Очищаем только если:
+ // 1. Комната и токен отсутствуют
+ // 2. НЕ находимся на странице доски (чтобы не очищать при навигации на доску)
+ // 3. НЕТ activeClassroom в store (комната действительно отключена, а не в процессе подключения)
+ if ((!room || !token) && search.call && !isOnBoardPage && !activeClassroom) {
+ const searchWithoutCall = { ...search };
+ delete searchWithoutCall.call;
+ navigate({
+ to: location.pathname,
+ search: searchWithoutCall,
+ replace: true,
+ });
+
+ // Очищаем все состояния интерфейса при отключении
+ const { clearAllRaisedHands, updateStore: updateCallStore } = useCallStore.getState();
+
+ // Очищаем поднятые руки
+ clearAllRaisedHands();
+
+ // Очищаем чат
+ updateCallStore('isChatOpen', false);
+ updateCallStore('chatMessages', []);
+ updateCallStore('unreadMessagesCount', 0);
+
+ // Очищаем информацию о доске при отключении
+ updateCallStore('activeBoardId', undefined);
+ updateCallStore('activeClassroom', undefined);
+
+ updateCallStore('mode', 'full');
+ }
+ }, [room, token, search.call, search, navigate]);
+
+ if (!room || !token) {
+ return <>{children}>;
+ }
+
+ if (mode === 'full') return <>{children}>;
+
+ return {children} ;
+};
diff --git a/packages/calls/src/ui/CompactView/index.ts b/packages/calls/src/ui/CompactView/index.ts
new file mode 100644
index 0000000..6410004
--- /dev/null
+++ b/packages/calls/src/ui/CompactView/index.ts
@@ -0,0 +1,3 @@
+export { CompactView } from './CompactView';
+export { CompactCall } from './CompactCall';
+export { CompactNavigationControls } from './CompactNavigationControls';
diff --git a/packages/calls/src/ui/Onboarding/CallsOnboarding.tsx b/packages/calls/src/ui/Onboarding/CallsOnboarding.tsx
new file mode 100644
index 0000000..2d5fdfd
--- /dev/null
+++ b/packages/calls/src/ui/Onboarding/CallsOnboarding.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useRef } from 'react';
+import { driver, type DriveStep } from 'driver.js';
+import 'driver.js/dist/driver.css';
+import 'common.ui/utils/driver.css';
+import { createRoot } from 'react-dom/client';
+import { Close } from '@xipkg/icons';
+import { useCallStore } from '../../store/callStore';
+
+const ONBOARDING_STORAGE_KEY = 'calls_onboarding_completed';
+
+// ID элементов для онбординга
+export const ONBOARDING_IDS = {
+ WHITEBOARD_BUTTON: 'calls-onboarding-whiteboard-button',
+ LINK_BUTTON: 'calls-onboarding-link-button',
+ SETTINGS_BUTTON: 'calls-onboarding-settings-button',
+ BACK_BUTTON: 'calls-onboarding-back-button',
+} as const;
+
+export const CallsOnboarding = () => {
+ const isStarted = useCallStore((state) => state.isStarted);
+ const hasStartedOnboarding = useRef(false);
+
+ useEffect(() => {
+ // Проверяем, был ли уже пройден онбординг
+ const onboardingCompleted = localStorage.getItem(ONBOARDING_STORAGE_KEY);
+ if (onboardingCompleted === 'true') {
+ return;
+ }
+
+ // Проверяем, подключен ли пользователь к конференции
+ if (!isStarted || hasStartedOnboarding.current) {
+ return;
+ }
+
+ const startOnboarding = () => {
+ const steps: DriveStep[] = [
+ {
+ element: `#${ONBOARDING_IDS.WHITEBOARD_BUTTON}`,
+ popover: {
+ description:
+ 'Пишите или рисуйте на онлайн-доске во время видеозвонка. Подготовьте доску заранее или создайте чистую',
+ side: 'top' as const,
+ align: 'center' as const,
+ },
+ },
+ {
+ element: `#${ONBOARDING_IDS.LINK_BUTTON}`,
+ popover: {
+ description:
+ 'Скопируйте ссылку на конференцию и отправьте ученикам. Присоединиться могут только участники этого кабинета.',
+ side: 'bottom' as const,
+ align: 'end' as const,
+ },
+ },
+ {
+ element: `#${ONBOARDING_IDS.SETTINGS_BUTTON}`,
+ popover: {
+ description: 'Настройте аудио и видео устройства, а также другие параметры конференции',
+ side: 'bottom' as const,
+ align: 'end' as const,
+ },
+ },
+ {
+ element: `#${ONBOARDING_IDS.BACK_BUTTON}`,
+ popover: {
+ description:
+ 'Можно перемещаться по платформе в любой момент, не прерывая звонок. Нажмите на стрелку или любой пункт меню, а конференция продолжится в компактном режиме',
+ side: 'bottom' as const,
+ align: 'start' as const,
+ },
+ },
+ ].filter((step) => {
+ // Фильтруем шаги, для которых элементы не найдены
+ const element = document.querySelector(step.element as string);
+ return element !== null;
+ });
+
+ // Проверяем наличие хотя бы одного элемента
+ if (steps.length === 0) {
+ console.warn('Элементы для онбординга не найдены');
+ return;
+ }
+
+ const driverObj = driver({
+ popoverClass: 'my-custom-popover-class',
+ showProgress: true,
+ steps: steps,
+ onPopoverRender: (popover) => {
+ const defaultCloseButton = popover.closeButton;
+ const customCloseButton = document.createElement('button');
+ customCloseButton.className = 'driver-popover-close-btn';
+
+ // Создаем корень для рендеринга компонента
+ const root = createRoot(customCloseButton);
+ root.render( );
+
+ defaultCloseButton.replaceWith(customCloseButton);
+ customCloseButton.addEventListener('click', () => {
+ driverObj.destroy();
+ });
+ },
+ nextBtnText: 'Продолжить',
+ prevBtnText: 'Назад',
+ doneBtnText: 'Завершить',
+ progressText: '{{current}} из {{total}}',
+ onDestroyed: () => {
+ // Сохраняем флаг завершения онбординга в localStorage
+ localStorage.setItem(ONBOARDING_STORAGE_KEY, 'true');
+ },
+ });
+
+ driverObj.drive();
+ hasStartedOnboarding.current = true;
+ };
+
+ let onboardingTimeoutId: ReturnType | null = null;
+
+ // Проверяем наличие элементов перед запуском онбординга
+ const checkElements = () => {
+ const settingsButton = document.querySelector(`#${ONBOARDING_IDS.SETTINGS_BUTTON}`);
+
+ // Минимально нужна кнопка Settings (она есть у всех)
+ if (settingsButton) {
+ // Добавляем задержку в 2 секунды перед запуском онбординга
+ onboardingTimeoutId = setTimeout(() => {
+ startOnboarding();
+ }, 2000);
+ return true;
+ }
+ return false;
+ };
+
+ // Сначала пытаемся сразу запустить
+ let checkTimeoutId: ReturnType | null = null;
+ if (!checkElements()) {
+ // Если элементы еще не отрендерились, ждем немного
+ checkTimeoutId = setTimeout(() => {
+ checkElements();
+ }, 500);
+ }
+
+ return () => {
+ if (checkTimeoutId) {
+ clearTimeout(checkTimeoutId);
+ }
+ if (onboardingTimeoutId) {
+ clearTimeout(onboardingTimeoutId);
+ }
+ };
+ }, [isStarted]);
+
+ return null;
+};
diff --git a/packages/calls/src/ui/Onboarding/index.ts b/packages/calls/src/ui/Onboarding/index.ts
new file mode 100644
index 0000000..ebdf0b1
--- /dev/null
+++ b/packages/calls/src/ui/Onboarding/index.ts
@@ -0,0 +1 @@
+export { CallsOnboarding, ONBOARDING_IDS } from './CallsOnboarding';
diff --git a/packages/calls/src/ui/Participant/ParticipantName.tsx b/packages/calls/src/ui/Participant/ParticipantName.tsx
new file mode 100644
index 0000000..e145ea1
--- /dev/null
+++ b/packages/calls/src/ui/Participant/ParticipantName.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { Participant } from 'livekit-client';
+
+type ParticipantNamePropsT = {
+ participant?: Participant;
+ id?: string | undefined; // Оставляем для обратной совместимости
+ username?: string | undefined; // Оставляем для обратной совместимости
+ children?: React.ReactNode;
+};
+
+export const ParticipantName = ({ participant, id, username, children }: ParticipantNamePropsT) => {
+ // Получаем мета-информацию участника из LiveKit
+ const getParticipantInfo = () => {
+ if (participant) {
+ try {
+ // Парсим метаданные участника
+ const metadata = participant.metadata;
+ if (metadata) {
+ const userInfo = JSON.parse(metadata);
+ return {
+ displayName: userInfo?.display_name || userInfo?.name || userInfo?.username,
+ userId: userInfo?.user_id || userInfo?.id,
+ };
+ }
+ } catch (error) {
+ console.warn('⚠️ Failed to parse participant metadata:', error);
+ }
+
+ // Если метаданные недоступны, используем стандартные поля LiveKit
+ return {
+ displayName: participant.name || participant.identity,
+ userId: participant.identity,
+ };
+ }
+
+ // Fallback для обратной совместимости
+ return {
+ displayName: username,
+ userId: id,
+ };
+ };
+
+ const { displayName } = getParticipantInfo();
+
+ // Если есть participant, но нет displayName, показываем загрузку
+ if (participant && !displayName) {
+ return ;
+ }
+
+ return (
+
+ {children}
+ {displayName || 'Unknown'}
+
+ );
+};
diff --git a/packages/calls/src/ui/Participant/ParticipantTile.tsx b/packages/calls/src/ui/Participant/ParticipantTile.tsx
new file mode 100644
index 0000000..45c42d8
--- /dev/null
+++ b/packages/calls/src/ui/Participant/ParticipantTile.tsx
@@ -0,0 +1,271 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from 'react';
+import { Participant, Track } from 'livekit-client';
+import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
+import { isTrackReference, isTrackReferencePinned } from '@livekit/components-core';
+import {
+ AudioTrack,
+ ConnectionQualityIndicator,
+ LockLockedIcon,
+ ParticipantContextIfNeeded,
+ ParticipantTileProps,
+ ScreenShareIcon,
+ TrackMutedIndicatorProps,
+ TrackRefContext,
+ useEnsureParticipant,
+ useFeatureContext,
+ useIsEncrypted,
+ useMaybeLayoutContext,
+ useMaybeTrackRefContext,
+ useParticipantTile,
+ useTrackMutedIndicator,
+ useParticipantInfo,
+} from '@livekit/components-react';
+import { MicrophoneOff, RedLine } from '@xipkg/icons';
+import { VideoTrack } from '../../../../common.ui/src/ui/shared';
+import { Avatar, AvatarFallback, AvatarImage } from '@xipkg/avatar';
+import { FocusToggle } from '../../../../common.ui/src/ui/shared/FocusToggle';
+import { ParticipantName } from './ParticipantName';
+import { RaisedHandIndicator } from './RaisedHandIndicator';
+
+type TrackRefContextIfNeededPropsT = {
+ trackRef?: TrackReferenceOrPlaceholder;
+ children?: React.ReactNode;
+};
+
+const TrackRefContextIfNeeded = ({ trackRef, children }: TrackRefContextIfNeededPropsT) => {
+ const hasContext = !!useMaybeTrackRefContext();
+ return trackRef && !hasContext ? (
+ {children}
+ ) : (
+ children
+ );
+};
+
+export const TrackMutedIndicator = ({
+ trackRef,
+ show = 'always',
+ ...props
+}: TrackMutedIndicatorProps) => {
+ const { isMuted } = useTrackMutedIndicator(trackRef);
+
+ const showIndicator =
+ show === 'always' || (show === 'muted' && isMuted) || (show === 'unmuted' && !isMuted);
+
+ if (!showIndicator) {
+ return null;
+ }
+
+ return (
+
+ {(props.children ?? isMuted) ? (
+
+
+
+
+ ) : null}
+
+ );
+};
+
+type FocusToggleDisablePropsT = {
+ isFocusToggleDisable?: boolean;
+};
+
+type ParticipantTilePropsT = ParticipantTileProps &
+ FocusToggleDisablePropsT & {
+ participant?: Participant;
+ source?: Track.Source;
+ publication?: unknown;
+ };
+
+export const ParticipantTile = ({
+ trackRef,
+ participant,
+ children,
+ source = Track.Source.Camera,
+ onParticipantClick,
+ publication,
+ disableSpeakingIndicator,
+ isFocusToggleDisable,
+ ...htmlProps
+}: ParticipantTilePropsT) => {
+ const maybeTrackRef = useMaybeTrackRefContext();
+ const p = useEnsureParticipant(participant);
+
+ const trackReference: TrackReferenceOrPlaceholder = React.useMemo(
+ () => ({
+ participant: trackRef?.participant ?? maybeTrackRef?.participant ?? p,
+ source: trackRef?.source ?? maybeTrackRef?.source ?? source,
+ publication: trackRef?.publication ?? maybeTrackRef?.publication ?? (publication as any),
+ }),
+ [maybeTrackRef, p, publication, source, trackRef],
+ );
+
+ const { identity } = useParticipantInfo({ participant: trackReference.participant });
+
+ // Принудительное обновление при изменении трека
+ const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
+
+ // Отслеживаем изменения состояния трека
+ React.useEffect(() => {
+ if (trackReference.publication) {
+ const handleTrackChanged = () => {
+ forceUpdate();
+ };
+
+ // Слушаем изменения трека
+ trackReference.publication.track?.on('muted', handleTrackChanged);
+ trackReference.publication.track?.on('unmuted', handleTrackChanged);
+
+ // Слушаем изменения публикации
+ trackReference.publication.on('subscribed', handleTrackChanged);
+ trackReference.publication.on('unsubscribed', handleTrackChanged);
+
+ return () => {
+ trackReference.publication.track?.off('muted', handleTrackChanged);
+ trackReference.publication.track?.off('unmuted', handleTrackChanged);
+ trackReference.publication.off('subscribed', handleTrackChanged);
+ trackReference.publication.off('unsubscribed', handleTrackChanged);
+ };
+ }
+ }, [trackReference.publication]);
+
+ const { elementProps } = useParticipantTile({
+ htmlProps,
+ disableSpeakingIndicator,
+ onParticipantClick,
+ trackRef: trackReference,
+ });
+ const isEncrypted = useIsEncrypted(p);
+ const layoutContext = useMaybeLayoutContext();
+
+ const autoManageSubscription = useFeatureContext()?.autoSubscription;
+
+ const handleSubscribe = React.useCallback(
+ (subscribed: boolean) => {
+ if (
+ trackReference.source &&
+ !subscribed &&
+ layoutContext?.pin.dispatch &&
+ isTrackReferencePinned(trackReference, layoutContext.pin.state)
+ ) {
+ layoutContext.pin.dispatch({ msg: 'clear_pin' });
+ }
+ },
+ [trackReference, layoutContext],
+ );
+
+ return (
+
+
+
+
+ {children ?? (
+
+ {/* Аватар всегда рендерится как фон */}
+
+ {/* Видео накладывается поверх аватара когда доступно */}
+ {isTrackReference(trackReference) &&
+ (trackReference.publication?.kind === 'video' ||
+ trackReference.source === Track.Source.Camera ||
+ trackReference.source === Track.Source.ScreenShare) &&
+ trackReference.publication?.isSubscribed &&
+ trackReference.publication?.isEnabled &&
+ !trackReference.publication?.track?.isMuted && (
+
+
+
+ )}
+ {/* Аудио трек для случаев без видео */}
+ {isTrackReference(trackReference) &&
+ (!trackReference.publication?.isSubscribed ||
+ trackReference.publication?.kind !== 'video' ||
+ trackReference.publication?.track?.isMuted) && (
+
+ )}
+
+
+ {trackReference.source === Track.Source.Camera ? (
+
+ {isEncrypted &&
}
+
+
+
+ ) : (
+
+
+
+ Демонстрация
+
+ {/* Индикатор поднятой руки в метаданных */}
+
+ )}
+
+
+
+
+ )}
+
+
+ {/* Индикатор поднятой руки в верхнем правом углу - скрываем для ScreenShare */}
+ {trackReference.source !== Track.Source.ScreenShare && (
+
+
+
+ )}
+
+ {isFocusToggleDisable ? null : (
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/calls/src/ui/Participant/RaisedHandIndicator.tsx b/packages/calls/src/ui/Participant/RaisedHandIndicator.tsx
new file mode 100644
index 0000000..32d1e70
--- /dev/null
+++ b/packages/calls/src/ui/Participant/RaisedHandIndicator.tsx
@@ -0,0 +1,32 @@
+import { Hand } from '@xipkg/icons';
+import { useCallStore } from '../../store/callStore';
+
+type RaisedHandIndicatorProps = {
+ participantId: string;
+ compact?: boolean; // Для компактного отображения в метаданных
+};
+
+export const RaisedHandIndicator = ({
+ participantId,
+ compact = false,
+}: RaisedHandIndicatorProps) => {
+ const { isHandRaisedByParticipant } = useCallStore();
+
+ const isHandRaised = isHandRaisedByParticipant(participantId);
+
+ if (!isHandRaised) return null;
+
+ if (compact) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/calls/src/ui/Participant/index.ts b/packages/calls/src/ui/Participant/index.ts
new file mode 100644
index 0000000..271cb84
--- /dev/null
+++ b/packages/calls/src/ui/Participant/index.ts
@@ -0,0 +1,2 @@
+export { ParticipantName } from './ParticipantName';
+export { ParticipantTile } from './ParticipantTile';
diff --git a/packages/calls/src/ui/PreJoin/PreJoin.tsx b/packages/calls/src/ui/PreJoin/PreJoin.tsx
new file mode 100644
index 0000000..551ebbf
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/PreJoin.tsx
@@ -0,0 +1,211 @@
+import { ScrollArea } from '@xipkg/scrollarea';
+import { Header, UserTile, MediaDevices } from './components';
+import { PermissionsDialog } from '../../../../common.ui/src/ui/shared/PermissionsDialog';
+import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
+import {
+ Track,
+ LocalVideoTrack,
+ LocalAudioTrack,
+ createLocalVideoTrack,
+ createLocalAudioTrack,
+} from 'livekit-client';
+import { usePreviewTracks } from '@livekit/components-react';
+import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices';
+import { useResolveInitiallyDefaultDeviceId } from '../../hooks/useResolveInitiallyDefaultDeviceId';
+import { useVideoBlur } from '../../hooks';
+
+export const PreJoin = () => {
+ const {
+ userChoices: { audioEnabled, videoEnabled, audioDeviceId, videoDeviceId },
+ saveAudioInputDeviceId,
+ saveVideoInputDeviceId,
+ } = usePersistentUserChoices();
+
+ const initialUserChoices = useRef<{
+ audioEnabled: boolean;
+ videoEnabled: boolean;
+ audioDeviceId: string;
+ videoDeviceId: string;
+ } | null>(null);
+
+ // Сохраняем начальные настройки пользователя
+ if (initialUserChoices.current === null) {
+ initialUserChoices.current = {
+ audioEnabled,
+ videoEnabled,
+ audioDeviceId,
+ videoDeviceId,
+ };
+ }
+
+ const onError = useCallback((e: Error) => {
+ console.error('PreJoin ERROR:', e);
+ }, []);
+
+ // Автоматически запрашиваем разрешения при загрузке
+ useEffect(() => {
+ const requestPermissions = async () => {
+ try {
+ // Проверяем, есть ли уже разрешения
+ const permissions = await navigator.permissions.query({ name: 'camera' as PermissionName });
+ if (permissions.state === 'prompt') {
+ // Запрашиваем разрешения
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: true,
+ });
+ // Останавливаем поток, нам нужны только разрешения
+ stream.getTracks().forEach((track) => track.stop());
+ }
+ } catch (error) {
+ console.log('Permission request failed:', error);
+ }
+ };
+
+ requestPermissions();
+ }, []);
+
+ // Preview треки - создаются только если пользователь изначально включил их
+ const tracks = usePreviewTracks(
+ {
+ audio: !!initialUserChoices.current &&
+ initialUserChoices.current?.audioEnabled && {
+ deviceId: initialUserChoices.current.audioDeviceId,
+ },
+ video: !!initialUserChoices.current &&
+ initialUserChoices.current?.videoEnabled && {
+ deviceId: initialUserChoices.current.videoDeviceId,
+ },
+ },
+ onError,
+ );
+
+ // Динамические треки - создаются "just-in-time" когда пользователь включает их
+ const [dynamicVideoTrack, setDynamicVideoTrack] = useState(null);
+ const [dynamicAudioTrack, setDynamicAudioTrack] = useState(null);
+
+ const previewVideoTrack = useMemo(
+ () => tracks?.filter((track) => track.kind === Track.Kind.Video)[0] as LocalVideoTrack,
+ [tracks],
+ );
+
+ const previewAudioTrack = useMemo(
+ () => tracks?.filter((track) => track.kind === Track.Kind.Audio)[0] as LocalAudioTrack,
+ [tracks],
+ );
+
+ // Создаем динамический видео трек если пользователь включил камеру после загрузки
+ useEffect(() => {
+ const createVideoTrack = async () => {
+ try {
+ const track = await createLocalVideoTrack({
+ deviceId: { exact: videoDeviceId },
+ });
+ setDynamicVideoTrack(track);
+ } catch (error) {
+ onError(error as Error);
+ }
+ };
+
+ if (
+ videoEnabled &&
+ !initialUserChoices.current?.videoEnabled &&
+ !previewVideoTrack &&
+ !dynamicVideoTrack
+ ) {
+ createVideoTrack();
+ }
+ }, [videoEnabled, videoDeviceId, previewVideoTrack, dynamicVideoTrack, onError]);
+
+ // Создаем динамический аудио трек если пользователь включил микрофон после загрузки
+ useEffect(() => {
+ const createAudioTrack = async () => {
+ try {
+ const track = await createLocalAudioTrack({
+ deviceId: { exact: audioDeviceId },
+ });
+ setDynamicAudioTrack(track);
+ } catch (error) {
+ onError(error as Error);
+ }
+ };
+
+ if (
+ audioEnabled &&
+ !initialUserChoices.current?.audioEnabled &&
+ !previewAudioTrack &&
+ !dynamicAudioTrack
+ ) {
+ createAudioTrack();
+ }
+ }, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack, onError]);
+
+ // Очистка динамических треков
+ useEffect(() => {
+ return () => {
+ dynamicVideoTrack?.stop();
+ };
+ }, [dynamicVideoTrack]);
+
+ useEffect(() => {
+ return () => {
+ dynamicAudioTrack?.stop();
+ };
+ }, [dynamicAudioTrack]);
+
+ // Финальные треки (динамические имеют приоритет над preview)
+ const videoTrack = dynamicVideoTrack || previewVideoTrack;
+ const audioTrack = dynamicAudioTrack || previewAudioTrack;
+
+ // Отладочная информация
+ useEffect(() => {
+ console.log('PreJoin tracks debug:', {
+ initialUserChoices: initialUserChoices.current,
+ videoEnabled,
+ audioEnabled,
+ videoDeviceId,
+ audioDeviceId,
+ tracks: tracks?.map((t) => ({ kind: t.kind, enabled: !t.isMuted })),
+ previewVideoTrack: previewVideoTrack ? { enabled: !previewVideoTrack.isMuted } : null,
+ previewAudioTrack: previewAudioTrack ? { enabled: !previewAudioTrack.isMuted } : null,
+ dynamicVideoTrack: dynamicVideoTrack ? { enabled: !dynamicVideoTrack.isMuted } : null,
+ dynamicAudioTrack: dynamicAudioTrack ? { enabled: !dynamicAudioTrack.isMuted } : null,
+ finalVideoTrack: videoTrack ? { enabled: !videoTrack.isMuted } : null,
+ finalAudioTrack: audioTrack ? { enabled: !audioTrack.isMuted } : null,
+ });
+ }, [
+ tracks,
+ previewVideoTrack,
+ previewAudioTrack,
+ dynamicVideoTrack,
+ dynamicAudioTrack,
+ videoTrack,
+ audioTrack,
+ videoEnabled,
+ audioEnabled,
+ videoDeviceId,
+ audioDeviceId,
+ ]);
+
+ // Разрешаем device ID для треков
+ useResolveInitiallyDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId);
+ useResolveInitiallyDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId);
+
+ // Передаем видеотрек для использования блюра
+ useVideoBlur(videoTrack);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/packages/calls/src/ui/PreJoin/components/Header/Header.tsx b/packages/calls/src/ui/PreJoin/components/Header/Header.tsx
new file mode 100644
index 0000000..d265eca
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/Header/Header.tsx
@@ -0,0 +1,42 @@
+import { useNavigate, useParams } from '@tanstack/react-router';
+import { Button } from '@xipkg/button';
+import { ArrowLeft } from '@xipkg/icons';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@xipkg/tooltip';
+import { useCalls } from '../../../../providers/CallsProvider';
+
+/* eslint-disable no-irregular-whitespace */
+export const Header = () => {
+ const navigate = useNavigate();
+ const { callId } = useParams({ strict: false }) as { callId: string };
+
+ const { room } = useCalls();
+
+ const { data: classroom } = room.useGetClassroom(Number(callId));
+
+ return (
+
+
+
+ {
+ navigate({
+ to: '/classrooms/$classroomId',
+ params: { classroomId: callId },
+ });
+ }}
+ type="button"
+ variant="ghost"
+ className="flex size-[40px] items-center justify-center rounded-[12px] p-0"
+ >
+
+
+
+
+ Вернуться в кабинет
+
+
+
Присоединиться к занятию
+
{classroom?.name}
+
+ );
+};
diff --git a/packages/calls/src/ui/PreJoin/components/Header/index.ts b/packages/calls/src/ui/PreJoin/components/Header/index.ts
new file mode 100644
index 0000000..29429dc
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/Header/index.ts
@@ -0,0 +1 @@
+export { Header } from './Header';
diff --git a/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceMenu.tsx b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceMenu.tsx
new file mode 100644
index 0000000..172edfb
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceMenu.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { computeMenuPosition, wasClickOutside } from '@livekit/components-core';
+import { Select, SelectContent, SelectGroup, SelectTrigger, SelectValue } from '@xipkg/select';
+import { Conference, Microphone, SoundTwo } from '@xipkg/icons';
+import { useMediaDeviceSelect } from '@livekit/components-react';
+import { MediaDeviceKind, MediaDeviceSelect } from './MediaDeviceSelect';
+
+const placeholders = {
+ audioinput: 'Встроенный микрофон',
+ audiooutput: 'Встроенные динамики',
+ videoinput: 'Встроенная камера',
+ default: 'По умолчанию',
+};
+
+export interface MediaDeviceMenuProps extends React.ButtonHTMLAttributes {
+ disabled?: boolean;
+ kind: MediaDeviceKind;
+ initialSelection: string | undefined;
+ onActiveDeviceChange?: (kind: MediaDeviceKind, deviceId: string) => void;
+ warnDisable?: boolean;
+ requestPermissions?: boolean;
+}
+
+export const MediaDeviceMenu = ({
+ warnDisable,
+ kind,
+ initialSelection,
+ onActiveDeviceChange,
+ disabled,
+ requestPermissions = false,
+}: MediaDeviceMenuProps) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [updateRequired, setUpdateRequired] = React.useState(true);
+ const [, setNeedPermissions] = React.useState(requestPermissions);
+ const button = React.useRef(null);
+ const tooltip = React.useRef(null);
+
+ const handleError = React.useCallback((e: Error) => {
+ console.error('Media device error:', e);
+ }, []);
+ const { devices, setActiveMediaDevice } = useMediaDeviceSelect({
+ kind,
+ room: undefined, // Для PreJoin не нужна комната
+ requestPermissions,
+ onError: handleError,
+ });
+
+ React.useLayoutEffect(() => {
+ if (isOpen) {
+ setNeedPermissions(true);
+ }
+ }, [isOpen]);
+
+ React.useLayoutEffect(() => {
+ if (button.current && tooltip.current && (devices || updateRequired)) {
+ const handlePositionChange = (x: number, y: number) => {
+ if (tooltip.current) {
+ tooltip.current.style.left = `${x}px`;
+ tooltip.current.style.top = `${y}px`;
+ }
+ };
+
+ computeMenuPosition(button.current, tooltip.current, handlePositionChange);
+ }
+ setUpdateRequired(false);
+ }, [button, tooltip, updateRequired, devices]);
+
+ const handleClickOutside = React.useCallback(
+ (event: MouseEvent) => {
+ if (!tooltip.current) {
+ return;
+ }
+ if (event.target === button.current) {
+ return;
+ }
+ if (isOpen && wasClickOutside(tooltip.current, event)) {
+ setIsOpen(false);
+ }
+ },
+ [isOpen, tooltip, button],
+ );
+
+ React.useEffect(() => {
+ document.addEventListener<'click'>('click', handleClickOutside);
+ window.addEventListener<'resize'>('resize', () => setUpdateRequired(true));
+ return () => {
+ document.removeEventListener<'click'>('click', handleClickOutside);
+ window.removeEventListener<'resize'>('resize', () => setUpdateRequired(true));
+ };
+ }, [handleClickOutside, setUpdateRequired]);
+
+ const getPlaceholder = () => {
+ if (initialSelection === '') return placeholders.default;
+ if (!initialSelection && kind) {
+ return placeholders[kind] || placeholders.default;
+ }
+ return placeholders.default;
+ };
+ async function handleActiveChange(deviceId: string, kind: MediaDeviceKind) {
+ setIsOpen(false);
+ onActiveDeviceChange?.(kind, deviceId);
+ await setActiveMediaDevice(deviceId);
+ }
+
+ return (
+
+ handleActiveChange(value, kind)}
+ defaultValue={devices?.length > 0 ? initialSelection : undefined}
+ disabled={
+ disabled || warnDisable || !devices || devices.length === 0 || devices[0].deviceId === ''
+ }
+ >
+
+ {kind === 'videoinput' && }
+ {kind === 'audiooutput' && }
+ {!(kind === 'videoinput' || kind === 'audiooutput') && }
+
+ }
+ >
+
+
+ ref?.addEventListener('touchend', (e) => e.preventDefault())}
+ className="w-full"
+ >
+ {devices.length !== 0 && devices[0].deviceId !== '' && (
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceSelect.tsx b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceSelect.tsx
new file mode 100644
index 0000000..3c8057c
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDeviceSelect.tsx
@@ -0,0 +1,18 @@
+import { SelectItem } from '@xipkg/select';
+
+export type MediaDeviceKind = 'videoinput' | 'audiooutput' | 'audioinput';
+
+type MediaDeviceSelectPropsT = {
+ devices: MediaDeviceInfo[];
+};
+
+export const MediaDeviceSelect = ({ devices }: MediaDeviceSelectPropsT) => (
+
+);
diff --git a/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDevices.tsx b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDevices.tsx
new file mode 100644
index 0000000..f3f5aaa
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/MediaDevices/MediaDevices.tsx
@@ -0,0 +1,202 @@
+import { Button } from '@xipkg/button';
+import { MediaDeviceMenu } from './MediaDeviceMenu';
+import { usePersistentUserChoices } from '../../../../hooks/usePersistentUserChoices';
+import { useMemo } from 'react';
+import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
+import { useCallStore } from '../../../../store/callStore';
+import { useRoom } from '../../../../providers/RoomProvider';
+import { Alert, AlertIcon, AlertContainer, AlertDescription } from '@xipkg/alert';
+import { InfoCircle } from '@xipkg/icons';
+import { Label } from '@xipkg/label';
+import { Switch } from '@xipkg/switcher';
+import { supportsBackgroundProcessors } from '@livekit/track-processors';
+
+interface MediaDevicesProps {
+ audioTrack?: LocalAudioTrack;
+ videoTrack?: LocalVideoTrack;
+}
+
+export const MediaDevices = ({ audioTrack, videoTrack }: MediaDevicesProps) => {
+ const {
+ userChoices: { audioDeviceId, audioOutputDeviceId, videoDeviceId, blurEnabled },
+ saveAudioInputDeviceId,
+ saveAudioOutputDeviceId,
+ saveVideoInputDeviceId,
+ saveAudioInputEnabled,
+ saveVideoInputEnabled,
+ saveBlurEnabled,
+ } = usePersistentUserChoices();
+
+ const { updateStore, token, isConnecting } = useCallStore();
+ const { room } = useRoom();
+
+ const isBlurSupported = supportsBackgroundProcessors();
+
+ const handleJoin = async () => {
+ if (!token) {
+ console.error('No token available for joining the call');
+ return;
+ }
+
+ // Проверяем, не подключены ли уже
+ if (room.state === 'connected') {
+ console.log('Already connected to room, just updating store...');
+ // Если уже подключены, просто обновляем store
+ updateStore('connect', true);
+ updateStore('isStarted', true);
+ updateStore('isConnecting', false);
+ return;
+ }
+
+ if (isConnecting) {
+ // console.log('Already connecting to room...');
+ return;
+ }
+
+ // Устанавливаем флаг подключения
+ updateStore('isConnecting', true);
+
+ try {
+ // Сохраняем текущие настройки устройств в store
+ updateStore('audioDeviceId', audioDeviceId);
+ updateStore('audioOutputDeviceId', audioOutputDeviceId);
+ updateStore('videoDeviceId', videoDeviceId);
+
+ // Сохраняем состояние аудио и видео
+ updateStore('audioEnabled', audioTrack ? !audioTrack.isMuted : false);
+ updateStore('videoEnabled', videoTrack ? !videoTrack.isMuted : false);
+
+ // console.log('Preparing to join room...');
+
+ // LiveKitRoom автоматически управляет подключением
+ // Нам нужно только установить флаг подключения
+ updateStore('connect', true);
+ updateStore('isStarted', true);
+ updateStore('isConnecting', false);
+
+ // console.log('Successfully joined room with devices:', {
+ // audioDeviceId,
+ // audioOutputDeviceId,
+ // videoDeviceId,
+ // audioEnabled: audioTrack ? !audioTrack.isMuted : false,
+ // videoEnabled: videoTrack ? !videoTrack.isMuted : false,
+ // });
+ } catch (error) {
+ console.error('Failed to join room:', error);
+
+ // Сбрасываем состояние при ошибке
+ updateStore('connect', false);
+ updateStore('isStarted', false);
+ updateStore('isConnecting', false);
+
+ // Если это ошибка отключения клиента, не показываем пользователю
+ if (error instanceof Error && error.message.includes('Client initiated disconnect')) {
+ console.log('Connection was cancelled by client - this is normal during navigation');
+ return;
+ }
+
+ // Для других ошибок можно показать уведомление пользователю
+ console.warn('Connection failed, please try again');
+ }
+ };
+
+ // Обработчики переключения устройств с обработкой ошибок
+ const handleAudioDeviceChange = useMemo(
+ () => async (_kind: MediaDeviceKind, deviceId: string) => {
+ try {
+ saveAudioInputDeviceId(deviceId);
+ if (audioTrack) {
+ await audioTrack.setDeviceId({ exact: deviceId });
+ // Синхронизируем состояние после смены устройства
+ const isActuallyEnabled = !audioTrack.isMuted;
+ // console.log('MediaDevices: audio device changed, syncing state', {
+ // deviceId,
+ // trackMuted: audioTrack.isMuted,
+ // shouldBeEnabled: isActuallyEnabled,
+ // });
+ saveAudioInputEnabled(isActuallyEnabled);
+ }
+ } catch (err) {
+ console.error('Failed to switch microphone device', err);
+ }
+ },
+ [audioTrack, saveAudioInputDeviceId, saveAudioInputEnabled],
+ );
+
+ const handleVideoDeviceChange = useMemo(
+ () => async (_kind: MediaDeviceKind, deviceId: string) => {
+ try {
+ saveVideoInputDeviceId(deviceId);
+ if (videoTrack) {
+ await videoTrack.setDeviceId({ exact: deviceId });
+ // Синхронизируем состояние после смены устройства
+ const isActuallyEnabled = !videoTrack.isMuted;
+ // console.log('MediaDevices: video device changed, syncing state', {
+ // deviceId,
+ // trackMuted: videoTrack.isMuted,
+ // shouldBeEnabled: isActuallyEnabled,
+ // });
+ saveVideoInputEnabled(isActuallyEnabled);
+ }
+ } catch (err) {
+ console.error('Failed to switch camera device', err);
+ }
+ },
+ [videoTrack, saveVideoInputDeviceId, saveVideoInputEnabled],
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/calls/src/ui/PreJoin/components/MediaDevices/index.ts b/packages/calls/src/ui/PreJoin/components/MediaDevices/index.ts
new file mode 100644
index 0000000..7d39ff9
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/MediaDevices/index.ts
@@ -0,0 +1 @@
+export { MediaDevices } from './MediaDevices';
diff --git a/packages/calls/src/ui/PreJoin/components/UserTile/Controls.tsx b/packages/calls/src/ui/PreJoin/components/UserTile/Controls.tsx
new file mode 100644
index 0000000..8d58022
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/UserTile/Controls.tsx
@@ -0,0 +1,97 @@
+import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client';
+import { useCallback, useMemo } from 'react';
+
+import { DevicesBar } from '../../../../../../common.ui/src/ui/shared/DevicesBar';
+import { usePersistentUserChoices } from '../../../../hooks/usePersistentUserChoices';
+
+type ControlsProps = {
+ audioTrack?: LocalAudioTrack;
+ videoTrack?: LocalVideoTrack;
+};
+
+export const Controls = ({ audioTrack, videoTrack }: ControlsProps) => {
+ const {
+ userChoices: { audioEnabled, videoEnabled },
+ saveAudioInputEnabled,
+ saveVideoInputEnabled,
+ } = usePersistentUserChoices();
+
+ const handleAudioChange = useCallback(
+ async (enabled: boolean) => {
+ // console.log('Controls: handleAudioChange', {
+ // enabled,
+ // audioTrack: !!audioTrack,
+ // currentMuted: audioTrack?.isMuted,
+ // });
+ saveAudioInputEnabled(enabled);
+ if (audioTrack) {
+ if (enabled) {
+ console.log('Controls: unmuting audio track');
+ await audioTrack.unmute();
+ } else {
+ console.log('Controls: muting audio track');
+ await audioTrack.mute();
+ }
+ console.log('Controls: audio track state after change', { muted: audioTrack.isMuted });
+ } else {
+ console.log('Controls: no audio track available');
+ }
+ },
+ [audioTrack, saveAudioInputEnabled],
+ );
+
+ const handleVideoChange = useCallback(
+ async (enabled: boolean) => {
+ // console.log('Controls: handleVideoChange', {
+ // enabled,
+ // videoTrack: !!videoTrack,
+ // currentMuted: videoTrack?.isMuted,
+ // });
+ saveVideoInputEnabled(enabled);
+ if (videoTrack) {
+ if (enabled) {
+ console.log('Controls: unmuting video track');
+ await videoTrack.unmute();
+ } else {
+ console.log('Controls: muting video track');
+ await videoTrack.mute();
+ }
+ console.log('Controls: video track state after change', { muted: videoTrack.isMuted });
+ } else {
+ console.log('Controls: no video track available');
+ }
+ },
+ [videoTrack, saveVideoInputEnabled],
+ );
+
+ const microTrackToggle = useMemo(
+ () => ({
+ showIcon: true,
+ source: Track.Source.Microphone,
+ onChange: handleAudioChange,
+ }),
+ [handleAudioChange],
+ );
+
+ const videoTrackToggle = useMemo(
+ () => ({
+ showIcon: true,
+ source: Track.Source.Camera,
+ onChange: handleVideoChange,
+ }),
+ [handleVideoChange],
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/calls/src/ui/PreJoin/components/UserTile/UserTile.tsx b/packages/calls/src/ui/PreJoin/components/UserTile/UserTile.tsx
new file mode 100644
index 0000000..07391aa
--- /dev/null
+++ b/packages/calls/src/ui/PreJoin/components/UserTile/UserTile.tsx
@@ -0,0 +1,357 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@xipkg/avatar';
+import { useMemo, useRef, useEffect, useState } from 'react';
+import { facingModeFromLocalTrack, LocalVideoTrack, LocalAudioTrack } from 'livekit-client';
+import { Controls } from './Controls';
+import { usePersistentUserChoices } from '../../../../hooks/usePersistentUserChoices';
+import { useCannotUseDevice } from '../../../../hooks/useCannotUseDevice';
+import { openPermissionsDialog } from '../../../../store/permissions';
+import { Button } from '@xipkg/button';
+import { SecureVideo } from '../../../../../../common.ui/src/ui/shared';
+
+const UserTileUI = ({
+ audioTrack,
+ videoTrack,
+ videoEnabled,
+ facingMode,
+ videoEl,
+ userId,
+ isCameraDeniedOrPrompted,
+ isMicrophoneDeniedOrPrompted,
+ isVideoInitiated,
+}: {
+ audioTrack?: LocalAudioTrack;
+ videoTrack?: LocalVideoTrack;
+ videoEnabled: boolean;
+ facingMode: string;
+ videoEl: React.RefObject