frontend: Enables user-controlled live captions

Allows users to toggle live captions on or off.
Introduces a room configuration setting to enable/disable the captions feature.
The captions button visibility is now controlled by the 'showCaptions' feature flag.
This commit is contained in:
CSantosM 2026-01-22 19:28:18 +01:00
parent cb12d9a8fe
commit 0a56a74433
11 changed files with 100 additions and 38 deletions

View File

@ -1,8 +1,8 @@
@if (meetingContextService.lkRoom()) {
<div class="main-container" [ngClass]="{ withFooter: shouldShowCaptions() }">
<div class="main-container" [ngClass]="{ withFooter: areCaptionsEnabledByUser() }">
<ov-layout
[ovRemoteParticipants]="visibleRemoteParticipants()"
[ngClass]="{ withFooter: shouldShowCaptions() }"
[ngClass]="{ withFooter: areCaptionsEnabledByUser() }"
>
@if (shouldShowLinkOverlay()) {
<ng-container *ovLayoutAdditionalElements>
@ -43,7 +43,7 @@
</ov-layout>
</div>
<!-- Live Captions Component -->
@if (shouldShowCaptions()) {
@if (areCaptionsEnabledByUser()) {
<ov-meeting-captions [captions]="captions()"></ov-meeting-captions>
}
}

View File

@ -50,7 +50,7 @@ export class MeetingCustomLayoutComponent {
return this.meetingContextService.canModerateRoom() && hasNoRemotes;
});
protected readonly shouldShowCaptions = computed(() => this.captionsService.areCaptionsEnabled());
protected readonly areCaptionsEnabledByUser = computed(() => this.captionsService.areCaptionsEnabledByUser());
protected readonly captions = computed(() => this.captionsService.captions());

View File

@ -1,25 +1,25 @@
<!-- Captions button -->
@if (isMobile()) {
<!-- On mobile, the captions button will be inside a menu -->
<button id="captions-button" mat-menu-item (click)="onCaptionsClick()" [disableRipple]="true">
<mat-icon class="material-symbols-outlined">{{
areCaptionsEnabled() ? 'subtitles_off' : 'subtitles'
}}</mat-icon>
<span class="button-text">
{{ areCaptionsEnabled() ? 'Disable live captions' : 'Enable live captions' }}
</span>
</button>
} @else {
<button
id="captions-button"
[ngClass]="areCaptionsEnabled() ? 'active' : ''"
mat-icon-button
(click)="onCaptionsClick()"
[disableRipple]="true"
[matTooltip]="areCaptionsEnabled() ? 'Disable live captions' : 'Enable live captions'"
>
<mat-icon>{{ areCaptionsEnabled() ? 'subtitles_off' : 'subtitles' }}</mat-icon>
</button>
@if (showCaptionsButton()) {
@if (isMobile()) {
<!-- On mobile, the captions button will be inside a menu -->
<button id="captions-button" mat-menu-item (click)="onCaptionsClick()" [disableRipple]="true">
<mat-icon class="material-symbols-outlined">subtitles</mat-icon>
<span class="button-text">
{{ areCaptionsEnabledByUser() ? 'Disable live captions' : 'Enable live captions' }}
</span>
</button>
} @else {
<button
id="captions-button"
[ngClass]="areCaptionsEnabledByUser() ? 'active' : ''"
mat-icon-button
(click)="onCaptionsClick()"
[disableRipple]="true"
[matTooltip]="areCaptionsEnabledByUser() ? 'Disable live captions' : 'Enable live captions'"
>
<mat-icon>subtitles</mat-icon>
</button>
}
}
<!-- Copy Link Button -->

View File

@ -33,12 +33,17 @@ export class MeetingToolbarExtraButtonsComponent {
*/
protected showCopyLinkButton = computed(() => this.meetingContextService.canModerateRoom());
/**
* Whether to show the captions button
*/
protected showCaptionsButton = computed(() => this.meetingContextService.areCaptionsAllowed());
/**
* Whether the device is mobile (affects button style)
*/
protected isMobile = computed(() => this.meetingContextService.isMobile());
protected areCaptionsEnabled = computed(() => this.captionService.areCaptionsEnabled());
protected areCaptionsEnabledByUser = computed(() => this.captionService.areCaptionsEnabledByUser());
onCopyLinkClick(): void {
const room = this.meetingContextService.meetRoom();
@ -51,6 +56,6 @@ export class MeetingToolbarExtraButtonsComponent {
}
onCaptionsClick(): void {
this.captionService.areCaptionsEnabled() ? this.captionService.disable() : this.captionService.enable();
this.captionService.areCaptionsEnabledByUser() ? this.captionService.disable() : this.captionService.enable();
}
}

View File

@ -37,11 +37,16 @@ export class MeetingCaptionsService {
// Reactive state
private readonly _captions = signal<Caption[]>([]);
private readonly _isEnabled = signal<boolean>(false);
private readonly _areCaptionsEnabledByUser = signal<boolean>(false);
// Public readonly signals
/**
* Current list of active captions
*/
readonly captions = this._captions.asReadonly();
readonly areCaptionsEnabled = this._isEnabled.asReadonly();
/**
* Whether captions are enabled by the user
*/
readonly areCaptionsEnabledByUser = this._areCaptionsEnabledByUser.asReadonly();
// Map to track expiration timeouts
private expirationTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@ -83,7 +88,7 @@ export class MeetingCaptionsService {
return;
}
if (this._isEnabled()) {
if (this._areCaptionsEnabledByUser()) {
this.logger.d('Captions already enabled');
return;
}
@ -91,7 +96,7 @@ export class MeetingCaptionsService {
// Register the LiveKit transcription handler
this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this));
this._isEnabled.set(true);
this._areCaptionsEnabledByUser.set(true);
this.logger.d('Captions enabled');
}
@ -100,7 +105,7 @@ export class MeetingCaptionsService {
* This is called when the user deactivates captions.
*/
disable(): void {
if (!this._isEnabled()) {
if (!this._areCaptionsEnabledByUser()) {
this.logger.d('Captions already disabled');
return;
}
@ -108,7 +113,7 @@ export class MeetingCaptionsService {
// Clear all active captions
this.clearAllCaptions();
this._isEnabled.set(false);
this._areCaptionsEnabledByUser.set(false);
this.room?.unregisterTextStreamHandler('lk.transcription');
this.logger.d('Captions disabled');
}
@ -119,7 +124,7 @@ export class MeetingCaptionsService {
destroy(): void {
this.clearAllCaptions();
this.room = null;
this._isEnabled.set(false);
this._areCaptionsEnabledByUser.set(false);
this.logger.d('Meeting Captions service destroyed');
}

View File

@ -186,6 +186,14 @@ export class MeetingContextService {
return this._e2eeKey().length > 0;
}
/**
* Returns whether captions feature is allowed in the room
* @returns true if captions feature is allowed, false otherwise
*/
areCaptionsAllowed(): boolean {
return this.featureConfigService.features().showCaptions;
}
/**
* Sets the room secret in context
* @param secret The room secret

View File

@ -61,6 +61,30 @@
</mat-card-content>
</mat-card>
<!-- Captions Card -->
<mat-card class="config-card">
<mat-card-content>
<div class="card-header">
<div class="icon-title-group">
<mat-icon class="feature-icon">closed_caption</mat-icon>
<div class="title-group">
<h4 class="card-title">Captions</h4>
<p class="card-description">
Enable live transcription to display captions during the meeting
</p>
</div>
</div>
<mat-slide-toggle
[checked]="captionsEnabled"
(change)="onCaptionsToggleChange($event)"
color="primary"
class="feature-toggle"
>
</mat-slide-toggle>
</div>
</mat-card-content>
</mat-card>
<!-- Chat Settings Card -->
<mat-card class="config-card">
<mat-card-content>

View File

@ -44,6 +44,9 @@ export class RoomConfigComponent implements OnDestroy {
},
e2ee: {
enabled: formValue.e2eeEnabled ?? false
},
captions: {
enabled: formValue.captionsEnabled ?? false
}
}
};
@ -102,6 +105,11 @@ export class RoomConfigComponent implements OnDestroy {
this.configForm.patchValue({ virtualBackgroundEnabled: isEnabled });
}
onCaptionsToggleChange(event: any): void {
const isEnabled = event.checked;
this.configForm.patchValue({ captionsEnabled: isEnabled });
}
get chatEnabled(): boolean {
return this.configForm.value.chatEnabled || false;
}
@ -113,4 +121,8 @@ export class RoomConfigComponent implements OnDestroy {
get e2eeEnabled(): boolean {
return this.configForm.value.e2eeEnabled ?? false;
}
get captionsEnabled(): boolean {
return this.configForm.value.captionsEnabled ?? false;
}
}

View File

@ -19,7 +19,8 @@ const DEFAULT_CONFIG: MeetRoomConfig = {
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: false }
e2ee: { enabled: false },
captions: { enabled: false }
};
/**
@ -192,7 +193,8 @@ export class RoomWizardStateService {
formGroup: this.formBuilder.group({
chatEnabled: initialRoomOptions.config!.chat!.enabled,
virtualBackgroundEnabled: initialRoomOptions.config!.virtualBackground!.enabled,
e2eeEnabled: initialRoomOptions.config!.e2ee!.enabled
e2eeEnabled: initialRoomOptions.config!.e2ee!.enabled,
captionsEnabled: initialRoomOptions.config!.captions!.enabled
})
}
];
@ -267,6 +269,10 @@ export class RoomWizardStateService {
...currentOptions.config?.e2ee,
...stepData.config?.e2ee
},
captions: {
...currentOptions.config?.captions,
...stepData.config?.captions
},
recording: {
...currentOptions.config?.recording,
// If recording is explicitly set in stepData, use it

View File

@ -31,6 +31,7 @@ export interface ApplicationFeatures {
showRecordingPanel: boolean;
showChat: boolean;
showBackgrounds: boolean;
showCaptions: boolean;
showParticipantList: boolean;
showSettings: boolean;
showFullscreen: boolean;

View File

@ -22,6 +22,7 @@ const DEFAULT_FEATURES: ApplicationFeatures = {
showRecordingPanel: true,
showChat: true,
showBackgrounds: true,
showCaptions: false,
showParticipantList: true,
showSettings: true,
showFullscreen: true,
@ -115,6 +116,7 @@ export class FeatureConfigurationService {
features.showRecordingPanel = roomConfig.recording.enabled;
features.showChat = roomConfig.chat.enabled;
features.showBackgrounds = roomConfig.virtualBackground.enabled;
features.showCaptions = roomConfig.captions?.enabled ?? false;
}
// Apply room member permissions (these can restrict enabled features)
@ -130,7 +132,6 @@ export class FeatureConfigurationService {
if (features.showBackgrounds) {
features.showBackgrounds = permissions.meet.canChangeVirtualBackground;
}
// Media features
const canPublish = permissions.livekit.canPublish;
const canPublishSources = permissions.livekit.canPublishSources ?? [];