diff --git a/meet-ce/backend/openapi/components/responses/success-get-room.yaml b/meet-ce/backend/openapi/components/responses/success-get-room.yaml index 704a431e..7db0b12c 100644 --- a/meet-ce/backend/openapi/components/responses/success-get-room.yaml +++ b/meet-ce/backend/openapi/components/responses/success-get-room.yaml @@ -18,6 +18,8 @@ content: config: recording: enabled: false + layout: grid + allowAccessTo: admin_moderator_speaker chat: enabled: true virtualBackground: @@ -78,6 +80,8 @@ content: config: recording: enabled: false + layout: grid + allowAccessTo: admin_moderator_speaker chat: enabled: true virtualBackground: diff --git a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml index 8c86e2ce..9872b5b8 100644 --- a/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml +++ b/meet-ce/backend/openapi/components/responses/success-get-rooms.yaml @@ -27,6 +27,8 @@ content: config: recording: enabled: false + layout: grid + allowAccessTo: admin_moderator_speaker chat: enabled: true virtualBackground: @@ -87,6 +89,8 @@ content: config: recording: enabled: true + layout: grid + allowAccessTo: admin_moderator_speaker chat: enabled: false virtualBackground: diff --git a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml index 860d7401..87f4d236 100644 --- a/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml +++ b/meet-ce/backend/openapi/components/schemas/meet-room-config.yaml @@ -29,6 +29,25 @@ MeetRecordingConfig: default: true example: true description: If true, the room will be allowed to record the video of the participants. + layout: + type: string + enum: + - grid + - speaker + - single-speaker + - grid-light + - speaker-light + - single-speaker-light + default: grid + example: grid + description: | + Defines the layout of the recording. Options are: + - `grid`: All participants are shown in a grid layout. + - `speaker`: The active speaker is shown prominently, with other participants in smaller thumbnails. + - `single-speaker`: Only the active speaker is shown in the recording. + - `grid-light`: Similar to `grid` but with a light-themed background. + - `speaker-light`: Similar to `speaker` but with a light-themed background. + - `single-speaker-light`: Similar to `single-speaker` but with a light allowAccessTo: type: string enum: diff --git a/meet-ce/backend/package.json b/meet-ce/backend/package.json index 2c80eb5b..97753f7c 100644 --- a/meet-ce/backend/package.json +++ b/meet-ce/backend/package.json @@ -73,6 +73,7 @@ "ioredis": "5.6.1", "jwt-decode": "4.0.0", "livekit-server-sdk": "2.13.3", + "lodash.merge": "4.6.2", "mongoose": "8.19.4", "ms": "2.1.3", "uid": "2.0.2", @@ -87,6 +88,7 @@ "@types/cors": "2.8.19", "@types/express": "4.17.25", "@types/jest": "29.5.14", + "@types/lodash.merge": "4.6.9", "@types/ms": "2.1.0", "@types/node": "22.16.5", "@types/supertest": "6.0.3", diff --git a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts index 8843106e..4713e0b1 100644 --- a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts +++ b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts @@ -1,5 +1,6 @@ import { MeetRecordingAccess, + MeetRecordingLayout, MeetRoom, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings, @@ -49,6 +50,12 @@ const MeetRecordingConfigSchema = new Schema( type: Boolean, required: true }, + layout: { + type: String, + enum: Object.values(MeetRecordingLayout), + required: true, + default: MeetRecordingLayout.GRID + }, allowAccessTo: { type: String, enum: Object.values(MeetRecordingAccess), diff --git a/meet-ce/backend/src/models/zod-schemas/room.schema.ts b/meet-ce/backend/src/models/zod-schemas/room.schema.ts index 9e9c6c45..804bbaee 100644 --- a/meet-ce/backend/src/models/zod-schemas/room.schema.ts +++ b/meet-ce/backend/src/models/zod-schemas/room.schema.ts @@ -5,6 +5,7 @@ import { MeetPermissions, MeetRecordingAccess, MeetRecordingConfig, + MeetRecordingLayout, MeetRoomAutoDeletionPolicy, MeetRoomConfig, MeetRoomDeletionPolicyWithMeeting, @@ -36,21 +37,11 @@ export const nonEmptySanitizedRoomId = (fieldName: string) => const RecordingAccessSchema: z.ZodType = z.nativeEnum(MeetRecordingAccess); -const RecordingConfigSchema: z.ZodType = z - .object({ - enabled: z.boolean(), - allowAccessTo: RecordingAccessSchema.optional() - }) - .refine( - (data) => { - // If recording is enabled, allowAccessTo must be provided - return !data.enabled || data.allowAccessTo !== undefined; - }, - { - message: 'allowAccessTo is required when recording is enabled', - path: ['allowAccessTo'] - } - ); +const RecordingConfigSchema: z.ZodType = z.object({ + enabled: z.boolean(), + layout: z.nativeEnum(MeetRecordingLayout).optional(), + allowAccessTo: RecordingAccessSchema.optional() +}); const ChatConfigSchema: z.ZodType = z.object({ enabled: z.boolean() @@ -119,16 +110,33 @@ const UpdateRoomConfigSchema: z.ZodType> = z /** * Schema for creating room config (applies defaults for missing fields) * Used when creating a new room - missing fields get default values + * + * IMPORTANT: Using functions in .default() to avoid shared mutable state. + * Each call creates a new object instance instead of reusing the same reference. */ const CreateRoomConfigSchema = z .object({ - recording: RecordingConfigSchema.optional().default({ enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }), - chat: ChatConfigSchema.optional().default({ enabled: true }), - virtualBackground: VirtualBackgroundConfigSchema.optional().default({ enabled: true }), - e2ee: E2EEConfigSchema.optional().default({ enabled: false }) + recording: RecordingConfigSchema.optional().default(() => ({ + enabled: true, + layout: MeetRecordingLayout.GRID, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + })), + chat: ChatConfigSchema.optional().default(() => ({ enabled: true })), + virtualBackground: VirtualBackgroundConfigSchema.optional().default(() => ({ enabled: true })), + e2ee: E2EEConfigSchema.optional().default(() => ({ enabled: false })) // appearance: AppearanceConfigSchema, }) .transform((data) => { + // Apply default layout if not provided + if (data.recording.layout === undefined) { + data.recording.layout = MeetRecordingLayout.GRID; + } + + // Apply default allowAccessTo if not provided + if (data.recording.allowAccessTo === undefined) { + data.recording.allowAccessTo = MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER; + } + // Automatically disable recording when E2EE is enabled if (data.e2ee.enabled && data.recording.enabled) { data.recording = { @@ -169,10 +177,10 @@ export const RoomOptionsSchema: z.ZodType = z.object({ ) .optional(), autoDeletionPolicy: RoomAutoDeletionPolicySchema.optional() - .default({ + .default(() => ({ withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS, withRecordings: MeetRoomDeletionPolicyWithRecordings.CLOSE - }) + })) .refine( (policy) => { return !policy || policy.withMeeting !== MeetRoomDeletionPolicyWithMeeting.FAIL; @@ -192,7 +200,11 @@ export const RoomOptionsSchema: z.ZodType = z.object({ } ), config: CreateRoomConfigSchema.optional().default({ - recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + recording: { + enabled: true, + layout: MeetRecordingLayout.GRID, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, chat: { enabled: true }, virtualBackground: { enabled: true }, e2ee: { enabled: false } diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts index 235b1459..dee3b3ce 100644 --- a/meet-ce/backend/src/services/recording.service.ts +++ b/meet-ce/backend/src/services/recording.service.ts @@ -1,4 +1,10 @@ -import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings'; +import { + MeetRecordingFilters, + MeetRecordingInfo, + MeetRecordingStatus, + MeetRoom, + MeetRoomConfig +} from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { EgressStatus, EncodedFileOutput, EncodedFileType, RoomCompositeOptions } from 'livekit-server-sdk'; import ms from 'ms'; @@ -58,7 +64,7 @@ export class RecordingService { if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId); - await this.validateRoomForStartRecording(roomId); + const room = await this.validateRoomForStartRecording(roomId); // Manually send the recording signal to OpenVidu Components for avoiding missing event if timeout occurs // and the egress_started webhook is not received. @@ -100,7 +106,7 @@ export class RecordingService { const startRecordingPromise = (async (): Promise => { try { - const options = this.generateCompositeOptionsFromRequest(); + const options = this.generateCompositeOptionsFromRequest(room.config); const output = this.generateFileOutputFromRequest(roomId); const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options); @@ -542,7 +548,14 @@ export class RecordingService { } } - protected async validateRoomForStartRecording(roomId: string): Promise { + /** + * Validates that a room exists and has participants before starting a recording. + * + * @param roomId + * @returns The MeetRoom object if validation passes. + * @throws Will throw an error if the room does not exist or has no participants. + */ + protected async validateRoomForStartRecording(roomId: string): Promise { const room = await this.roomRepository.findByRoomId(roomId); if (!room) throw errorRoomNotFound(roomId); @@ -550,6 +563,8 @@ export class RecordingService { const hasParticipants = await this.livekitService.roomHasParticipants(roomId); if (!hasParticipants) throw errorRoomHasNoParticipants(roomId); + + return room; } /** @@ -683,9 +698,15 @@ export class RecordingService { } } - protected generateCompositeOptionsFromRequest(layout = 'grid'): RoomCompositeOptions { + /** + * Generates composite options for recording based on the provided room configuration. + * + * @param roomConfig The room configuration + * @returns The generated RoomCompositeOptions object. + */ + protected generateCompositeOptionsFromRequest({ recording }: MeetRoomConfig): RoomCompositeOptions { return { - layout: layout + layout: recording.layout // customBaseUrl: customLayout, // audioOnly: false, // videoOnly: false diff --git a/meet-ce/backend/src/services/room.service.ts b/meet-ce/backend/src/services/room.service.ts index 783ec307..c0347877 100644 --- a/meet-ce/backend/src/services/room.service.ts +++ b/meet-ce/backend/src/services/room.service.ts @@ -13,6 +13,7 @@ import { } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { CreateOptions, Room } from 'livekit-server-sdk'; +import merge from 'lodash.merge'; import ms from 'ms'; import { uid as secureUid } from 'uid/secure'; import { uid } from 'uid/single'; @@ -33,6 +34,7 @@ import { LoggerService } from './logger.service.js'; import { RecordingService } from './recording.service.js'; import { RequestSessionService } from './request-session.service.js'; + /** * Service for managing OpenVidu Meet rooms. * @@ -131,11 +133,8 @@ export class RoomService { throw errorRoomActiveMeeting(roomId); } - // Merge the partial config with the existing config - room.config = { - ...room.config, - ...config - }; + // Merge existing config with new config (partial update) + room.config = merge({}, room.config, config); // Disable recording if E2EE is enabled if (room.config.e2ee.enabled && room.config.recording.enabled) { diff --git a/meet-ce/backend/tests/helpers/assertion-helpers.ts b/meet-ce/backend/tests/helpers/assertion-helpers.ts index 4184c897..c2b9d935 100644 --- a/meet-ce/backend/tests/helpers/assertion-helpers.ts +++ b/meet-ce/backend/tests/helpers/assertion-helpers.ts @@ -3,6 +3,7 @@ import { MeetingEndAction, MeetRecordingAccess, MeetRecordingInfo, + MeetRecordingLayout, MeetRecordingStatus, MeetRoom, MeetRoomAutoDeletionPolicy, @@ -155,6 +156,7 @@ export const expectValidRoom = ( expect(room.config).toEqual({ recording: { enabled: true, + layout: MeetRecordingLayout.GRID, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, diff --git a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts index d7827a35..de9e2997 100644 --- a/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/create-room.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; import { MeetRecordingAccess, + MeetRecordingLayout, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings } from '@openvidu-meet/typings'; @@ -60,6 +61,7 @@ describe('Room API Tests', () => { config: { recording: { enabled: false, + layout: MeetRecordingLayout.GRID, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: false }, @@ -95,7 +97,9 @@ describe('Room API Tests', () => { const expectedConfig = { recording: { - enabled: false + enabled: false, + layout: MeetRecordingLayout.GRID, // Default value + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER // Default value }, chat: { enabled: true }, // Default value virtualBackground: { enabled: true }, // Default value @@ -123,6 +127,7 @@ describe('Room API Tests', () => { const expectedConfig = { recording: { enabled: true, // Default value + layout: MeetRecordingLayout.GRID, // Default value allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER // Default value }, chat: { enabled: false }, diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts index 169fef1c..2a9b38a1 100644 --- a/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/get-room-config.test.ts @@ -1,14 +1,15 @@ import { afterEach, beforeAll, describe, it } from '@jest/globals'; -import { MeetRecordingAccess } from '@openvidu-meet/typings'; +import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings'; +import { Response } from 'supertest'; import { expectSuccessRoomConfigResponse } from '../../../helpers/assertion-helpers.js'; import { deleteAllRooms, getRoomConfig, startTestServer } from '../../../helpers/request-helpers.js'; import { setupSingleRoom } from '../../../helpers/test-scenarios.js'; -import { Response } from 'supertest'; describe('Room API Tests', () => { const DEFAULT_CONFIG = { recording: { enabled: true, + layout: MeetRecordingLayout.GRID, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, @@ -40,6 +41,7 @@ describe('Room API Tests', () => { config: { recording: { enabled: true, + layout: MeetRecordingLayout.SPEAKER, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, diff --git a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts index 1fad37a9..a00271c1 100644 --- a/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/get-room.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; -import { MeetRecordingAccess } from '@openvidu-meet/typings'; +import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings'; import ms from 'ms'; import { expectSuccessRoomResponse, @@ -38,6 +38,7 @@ describe('Room API Tests', () => { config: { recording: { enabled: true, + layout: MeetRecordingLayout.SPEAKER, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, diff --git a/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts new file mode 100644 index 00000000..91684c31 --- /dev/null +++ b/meet-ce/backend/tests/integration/api/rooms/recording-layout-room-config.test.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, describe, it } from '@jest/globals'; +import { MeetRecordingAccess, MeetRecordingLayout } from '@openvidu-meet/typings'; +import { expectValidRoom } from '../../../helpers/assertion-helpers.js'; +import { createRoom, deleteAllRooms, startTestServer } from '../../../helpers/request-helpers.js'; + +describe('Room API Tests', () => { + beforeAll(async () => { + await startTestServer(); + }); + + afterAll(async () => { + await deleteAllRooms(); + }); + describe('Recording Layout Tests', () => { + it('Should create a room with default grid layout when layout is not specified', async () => { + const payload = { + roomName: 'Room with Default Layout', + config: { + recording: { + enabled: true + } + } + }; + + const room = await createRoom(payload); + + const expectedConfig = { + recording: { + enabled: true, + layout: MeetRecordingLayout.GRID, // Default value + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + }; + expectValidRoom(room, 'Room with Default Layout', 'room_with_default_layout', expectedConfig); + }); + + it('Should create a room with speaker layout', async () => { + const payload = { + roomName: 'Speaker Layout Room', + config: { + recording: { + enabled: true, + layout: MeetRecordingLayout.SPEAKER, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + } + } + }; + + const room = await createRoom(payload); + + const expectedConfig = { + recording: { + enabled: true, + layout: MeetRecordingLayout.SPEAKER, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + }; + expectValidRoom(room, 'Speaker Layout Room', 'speaker_layout_room', expectedConfig); + }); + + it('Should create a room with single-speaker layout', async () => { + const payload = { + roomName: 'Single Speaker Layout Room', + config: { + recording: { + enabled: true, + layout: MeetRecordingLayout.SINGLE_SPEAKER, + allowAccessTo: MeetRecordingAccess.ADMIN + } + } + }; + + const room = await createRoom(payload); + + const expectedConfig = { + recording: { + enabled: true, + layout: MeetRecordingLayout.SINGLE_SPEAKER, + allowAccessTo: MeetRecordingAccess.ADMIN + }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + }; + expectValidRoom(room, 'Single Speaker Layout Room', 'single_speaker_layout_room', expectedConfig); + }); + }); +}); diff --git a/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts b/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts index 03f16816..04d7e378 100644 --- a/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts +++ b/meet-ce/backend/tests/integration/api/rooms/update-room-config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'; -import { MeetRecordingAccess, MeetRoomConfig, MeetSignalType } from '@openvidu-meet/typings'; +import { MeetRecordingAccess, MeetRecordingLayout, MeetRoomConfig, MeetSignalType } from '@openvidu-meet/typings'; import { container } from '../../../../src/config/dependency-injector.config.js'; import { FrontendEventService } from '../../../../src/services/frontend-event.service.js'; import { @@ -61,7 +61,10 @@ describe('Room API Tests', () => { createdRoom.roomId, { roomId: createdRoom.roomId, - config: updatedConfig, + config: { + ...updatedConfig, + recording: { ...updatedConfig.recording, layout: MeetRecordingLayout.GRID } + }, timestamp: expect.any(Number) }, { @@ -76,7 +79,10 @@ describe('Room API Tests', () => { // Verify with a get request const getResponse = await getRoom(createdRoom.roomId); expect(getResponse.status).toBe(200); - expect(getResponse.body.config).toEqual(updatedConfig); + expect(getResponse.body.config).toEqual({ + ...updatedConfig, + recording: { ...updatedConfig.recording, layout: MeetRecordingLayout.GRID } // Layout remains unchanged + }); }); it('should allow partial config updates', async () => { @@ -86,7 +92,8 @@ describe('Room API Tests', () => { config: { recording: { enabled: true, - allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + layout: MeetRecordingLayout.SPEAKER + // allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, virtualBackground: { enabled: true }, @@ -112,7 +119,9 @@ describe('Room API Tests', () => { const expectedConfig: MeetRoomConfig = { recording: { - enabled: false + enabled: false, + layout: MeetRecordingLayout.SPEAKER, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, virtualBackground: { enabled: true }, @@ -186,24 +195,5 @@ describe('Room API Tests', () => { expect(response.body.error).toContain('Unprocessable Entity'); expect(JSON.stringify(response.body.details)).toContain('recording.enabled'); }); - - it('should fail when recording is enabled but allowAccessTo is missing', async () => { - const createdRoom = await createRoom({ - roomName: 'missing-access' - }); - - const invalidConfig = { - recording: { - enabled: true // Missing allowAccessTo - }, - chat: { enabled: false }, - virtualBackground: { enabled: false } - }; - const response = await updateRoomConfig(createdRoom.roomId, invalidConfig); - - expect(response.status).toBe(422); - expect(response.body.error).toContain('Unprocessable Entity'); - expect(JSON.stringify(response.body.details)).toContain('recording.allowAccessTo'); - }); }); }); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss index 82b026aa..255d3b23 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss @@ -10,6 +10,10 @@ min-height: 120px; display: flex; flex-direction: column; + height: -webkit-fill-available; + height: -moz-available; + height: fill-available; + margin-top: 0; &:hover:not(.no-hover):not(.selected) { @include design-tokens.ov-hover-lift(-2px); @@ -67,7 +71,7 @@ img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; display: block; @include design-tokens.ov-theme-transition; } @@ -125,6 +129,7 @@ color: var(--ov-meet-text-secondary); line-height: var(--ov-meet-line-height-normal); flex: 1; + text-align: center; } } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.html index e3962334..9288a10c 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.html @@ -3,7 +3,7 @@
video_library
-

Recording Config

+

Recording Configuration

Choose whether to enable recording capabilities for this room

diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts index b00655fc..93476691 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-config/recording-config.component.ts @@ -6,10 +6,10 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; +import { MeetRecordingAccess, MeetRoomOptions } from '@openvidu-meet/typings'; +import { Subject, takeUntil } from 'rxjs'; import { SelectableCardComponent, SelectableOption, SelectionEvent } from '../../../../../../components'; import { RoomWizardStateService } from '../../../../../../services'; -import { MeetRecordingAccess } from '@openvidu-meet/typings'; -import { Subject, takeUntil } from 'rxjs'; interface RecordingAccessOption { value: MeetRecordingAccess; @@ -88,7 +88,7 @@ export class RecordingConfigComponent implements OnDestroy { private saveFormData(formValue: any) { const enabled = formValue.recordingEnabled === 'enabled'; - const stepData: any = { + const stepData: Partial = { config: { recording: { enabled, diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html index b23fcc3e..833ddc12 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html @@ -13,10 +13,10 @@
- @for (option of layoutOptions; track option.id) { + @for (option of layoutOptions(); track option.id) { = computed(() => { + return [ + { + id: 'grid', + title: 'Grid Layout', + description: 'Display participants in an equal-size grid', + imageUrl: `./assets/layouts/grid_${this.theme()}.png` + }, + { + id: 'speaker', + title: 'Speaker Layout', + description: 'Highlight the active speaker with other participants below', + imageUrl: `./assets/layouts/speaker_${this.theme()}.png`, + isPro: false, + disabled: false + // recommended: true + }, + { + id: 'single-speaker', + title: 'Single Speaker', + description: 'Show only the active speaker in the recording', + imageUrl: `./assets/layouts/single_speaker_${this.theme()}.png`, + isPro: false, + disabled: false + } + ]; + }); - private destroy$ = new Subject(); + private formValues: Signal; + selectedOption: Signal; - constructor(private wizardService: RoomWizardStateService) { + constructor() { const currentStep = this.wizardService.currentStep(); this.layoutForm = currentStep!.formGroup; - this.layoutForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + // Initialize formValues signal after layoutForm is created + this.formValues = toSignal(this.layoutForm.valueChanges, { + initialValue: this.layoutForm.value + }); + + // Initialize selectedOption computed signal + this.selectedOption = computed(() => { + const formValue = this.formValues(); + return formValue?.layout || MeetRecordingLayout.GRID; + }); + + // Subscribe to form changes to save data (using takeUntilDestroyed for automatic cleanup) + this.layoutForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { this.saveFormData(value); }); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - private saveFormData(formValue: any) { - // Note: Recording layout type is not part of MeetRoomOptions - // For now, just keep the form state + const roomOptions = this.wizardService.roomOptions(); + if (roomOptions.config?.recording) { + roomOptions.config.recording.layout = formValue.layout; + this.wizardService.updateStepData('recordingLayout', formValue); + } } onOptionSelect(event: SelectionEvent): void { this.layoutForm.patchValue({ - layoutType: event.optionId + layout: event.optionId }); } - - get selectedOption(): string { - return this.layoutForm.value.layoutType || 'grid'; - } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts index a0644d63..5b8bccff 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts @@ -1,18 +1,20 @@ import { computed, Injectable, signal } from '@angular/core'; import { AbstractControl, FormBuilder, ValidationErrors, Validators } from '@angular/forms'; -import { WizardNavigationConfig, WizardStep } from '../models'; import { MeetRecordingAccess, + MeetRecordingLayout, MeetRoomConfig, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings, MeetRoomOptions } from '@openvidu-meet/typings'; +import { WizardNavigationConfig, WizardStep } from '../models'; // Default room config following the app's defaults const DEFAULT_CONFIG: MeetRoomConfig = { recording: { enabled: true, + layout: MeetRecordingLayout.GRID, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, chat: { enabled: true }, @@ -178,7 +180,7 @@ export class RoomWizardStateService { isActive: false, isVisible: false, // Initially hidden, will be shown based on recording settings formGroup: this.formBuilder.group({ - layoutType: 'grid' + layout: initialRoomOptions.config?.recording?.layout || MeetRecordingLayout.GRID }) }, { @@ -232,6 +234,7 @@ export class RoomWizardStateService { break; case 'recording': + case 'recordingLayout': updatedOptions = { ...currentOptions, config: { @@ -244,7 +247,6 @@ export class RoomWizardStateService { }; break; case 'recordingTrigger': - case 'recordingLayout': // These steps don't update room options updatedOptions = { ...currentOptions }; break; @@ -291,17 +293,23 @@ export class RoomWizardStateService { private updateStepsVisibility(): void { const currentSteps = this._steps(); const currentOptions = this._roomOptions(); - // TODO: Uncomment when recording config is fully implemented - const recordingEnabled = false; // currentOptions.config?.recording.enabled ?? false; + + const recordingEnabled = currentOptions.config?.recording?.enabled ?? false; // Update recording steps visibility based on recordingEnabled const updatedSteps = currentSteps.map((step) => { - if (step.id === 'recordingTrigger' || step.id === 'recordingLayout') { + if (step.id === 'recordingLayout') { return { ...step, isVisible: recordingEnabled // Only show if recording is enabled }; } + if (step.id === 'recordingTrigger') { + return { + ...step, + isVisible: false // TODO: Change to true when recording trigger config is implemented + }; + } return step; }); this._steps.set(updatedSteps); diff --git a/meet-ce/frontend/src/assets/layouts/grid.png b/meet-ce/frontend/src/assets/layouts/grid.png deleted file mode 100644 index c33fba55..00000000 Binary files a/meet-ce/frontend/src/assets/layouts/grid.png and /dev/null differ diff --git a/meet-ce/frontend/src/assets/layouts/grid_dark.png b/meet-ce/frontend/src/assets/layouts/grid_dark.png new file mode 100644 index 00000000..ee7f35f2 Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/grid_dark.png differ diff --git a/meet-ce/frontend/src/assets/layouts/grid_light.png b/meet-ce/frontend/src/assets/layouts/grid_light.png new file mode 100644 index 00000000..be3e4846 Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/grid_light.png differ diff --git a/meet-ce/frontend/src/assets/layouts/single-speaker.png b/meet-ce/frontend/src/assets/layouts/single-speaker.png deleted file mode 100644 index 645578e7..00000000 Binary files a/meet-ce/frontend/src/assets/layouts/single-speaker.png and /dev/null differ diff --git a/meet-ce/frontend/src/assets/layouts/single_speaker_dark.png b/meet-ce/frontend/src/assets/layouts/single_speaker_dark.png new file mode 100644 index 00000000..299f293d Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/single_speaker_dark.png differ diff --git a/meet-ce/frontend/src/assets/layouts/single_speaker_light.png b/meet-ce/frontend/src/assets/layouts/single_speaker_light.png new file mode 100644 index 00000000..b770428c Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/single_speaker_light.png differ diff --git a/meet-ce/frontend/src/assets/layouts/speaker.png b/meet-ce/frontend/src/assets/layouts/speaker.png deleted file mode 100644 index 52591049..00000000 Binary files a/meet-ce/frontend/src/assets/layouts/speaker.png and /dev/null differ diff --git a/meet-ce/frontend/src/assets/layouts/speaker_dark.png b/meet-ce/frontend/src/assets/layouts/speaker_dark.png new file mode 100644 index 00000000..a1751eca Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/speaker_dark.png differ diff --git a/meet-ce/frontend/src/assets/layouts/speaker_light.png b/meet-ce/frontend/src/assets/layouts/speaker_light.png new file mode 100644 index 00000000..2ff90581 Binary files /dev/null and b/meet-ce/frontend/src/assets/layouts/speaker_light.png differ diff --git a/meet-ce/typings/src/recording.model.ts b/meet-ce/typings/src/recording.model.ts index 7d5a0cee..8a552eb3 100644 --- a/meet-ce/typings/src/recording.model.ts +++ b/meet-ce/typings/src/recording.model.ts @@ -9,6 +9,14 @@ export enum MeetRecordingStatus { ABORTED = 'aborted', LIMIT_REACHED = 'limit_reached' } +export enum MeetRecordingLayout { + GRID = 'grid', + SPEAKER = 'speaker', + SINGLE_SPEAKER = 'single-speaker', + // GRID_LIGHT = 'grid-light', + // SPEAKER_LIGHT = 'speaker-light', + // SINGLE_SPEAKER_LIGHT = 'single-speaker-light' +} // export enum MeetRecordingOutputMode { // COMPOSED = 'composed', @@ -22,6 +30,7 @@ export interface MeetRecordingInfo { roomId: string; roomName: string; // outputMode: MeetRecordingOutputMode; + // layout: MeetRecordingLayout; status: MeetRecordingStatus; filename?: string; startDate?: number; diff --git a/meet-ce/typings/src/room-config.ts b/meet-ce/typings/src/room-config.ts index 448643bc..c8ae2104 100644 --- a/meet-ce/typings/src/room-config.ts +++ b/meet-ce/typings/src/room-config.ts @@ -1,3 +1,5 @@ +import { MeetRecordingLayout } from './recording.model'; + /** * Interface representing the config for a room. */ @@ -14,6 +16,7 @@ export interface MeetRoomConfig { */ export interface MeetRecordingConfig { enabled: boolean; + layout?: MeetRecordingLayout; allowAccessTo?: MeetRecordingAccess; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3857bb52..8afb8c65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: livekit-server-sdk: specifier: 2.13.3 version: 2.13.3 + lodash.merge: + specifier: 4.6.2 + version: 4.6.2 mongoose: specifier: 8.19.4 version: 8.19.4(socks@2.8.7) @@ -162,6 +165,9 @@ importers: '@types/jest': specifier: 29.5.14 version: 29.5.14 + '@types/lodash.merge': + specifier: 4.6.9 + version: 4.6.9 '@types/ms': specifier: 2.1.0 version: 2.1.0 @@ -3874,6 +3880,12 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash.merge@4.6.9': + resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==} + + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} + '@types/lru-cache@4.1.3': resolution: {integrity: sha512-QjCOmf5kYwekcsfEKhcEHMK8/SvgnneuSDXFERBuC/DPca2KJIO/gpChTsVb35BoWLBpEAJWz1GFVEArSdtKtw==} @@ -13862,6 +13874,12 @@ snapshots: '@types/json5@0.0.29': {} + '@types/lodash.merge@4.6.9': + dependencies: + '@types/lodash': 4.17.21 + + '@types/lodash@4.17.21': {} + '@types/lru-cache@4.1.3': {} '@types/luxon@3.7.1': {}