Adds recording layout configuration
Enables configuration of recording layouts. Specifies the recording layout in the room configuration. Now supports different layouts, such as grid, speaker, and single-speaker. Updated zod validation schemas Updated integration tests
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<MeetRecordingAccess> = z.nativeEnum(MeetRecordingAccess);
|
||||
|
||||
const RecordingConfigSchema: z.ZodType<MeetRecordingConfig> = 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<MeetRecordingConfig> = z.object({
|
||||
enabled: z.boolean(),
|
||||
layout: z.nativeEnum(MeetRecordingLayout).optional(),
|
||||
allowAccessTo: RecordingAccessSchema.optional()
|
||||
});
|
||||
|
||||
const ChatConfigSchema: z.ZodType<MeetChatConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
@ -119,16 +110,33 @@ const UpdateRoomConfigSchema: z.ZodType<Partial<MeetRoomConfig>> = 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<MeetRoomOptions> = 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<MeetRoomOptions> = 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 }
|
||||
|
||||
@ -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<MeetRecordingInfo> => {
|
||||
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<void> {
|
||||
/**
|
||||
* 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<MeetRoom> {
|
||||
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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<header class="step-header">
|
||||
<mat-icon class="ov-recording-icon step-icon">video_library</mat-icon>
|
||||
<div class="step-title-group">
|
||||
<h3 class="step-title">Recording Config</h3>
|
||||
<h3 class="step-title">Recording Configuration</h3>
|
||||
<p class="step-description">Choose whether to enable recording capabilities for this room</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -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<MeetRoomOptions> = {
|
||||
config: {
|
||||
recording: {
|
||||
enabled,
|
||||
|
||||
@ -13,10 +13,10 @@
|
||||
<form [formGroup]="layoutForm" class="layout-form">
|
||||
<!-- Layout Options Cards -->
|
||||
<div class="options-grid">
|
||||
@for (option of layoutOptions; track option.id) {
|
||||
@for (option of layoutOptions(); track option.id) {
|
||||
<ov-selectable-card
|
||||
[option]="option"
|
||||
[selectedValue]="selectedOption"
|
||||
[selectedValue]="selectedOption()"
|
||||
[showSelectionIndicator]="true"
|
||||
[showProBadge]="option.isPro ?? false"
|
||||
[showRecommendedBadge]="option.recommended ?? false"
|
||||
|
||||
@ -1,83 +1,95 @@
|
||||
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { Component, computed, inject, Signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MeetRecordingLayout } from '@openvidu-meet/typings';
|
||||
import { SelectableCardComponent, SelectableOption, SelectionEvent } from '../../../../../../components';
|
||||
import { RoomWizardStateService } from '../../../../../../services';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { RoomWizardStateService, ThemeService } from '../../../../../../services';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-recording-layout',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCardModule,
|
||||
MatRadioModule,
|
||||
SelectableCardComponent
|
||||
],
|
||||
templateUrl: './recording-layout.component.html',
|
||||
styleUrl: './recording-layout.component.scss'
|
||||
selector: 'ov-recording-layout',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCardModule,
|
||||
MatRadioModule,
|
||||
SelectableCardComponent
|
||||
],
|
||||
templateUrl: './recording-layout.component.html',
|
||||
styleUrl: './recording-layout.component.scss'
|
||||
})
|
||||
export class RecordingLayoutComponent implements OnDestroy {
|
||||
export class RecordingLayoutComponent {
|
||||
private themeService = inject(ThemeService);
|
||||
private wizardService = inject(RoomWizardStateService);
|
||||
protected theme = this.themeService.currentTheme;
|
||||
layoutForm: FormGroup;
|
||||
layoutOptions: SelectableOption[] = [
|
||||
{
|
||||
id: 'grid',
|
||||
title: 'Grid Layout',
|
||||
description: 'Show all participants in a grid view with equal sized tiles',
|
||||
imageUrl: './assets/layouts/grid.png'
|
||||
},
|
||||
{
|
||||
id: 'speaker',
|
||||
title: 'Speaker Layout',
|
||||
description: 'Highlight the active speaker with other participants below',
|
||||
imageUrl: './assets/layouts/speaker.png',
|
||||
isPro: true,
|
||||
disabled: true
|
||||
// recommended: true
|
||||
},
|
||||
{
|
||||
id: 'single-speaker',
|
||||
title: 'Single Speaker',
|
||||
description: 'Show only the active speaker in the recording',
|
||||
imageUrl: './assets/layouts/single-speaker.png',
|
||||
isPro: true,
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
layoutOptions: Signal<SelectableOption[]> = 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<void>();
|
||||
private formValues: Signal<any>;
|
||||
selectedOption: Signal<MeetRecordingLayout>;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 9.6 KiB |
BIN
meet-ce/frontend/src/assets/layouts/grid_dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
meet-ce/frontend/src/assets/layouts/grid_light.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
BIN
meet-ce/frontend/src/assets/layouts/single_speaker_dark.png
Normal file
|
After Width: | Height: | Size: 1014 B |
BIN
meet-ce/frontend/src/assets/layouts/single_speaker_light.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
BIN
meet-ce/frontend/src/assets/layouts/speaker_dark.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
meet-ce/frontend/src/assets/layouts/speaker_light.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
18
pnpm-lock.yaml
generated
@ -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': {}
|
||||
|
||||