frontend: enhance captions button functionality and integrate AI assistant for live captions

This commit is contained in:
CSantosM 2026-03-03 19:13:42 +01:00
parent c808e98820
commit 02703b1f83
5 changed files with 122 additions and 26 deletions

View File

@ -11,8 +11,10 @@
>
<mat-icon class="material-symbols-outlined">subtitles</mat-icon>
<span class="button-text">
@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'
: isCaptionsTogglePending()
? (areCaptionsEnabledByUser() ? 'Disabling live captions...' : 'Enabling live captions...')
: areCaptionsEnabledByUser()
? 'Disable live captions'
: 'Enable live captions'
"
>
@if (isCaptionsButtonDisabled()) {
@if (captionsStatus() === 'DISABLED_WITH_WARNING') {
<mat-icon>subtitles_off</mat-icon>
} @else {
<mat-icon>subtitles</mat-icon>

View File

@ -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 {
async onCaptionsClick(): Promise<void> {
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() ? this.captionService.disable() : this.captionService.enable();
this.captionService.areCaptionsEnabledByUser()
? await this.captionService.disable()
: await this.captionService.enable();
} catch (error) {
this.log.e('Error toggling captions:', error);
}
}
}

View File

@ -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<CaptionsConfig> = {
@ -38,6 +40,8 @@ export class MeetingCaptionsService {
// Reactive state
private readonly _captions = signal<Caption[]>([]);
private readonly _areCaptionsEnabledByUser = signal<boolean>(false);
private readonly _captionsAgentId = signal<string | null>(null);
private readonly _isCaptionsTogglePending = signal<boolean>(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<string, ReturnType<typeof setTimeout>>();
@ -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<void> {
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._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<void> {
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._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);
}
}
/**

View File

@ -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<void> {
const path = `${this.AI_ASSISTANT_API}/${assistantId}`;
await this.httpService.deleteRequest<void>(path);
}
async createLiveCaptionsAssistant(): Promise<MeetCreateAssistantResponse> {
const request: MeetCreateAssistantRequest = {
capabilities: [{ name: MeetAssistantCapabilityName.LIVE_CAPTIONS }]
};
return this.createAssistant(request);
}
private async createAssistant(request: MeetCreateAssistantRequest): Promise<MeetCreateAssistantResponse> {
return this.httpService.postRequest<MeetCreateAssistantResponse>(this.AI_ASSISTANT_API, request);
}
}

View File

@ -1,3 +1,4 @@
export * from './ai-assistant.service';
export * from './analytics.service';
export * from './api-key.service';
export * from './app-config.service';