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
This commit is contained in:
Carlos Santos 2026-01-08 10:41:38 +01:00
parent be5e3ffb1d
commit 6f841eb254
32 changed files with 347 additions and 130 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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",

View File

@ -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),

View File

@ -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 }

View File

@ -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

View File

@ -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) {

View File

@ -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 },

View File

@ -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 },

View File

@ -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 },

View File

@ -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 },

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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,

View File

@ -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"

View File

@ -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';
}
}

View File

@ -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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -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;

View File

@ -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
View File

@ -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': {}