diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html
index ad8ecd41..5b4836da 100644
--- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html
+++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.html
@@ -11,8 +11,10 @@
>
subtitles
- @if (isCaptionsButtonDisabled()) {
+ @if (captionsStatus() === 'DISABLED_WITH_WARNING') {
Live captions (disabled by admin)
+ } @else if (isCaptionsTogglePending()) {
+ {{ areCaptionsEnabledByUser() ? 'Disabling live captions...' : 'Enabling live captions...' }}
} @else {
{{ areCaptionsEnabledByUser() ? 'Disable live captions' : 'Enable live captions' }}
}
@@ -28,14 +30,16 @@
[disabledInteractive]="isCaptionsButtonDisabled()"
[disableRipple]="true"
[matTooltip]="
- isCaptionsButtonDisabled()
+ captionsStatus() === 'DISABLED_WITH_WARNING'
? 'Live captions are disabled by admin'
- : areCaptionsEnabledByUser()
- ? 'Disable live captions'
- : 'Enable live captions'
+ : isCaptionsTogglePending()
+ ? (areCaptionsEnabledByUser() ? 'Disabling live captions...' : 'Enabling live captions...')
+ : areCaptionsEnabledByUser()
+ ? 'Disable live captions'
+ : 'Enable live captions'
"
>
- @if (isCaptionsButtonDisabled()) {
+ @if (captionsStatus() === 'DISABLED_WITH_WARNING') {
subtitles_off
} @else {
subtitles
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts
index bcaf47be..c8dc62fb 100644
--- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts
+++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/customization/meeting-toolbar-extra-buttons/meeting-toolbar-extra-buttons.component.ts
@@ -44,9 +44,18 @@ export class MeetingToolbarExtraButtonsComponent {
protected showCaptionsButton = computed(() => this.captionsStatus() !== 'HIDDEN');
/**
- * Whether captions button is disabled (true when DISABLED_WITH_WARNING)
+ * Whether captions button is disabled:
+ * - Always disabled when captions are turned off at admin/system level (DISABLED_WITH_WARNING)
+ * - Also disabled while an enable/disable request is in flight to prevent concurrent calls
*/
- protected isCaptionsButtonDisabled = computed(() => this.captionsStatus() === 'DISABLED_WITH_WARNING');
+ protected isCaptionsButtonDisabled = computed(
+ () => this.captionsStatus() === 'DISABLED_WITH_WARNING' || this.captionService.isCaptionsTogglePending()
+ );
+
+ /**
+ * Whether the captions toggle is pending a server response
+ */
+ protected isCaptionsTogglePending = computed(() => this.captionService.isCaptionsTogglePending());
/**
* Whether the device is mobile (affects button style)
@@ -65,12 +74,18 @@ export class MeetingToolbarExtraButtonsComponent {
this.meetingService.copyMeetingSpeakerLink(room);
}
- onCaptionsClick(): void {
- // Don't allow toggling if captions are disabled at system level
- if (this.isCaptionsButtonDisabled()) {
- this.log.w('Captions are disabled at system level (MEET_CAPTIONS_ENABLED=false)');
- return;
+ async onCaptionsClick(): Promise {
+ try {
+ // Don't allow toggling if captions are disabled at system level
+ if (this.isCaptionsButtonDisabled()) {
+ this.log.w('Captions are disabled at system level (MEET_CAPTIONS_ENABLED=false)');
+ return;
+ }
+ this.captionService.areCaptionsEnabledByUser()
+ ? await this.captionService.disable()
+ : await this.captionService.enable();
+ } catch (error) {
+ this.log.e('Error toggling captions:', error);
}
- this.captionService.areCaptionsEnabledByUser() ? this.captionService.disable() : this.captionService.enable();
}
}
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts
index 5094763b..a63dbf49 100644
--- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts
+++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/meeting/services/meeting-captions.service.ts
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core';
import { ILogger, LoggerService, ParticipantService, Room, TextStreamReader } from 'openvidu-components-angular';
+import { AiAssistantService } from '../../../shared';
import { Caption, CaptionsConfig } from '../models/captions.model';
import { CustomParticipantModel } from '../models/custom-participant.model';
@@ -18,9 +19,10 @@ import { CustomParticipantModel } from '../models/custom-participant.model';
providedIn: 'root'
})
export class MeetingCaptionsService {
- private readonly loggerService = inject(LoggerService);
private readonly logger: ILogger;
+ private readonly loggerService = inject(LoggerService);
private readonly participantService = inject(ParticipantService);
+ private readonly aiAssistantService = inject(AiAssistantService);
// Configuration with defaults
private readonly defaultConfig: Required = {
@@ -38,6 +40,8 @@ export class MeetingCaptionsService {
// Reactive state
private readonly _captions = signal([]);
private readonly _areCaptionsEnabledByUser = signal(false);
+ private readonly _captionsAgentId = signal(null);
+ private readonly _isCaptionsTogglePending = signal(false);
/**
* Current list of active captions
@@ -47,6 +51,11 @@ export class MeetingCaptionsService {
* Whether captions are enabled by the user
*/
readonly areCaptionsEnabledByUser = this._areCaptionsEnabledByUser.asReadonly();
+ /**
+ * True while an enable() or disable() call is in flight.
+ * Use this to prevent concurrent toggle requests.
+ */
+ readonly isCaptionsTogglePending = this._isCaptionsTogglePending.asReadonly();
// Map to track expiration timeouts
private expirationTimeouts = new Map>();
@@ -82,7 +91,7 @@ export class MeetingCaptionsService {
* Enables captions by registering the transcription handler.
* This is called when the user activates captions.
*/
- enable(): void {
+ async enable(): Promise {
if (!this.room) {
this.logger.e('Cannot enable captions: room is not initialized');
return;
@@ -93,29 +102,63 @@ export class MeetingCaptionsService {
return;
}
- // Register the LiveKit transcription handler
- this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this));
+ if (this._isCaptionsTogglePending()) {
+ this.logger.d('Captions toggle already in progress');
+ return;
+ }
- this._areCaptionsEnabledByUser.set(true);
- this.logger.d('Captions enabled');
+ this._isCaptionsTogglePending.set(true);
+
+ try {
+ // Register the LiveKit transcription handler
+ const agent = await this.aiAssistantService.createLiveCaptionsAssistant();
+ this._captionsAgentId.set(agent.id);
+ this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this));
+ this._areCaptionsEnabledByUser.set(true);
+ this.logger.d('Captions enabled');
+ } finally {
+ // Add a small delay before allowing another toggle to prevent rapid concurrent calls
+ setTimeout(() => this._isCaptionsTogglePending.set(false), 500);
+ }
}
/**
* Disables captions by clearing all captions and stopping transcription.
* This is called when the user deactivates captions.
*/
- disable(): void {
+ async disable(): Promise {
if (!this._areCaptionsEnabledByUser()) {
this.logger.d('Captions already disabled');
return;
}
- // Clear all active captions
- this.clearAllCaptions();
+ if (this._isCaptionsTogglePending()) {
+ this.logger.d('Captions toggle already in progress');
+ return;
+ }
- this._areCaptionsEnabledByUser.set(false);
- this.room?.unregisterTextStreamHandler('lk.transcription');
- this.logger.d('Captions disabled');
+ this._isCaptionsTogglePending.set(true);
+
+ try {
+ const agentId = this._captionsAgentId();
+
+ // Clear all active captions and unregister handler immediately so the UI
+ // reflects the disabled state before the async server call completes.
+ this.clearAllCaptions();
+ this._areCaptionsEnabledByUser.set(false);
+ this.room?.unregisterTextStreamHandler('lk.transcription');
+
+ if (agentId) {
+ await this.aiAssistantService.cancelAssistant(agentId);
+ }
+
+ this.logger.d('Captions disabled');
+ } catch (error) {
+ this.logger.e('Error disabling captions:', error);
+ } finally {
+ // Add a small delay before allowing another toggle to prevent rapid concurrent calls
+ setTimeout(() => this._isCaptionsTogglePending.set(false), 500);
+ }
}
/**
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/ai-assistant.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/ai-assistant.service.ts
new file mode 100644
index 00000000..67fb2037
--- /dev/null
+++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/ai-assistant.service.ts
@@ -0,0 +1,33 @@
+import { Injectable } from '@angular/core';
+import {
+ MeetAssistantCapabilityName,
+ MeetCreateAssistantRequest,
+ MeetCreateAssistantResponse
+} from '@openvidu-meet/typings';
+import { HttpService } from './http.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AiAssistantService {
+ protected readonly AI_ASSISTANT_API = `${HttpService.INTERNAL_API_PATH_PREFIX}/ai/assistants`;
+
+ constructor(protected httpService: HttpService) {}
+
+ async cancelAssistant(assistantId: string): Promise {
+ const path = `${this.AI_ASSISTANT_API}/${assistantId}`;
+ await this.httpService.deleteRequest(path);
+ }
+
+ async createLiveCaptionsAssistant(): Promise {
+ const request: MeetCreateAssistantRequest = {
+ capabilities: [{ name: MeetAssistantCapabilityName.LIVE_CAPTIONS }]
+ };
+
+ return this.createAssistant(request);
+ }
+
+ private async createAssistant(request: MeetCreateAssistantRequest): Promise {
+ return this.httpService.postRequest(this.AI_ASSISTANT_API, request);
+ }
+}
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/index.ts
index 982c1175..a6adc5dd 100644
--- a/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/index.ts
+++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/shared/services/index.ts
@@ -1,3 +1,4 @@
+export * from './ai-assistant.service';
export * from './analytics.service';
export * from './api-key.service';
export * from './app-config.service';