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