ov-components: Add E2EEKey directive and integrate E2EE configuration in OpenViduService

ov-components: Update E2EE error messages for improved clarity across multiple languages
pull/856/head
Carlos Santos 2025-11-03 11:05:54 +01:00
parent 4bf413cffc
commit 9950a2ba21
26 changed files with 407 additions and 162 deletions

View File

@ -1,31 +0,0 @@
.poster {
height: 100%;
width: 100%;
background-color: var(--ov-video-background, var(--ov-primary-action-color));
position: absolute;
z-index: 888;
border-radius: var(--ov-video-radius);
}
.initial {
position: absolute;
display: inline-grid;
z-index: 1;
margin: auto;
bottom: 0;
right: 0;
left: 0;
top: 0;
height: 70px;
width: 70px;
border-radius: var(--ov-video-radius);
border: 2px solid var(--ov-text-primary-color);
color: var(--ov-video-background, var(--ov-text-primary-color));
}
#poster-text {
padding: 0px !important;
font-weight: bold;
font-size: 40px;
margin: auto;
}

View File

@ -1,31 +0,0 @@
import { Component, Input } from '@angular/core';
/**
* @internal
*/
@Component({
selector: 'ov-avatar-profile',
template: `
<div class="poster" id="video-poster">
@if (letter) {
<div class="initial" [ngStyle]="{ 'background-color': color }">
<span id="poster-text">{{ letter }}</span>
</div>
}
</div>
`,
styleUrls: ['./avatar-profile.component.scss'],
standalone: false
})
export class AvatarProfileComponent {
letter: string;
@Input()
set name(name: string) {
if (name) this.letter = name[0];
}
@Input() color;
constructor() {}
}

View File

@ -8,7 +8,13 @@ import { Track } from 'livekit-client';
@Component({
selector: 'ov-media-element',
template: `
<ov-avatar-profile @posterAnimation *ngIf="showAvatar" [name]="avatarName" [color]="avatarColor"></ov-avatar-profile>
<ov-video-poster
@posterAnimation
[showAvatar]="showAvatar"
[nickname]="avatarName"
[color]="avatarColor"
[hasEncryptionError]="hasEncryptionError"
></ov-video-poster>
<video #videoElement *ngIf="_track?.kind === 'video'" class="OV_video-element" [attr.id]="_track?.sid"></video>
<audio #audioElement *ngIf="_track?.kind === 'audio'" [attr.id]="_track?.sid"></audio>
`,
@ -29,10 +35,11 @@ export class MediaElementComponent implements AfterViewInit, OnDestroy {
private _muted: boolean = false;
private previousTrack: Track | null = null;
@Input() showAvatar: boolean;
@Input() avatarColor: string;
@Input() avatarName: string;
@Input() isLocal: boolean;
@Input() showAvatar: boolean = false;
@Input() avatarColor: string = '#000000';
@Input() avatarName: string = 'User';
@Input() isLocal: boolean = false;
@Input() hasEncryptionError: boolean = false;
@ViewChild('videoElement', { static: false })
set videoElement(element: ElementRef) {

View File

@ -8,7 +8,7 @@
[style.background-color]="_participant?.colorProfile"
[attr.aria-label]="'Avatar for ' + participantDisplayName"
>
<mat-icon>person</mat-icon>
<mat-icon>{{_participant.hasEncryptionError ? 'lock_person' : 'person'}}</mat-icon>
</div>
<!-- Content section with name and status -->
@ -28,14 +28,17 @@
</div>
</div>
@if (!_participant.hasEncryptionError) {
<div class="participant-subtitle">
<span class="status-indicator">
{{ _participant | tracksPublishedTypes }}
</span>
</div>
}
</div>
<!-- Action buttons section -->
@if (!_participant.hasEncryptionError) {
<div class="participant-action-buttons">
<!-- Mute/Unmute button for remote participants -->
<button
@ -52,7 +55,9 @@
: ('PANEL.PARTICIPANTS.MUTE' | translate) + ' ' + participantDisplayName
"
[matTooltip]="
_participant?.isMutedForcibly ? ('PANEL.PARTICIPANTS.UNMUTE' | translate) : ('PANEL.PARTICIPANTS.MUTE' | translate)
_participant?.isMutedForcibly
? ('PANEL.PARTICIPANTS.UNMUTE' | translate)
: ('PANEL.PARTICIPANTS.MUTE' | translate)
"
>
<mat-icon *ngIf="!_participant?.isMutedForcibly">volume_up</mat-icon>
@ -64,6 +69,7 @@
<ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container>
</div>
</div>
}
</div>
</mat-list-item>
</mat-list>

View File

@ -230,7 +230,8 @@ export class SessionComponent implements OnInit, OnDestroy {
}
// this.subscribeToCaptionLanguage();
this.subcribeToActiveSpeakersChanged();
this.subscribeToEncryptionErrors();
this.subscribeToActiveSpeakersChanged();
this.subscribeToParticipantConnected();
this.subscribeToTrackSubscribed();
this.subscribeToTrackUnsubscribed();
@ -261,7 +262,17 @@ export class SessionComponent implements OnInit, OnDestroy {
this.actionService.openDialog(this.translateService.translate('ERRORS.SESSION'), error?.error || error?.message || error);
}
}
subcribeToActiveSpeakersChanged() {
protected subscribeToEncryptionErrors() {
// TODO: LiveKit does not provide the participant who has the encryption error yet.
// Waiting for this issue to be solved: https://github.com/livekit/client-sdk-js/issues/1722
this.room.on(RoomEvent.EncryptionError, (error: Error, participant?: RemoteParticipant) => {
if (!participant) return;
this.participantService.setEncryptionError(participant?.sid, true);
});
}
protected subscribeToActiveSpeakersChanged() {
this.room.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
this.participantService.setSpeaking(speakers);
});

View File

@ -34,8 +34,10 @@
[avatarName]="_track.participant.name"
[muted]="_track.isMutedForcibly"
[isLocal]="_track.participant.isLocal"
[hasEncryptionError]="_track.participant.hasEncryptionError"
></ov-media-element>
@if (!_track.participant.hasEncryptionError) {
<div class="status-icons">
<mat-icon id="status-mic" fontIcon="mic_off" *ngIf="!_track.participant?.isMicrophoneEnabled"></mat-icon>
<mat-icon id="status-muted-forcibly" fontIcon="volume_off" *ngIf="_track.isMutedForcibly"></mat-icon>
@ -77,4 +79,5 @@
</button>
</div>
</div>
}
</div>

View File

@ -0,0 +1,31 @@
@if (hasEncryptionError) {
<div class="encryption-error-poster">
<div class="encryption-error-content">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
<line x1="12" y1="16" x2="12" y2="18"></line>
</svg>
<h3>{{ 'ERRORS.E2EE_ERROR_TITLE' | translate }}</h3>
<p>{{ 'ERRORS.E2EE_ERROR_CONTENT' | translate }}</p>
</div>
</div>
} @else if (showAvatar) {
<div class="poster" id="video-poster">
@if (letter) {
<div class="initial" [ngStyle]="{ 'background-color': color }">
<span id="poster-text">{{ letter }}</span>
</div>
}
</div>
}

View File

@ -0,0 +1,74 @@
.encryption-error-poster {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
backdrop-filter: blur(10px);
border-radius: var(--ov-video-radius);
z-index: 10;
}
.encryption-error-content {
text-align: center;
padding: 2rem;
max-width: 300px;
color: var(--ov-text-primary-color);
svg {
color: #dc3545;
margin-bottom: 1rem;
filter: drop-shadow(0 2px 4px rgba(220, 53, 69, 0.3));
}
h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: var(--ov-text-primary-color);
}
p {
font-size: 0.875rem;
margin: 0;
opacity: 0.8;
line-height: 1.4;
color: var(--ov-text-secondary-color);
}
}
.poster {
height: 100%;
width: 100%;
background-color: var(--ov-video-background, var(--ov-primary-action-color));
position: absolute;
z-index: 888;
border-radius: var(--ov-video-radius);
}
.initial {
position: absolute;
display: inline-grid;
z-index: 1;
margin: auto;
bottom: 0;
right: 0;
left: 0;
top: 0;
height: 70px;
width: 70px;
border-radius: var(--ov-video-radius);
border: 2px solid var(--ov-text-primary-color);
color: var(--ov-video-background, var(--ov-text-primary-color));
}
#poster-text {
padding: 0px !important;
font-weight: bold;
font-size: 40px;
margin: auto;
}

View File

@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ov-video-poster',
templateUrl: './video-poster.component.html',
styleUrl: './video-poster.component.scss',
standalone: false
})
export class VideoPosterComponent {
letter: string = '';
@Input()
set nickname(name: string) {
if (name) this.letter = name[0];
}
@Input() color: string = '#000000';
@Input() showAvatar: boolean = true;
@Input() hasEncryptionError: boolean = false;
}

View File

@ -18,7 +18,8 @@ import {
RecordingActivityViewRecordingsButtonDirective,
RecordingActivityShowRecordingsListDirective,
ToolbarRoomNameDirective,
ShowThemeSelectorDirective
ShowThemeSelectorDirective,
E2EEKeyDirective
} from './internals.directive';
import { ParticipantPanelItemMuteButtonDirective } from './participant-panel-item.directive';
import {
@ -113,7 +114,8 @@ const directives = [
RecordingActivityViewRecordingsButtonDirective,
RecordingActivityShowRecordingsListDirective,
ToolbarRoomNameDirective,
ShowThemeSelectorDirective
ShowThemeSelectorDirective,
E2EEKeyDirective
];
@NgModule({

View File

@ -570,3 +570,51 @@ export class ShowThemeSelectorDirective implements AfterViewInit, OnDestroy {
this.libService.updateGeneralConfig({ showThemeSelector: value });
}
}
/**
* @internal
*
* The **e2eeKey** directive allows to configure end-to-end encryption for the videoconference.
* When provided, the room will be configured with E2EE using an external key provider.
*
* Default: `undefined`
*
* Usage:
* <ov-videoconference [e2eeKey]="yourEncryptionKey"></ov-videoconference>
*/
@Directive({
selector: 'ov-videoconference[e2eeKey]',
standalone: false
})
export class E2EEKeyDirective implements AfterViewInit, OnDestroy {
@Input() set e2eeKey(value: string | undefined) {
this._value = value;
this.update(this._value);
}
private _value: string | undefined;
constructor(
public elementRef: ElementRef,
private libService: OpenViduComponentsConfigService
) {}
ngAfterViewInit() {
this.update(this._value);
}
ngOnDestroy(): void {
this.clear();
}
private clear() {
this._value = undefined;
this.update(this._value);
}
private update(value: string | undefined) {
// Only update if value is valid (not undefined, not null, not empty string)
const validValue = value && value.trim() !== '' ? value.trim() : undefined;
this.libService.updateGeneralConfig({ e2eeKey: validValue });
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "加载视频时发生错误。",
"MEDIA_ERR_NETWORK": "网络错误导致视频下载中途失败。",
"MEDIA_ERR_DECODE": "由于损坏问题或视频使用了您的浏览器不支持的功能,视频播放被中止。",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "流媒体录制错误:无法访问 S3。"
"MEDIA_ERR_SRC_NOT_SUPPORTED": "流媒体录制错误:无法访问 S3。",
"E2EE_ERROR_TITLE": "房间密码错误",
"E2EE_ERROR_CONTENT": "此参与者使用了不同的安全密钥。无法显示视频。"
}
}

View File

@ -170,6 +170,8 @@
"MEDIA_ERR_GENERIC": "Beim Laden des Videos ist ein Fehler aufgetreten.",
"MEDIA_ERR_NETWORK": "Ein Netzwerkfehler führte dazu, dass der Video-Download teilweise fehlschlug.",
"MEDIA_ERR_DECODE": "Die Videowiedergabe wurde aufgrund eines Korruptionsproblems oder weil das Video Funktionen verwendet, die Ihr Browser nicht unterstützt, abgebrochen.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Fehler beim Streaming der Aufnahme: S3 konnte nicht erreicht werden."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Fehler beim Streaming der Aufnahme: S3 konnte nicht erreicht werden.",
"E2EE_ERROR_TITLE": "Raum-Passwortfehler",
"E2EE_ERROR_CONTENT": "Dieser Teilnehmer verwendet einen anderen Sicherheitsschlüssel. Video kann nicht angezeigt werden."
}
}

View File

@ -179,6 +179,8 @@
"MEDIA_ERR_GENERIC": "An error occurred while loading the video.",
"MEDIA_ERR_NETWORK": "A network error caused the video download to fail part-way.",
"MEDIA_ERR_DECODE": "The video playback was aborted due to a corruption problem or because the video used features your browser did not support.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Error streaming recording: S3 could not be reached."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Error streaming recording: S3 could not be reached.",
"E2EE_ERROR_TITLE": "Room password error",
"E2EE_ERROR_CONTENT": "This participant is using a different encryption key. Video cannot be displayed."
}
}

View File

@ -174,6 +174,8 @@
"MEDIA_ERR_GENERIC": "Ocurrió un error al cargar el video.",
"MEDIA_ERR_NETWORK": "Un error de red causó que la descarga del video fallara a mitad de camino.",
"MEDIA_ERR_DECODE": "La reproducción del video se interrumpió debido a un problema de corrupción o porque el video utiliza características que su navegador no soporta.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Error al transmitir la grabación: no se pudo conectar con S3."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Error al transmitir la grabación: no se pudo conectar con S3.",
"E2EE_ERROR_TITLE": "Error de contraseña de sala",
"E2EE_ERROR_CONTENT": "Este participante está utilizando una clave de cifrado diferente. No se puede mostrar el video."
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "Une erreur s'est produite lors du chargement de la vidéo.",
"MEDIA_ERR_NETWORK": "Une erreur de réseau a causé l'échec du téléchargement de la vidéo en cours de route.",
"MEDIA_ERR_DECODE": "La lecture de la vidéo a été interrompue en raison d'un problème de corruption ou parce que la vidéo utilise des fonctionnalités que votre navigateur ne prend pas en charge.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Erreur de diffusion de l'enregistrement : S3 n'a pas pu être atteint."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Erreur de diffusion de l'enregistrement : S3 n'a pas pu être atteint.",
"E2EE_ERROR_TITLE": "Erreur de mot de passe de la salle",
"E2EE_ERROR_CONTENT": "Ce participant utilise une clé de sécurité différente. La vidéo ne peut pas être affichée."
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "वीडियो लोड करते समय एक त्रुटि हुई।",
"MEDIA_ERR_NETWORK": "नेटवर्क त्रुटि के कारण वीडियो डाउनलोड बीच में ही विफल हो गया।",
"MEDIA_ERR_DECODE": "वीडियो प्लेबैक को एक भ्रष्टाचार समस्या या क्योंकि वीडियो ने आपके ब्राउज़र द्वारा समर्थित नहीं की गई सुविधाओं का उपयोग किया था, के कारण रोक दिया गया था।",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "स्ट्रीमिंग रिकॉर्डिंग में त्रुटि: S3 तक पहुंचा नहीं जा सका।"
"MEDIA_ERR_SRC_NOT_SUPPORTED": "स्ट्रीमिंग रिकॉर्डिंग में त्रुटि: S3 तक पहुंचा नहीं जा सका।",
"E2EE_ERROR_TITLE": "कक्ष पासवर्ड त्रुटि",
"E2EE_ERROR_CONTENT": "यह प्रतिभागी एक अलग सुरक्षा कुंजी का उपयोग कर रहा है। वीडियो प्रदर्शित नहीं किया जा सकता।"
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "Si è verificato un errore durante il caricamento del video.",
"MEDIA_ERR_NETWORK": "Un errore di rete ha causato l'interruzione del download del video a metà strada.",
"MEDIA_ERR_DECODE": "La riproduzione del video è stata interrotta a causa di un problema di corruzione o perché il video utilizzava funzionalità non supportate dal tuo browser.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Errore nello streaming della registrazione: impossibile raggiungere S3."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Errore nello streaming della registrazione: impossibile raggiungere S3.",
"E2EE_ERROR_TITLE": "Errore password della stanza",
"E2EE_ERROR_CONTENT": "Questo partecipante sta utilizzando una chiave di crittografia diversa. Il video non può essere visualizzato."
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "ビデオの読み込み中にエラーが発生しました。",
"MEDIA_ERR_NETWORK": "ネットワークエラーにより、ビデオのダウンロードが途中で失敗しました。",
"MEDIA_ERR_DECODE": "破損の問題またはビデオがブラウザでサポートされていない機能を使用したために、ビデオの再生が中止されました。",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "録画のストリーミングエラーS3にアクセスできませんでした。"
"MEDIA_ERR_SRC_NOT_SUPPORTED": "録画のストリーミングエラーS3にアクセスできませんでした。",
"E2EE_ERROR_TITLE": "ルームパスワードエラー",
"E2EE_ERROR_CONTENT": "この参加者は異なるセキュリティキーを使用しています。ビデオを表示できません。"
}
}

View File

@ -175,6 +175,8 @@
"MEDIA_ERR_GENERIC": "Er is een fout opgetreden bij het laden van de video.",
"MEDIA_ERR_NETWORK": "Een netwerkfout heeft ertoe geleid dat het downloaden van de video halverwege is mislukt.",
"MEDIA_ERR_DECODE": "Het afspelen van de video is afgebroken vanwege een corruptieprobleem of omdat de video functies gebruikte die uw browser niet ondersteunde.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Fout bij het streamen van de opname: S3 kon niet worden bereikt."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Fout bij het streamen van de opname: S3 kon niet worden bereikt.",
"E2EE_ERROR_TITLE": "Kamerwachtwoordfout",
"E2EE_ERROR_CONTENT": "Deze deelnemer gebruikt een andere beveiligingssleutel. Video kan niet worden weergegeven."
}
}

View File

@ -176,6 +176,8 @@
"MEDIA_ERR_GENERIC": "Ocorreu um erro ao carregar o vídeo.",
"MEDIA_ERR_NETWORK": "Um erro de rede fez com que o download do vídeo falhasse parcialmente.",
"MEDIA_ERR_DECODE": "A reprodução do vídeo foi interrompida devido a um problema de corrupção ou porque o vídeo usou recursos que o seu navegador não suportava.",
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Erro ao transmitir a gravação: não foi possível acessar o S3."
"MEDIA_ERR_SRC_NOT_SUPPORTED": "Erro ao transmitir a gravação: não foi possível acessar o S3.",
"E2EE_ERROR_TITLE": "Erro de senha da sala",
"E2EE_ERROR_CONTENT": "Este participante está usando uma chave de criptografia diferente. O vídeo não pode ser exibido."
}
}

View File

@ -128,6 +128,7 @@ export class ParticipantModel {
private room: Room | undefined;
private speaking: boolean = false;
private customVideoTrack: Partial<ParticipantTrackPublication>;
private _hasEncryptionError: boolean = false;
constructor(props: ParticipantProperties) {
this.participant = props.participant;
@ -550,4 +551,22 @@ export class ParticipantModel {
setMutedForcibly(muted: boolean) {
this.tracks.forEach((track) => (track.isMutedForcibly = muted));
}
/**
* Gets whether this participant has an encryption error.
* This indicates that the participant cannot decrypt the video stream due to an incorrect encryption key.
* @returns boolean
*/
get hasEncryptionError(): boolean {
return this._hasEncryptionError;
}
/**
* Sets the encryption error state for this participant.
* @param hasError - Whether the participant has an encryption error
* @internal
*/
setEncryptionError(hasError: boolean) {
this._hasEncryptionError = hasError;
}
}

View File

@ -30,7 +30,6 @@ import { VideoconferenceComponent } from './components/videoconference/videoconf
import { AdminDashboardComponent } from './admin/admin-dashboard/admin-dashboard.component';
import { AdminLoginComponent } from './admin/admin-login/admin-login.component';
import { AvatarProfileComponent } from './components/avatar-profile/avatar-profile.component';
// import { CaptionsComponent } from './components/captions/captions.component';
import { ProFeatureDialogTemplateComponent } from './components/dialogs/pro-feature-dialog.component';
import { ActivitiesPanelComponent } from './components/panel/activities-panel/activities-panel.component';
@ -48,6 +47,7 @@ import { OpenViduComponentsDirectiveModule } from './directives/template/openvid
import { AppMaterialModule } from './openvidu-components-angular.material.module';
import { ThemeSelectorComponent } from './components/settings/theme-selector/theme-selector.component';
import { LandscapeWarningComponent } from './components/landscape-warning/landscape-warning.component';
import { VideoPosterComponent } from './components/video-poster/video-poster.component';
const publicComponents = [
AdminDashboardComponent,
@ -74,7 +74,7 @@ const privateComponents = [
ProFeatureDialogTemplateComponent,
RecordingDialogComponent,
DeleteDialogComponent,
AvatarProfileComponent,
VideoPosterComponent,
MediaElementComponent,
VideoDevicesComponent,
AudioDevicesComponent,

View File

@ -96,6 +96,7 @@ interface GeneralConfig {
showDisconnectionDialog: boolean;
showThemeSelector: boolean;
recordingStreamBaseUrl: string;
e2eeKey?: string;
}
/**
@ -302,7 +303,8 @@ export class OpenViduComponentsConfigService {
prejoinDisplayParticipantName: true,
showDisconnectionDialog: true,
showThemeSelector: false,
recordingStreamBaseUrl: 'call/api/recordings'
recordingStreamBaseUrl: 'call/api/recordings',
e2eeKey: undefined
});
private toolbarConfig = this.createToolbarConfigItem({
@ -413,6 +415,11 @@ export class OpenViduComponentsConfigService {
distinctUntilChanged(),
shareReplay(1)
);
e2eeKey$: Observable<string | undefined> = this.generalConfig.observable$.pipe(
map((config) => config.e2eeKey),
distinctUntilChanged(),
shareReplay(1)
);
// Stream observables
videoEnabled$: Observable<boolean> = this.streamConfig.observable$.pipe(map((config) => config.videoEnabled));
@ -565,6 +572,10 @@ export class OpenViduComponentsConfigService {
return baseUrl;
}
getE2EEKey(): string | undefined {
return this.generalConfig.subject.getValue().e2eeKey;
}
// Stream configuration methods
isVideoEnabled(): boolean {

View File

@ -6,6 +6,7 @@ import {
AudioCaptureOptions,
ConnectionState,
CreateLocalTracksOptions,
ExternalE2EEKeyProvider,
LocalAudioTrack,
LocalTrack,
LocalVideoTrack,
@ -17,6 +18,7 @@ import {
createLocalTracks
} from 'livekit-client';
import { StorageService } from '../storage/storage.service';
import { OpenViduComponentsConfigService } from '../config/directive-config.service';
@Injectable({
providedIn: 'root'
@ -31,6 +33,7 @@ export class OpenViduService {
// private _isSttReady: BehaviorSubject<boolean> = new BehaviorSubject(true);
private room: Room;
private keyProvider: ExternalE2EEKeyProvider | undefined;
/**
* @internal
@ -53,7 +56,8 @@ export class OpenViduService {
constructor(
private loggerSrv: LoggerService,
private deviceService: DeviceService,
private storageService: StorageService
private storageService: StorageService,
private configService: OpenViduComponentsConfigService
) {
this.log = this.loggerSrv.get('OpenViduService');
// this.isSttReadyObs = this._isSttReady.asObservable();
@ -64,14 +68,25 @@ export class OpenViduService {
* @internal
*/
initRoom(): void {
// If room already exists, don't recreate it
if (this.room) {
// Check if E2EE configuration needs to be applied
const e2eeKey = this.configService.getE2EEKey();
const needsE2EEConfig = e2eeKey && e2eeKey.trim() !== '' && !this.keyProvider;
// If room already exists and doesn't need E2EE reconfiguration, don't recreate it
if (this.room && !needsE2EEConfig) {
this.log.d('Room already initialized, skipping re-initialization');
return;
}
// If room exists but needs E2EE configuration, we need to recreate it
if (this.room && needsE2EEConfig) {
this.log.d('Room needs E2EE configuration, recreating room');
this.room = null as any;
}
const videoDeviceId = this.deviceService.getCameraSelected()?.device ?? undefined;
const audioDeviceId = this.deviceService.getMicrophoneSelected()?.device ?? undefined;
const roomOptions: RoomOptions = {
adaptiveStream: true,
dynacast: true,
@ -93,15 +108,35 @@ export class OpenViduService {
stopLocalTrackOnUnpublish: true,
disconnectOnPageLeave: true
};
this.room = new Room(roomOptions);
this.log.d('Room initialized successfully');
// Configure E2EE if key is provided
if (e2eeKey && e2eeKey.trim() !== '') {
this.log.d('Configuring E2EE with provided key');
this.keyProvider = new ExternalE2EEKeyProvider();
// Create worker using the copied livekit-client e2ee worker from assets
roomOptions.e2ee = {
keyProvider: this.keyProvider,
worker: new Worker('./assets/livekit/livekit-client.e2ee.worker.mjs', { type: 'module' })
};
}
/**
this.room = new Room(roomOptions);
this.log.d('Room initialized successfully');
} /**
* Connects local participant to the room
*/
async connectRoom(): Promise<void> {
try {
// Configure E2EE if key provider was initialized
if (this.keyProvider) {
const e2eeKey = this.configService.getE2EEKey();
if (e2eeKey) {
this.log.d('Setting E2EE key and enabling encryption');
await this.keyProvider.setKey(e2eeKey);
await this.room.setE2EEEnabled(true);
this.log.d('E2EE successfully enabled');
}
}
await this.room.connect(this.livekitUrl, this.livekitToken);
this.log.d(`Successfully connected to room ${this.room.name}`);
const participantName = this.storageService.getParticipantName();

View File

@ -284,6 +284,26 @@ export class ParticipantService {
});
}
/**
* Sets the encryption error state for a participant.
* This is called when a participant cannot decrypt video streams due to an incorrect encryption key.
* @param participantSid - The SID of the participant with the encryption error
* @param hasError - Whether the participant has an encryption error
* @internal
*/
setEncryptionError(participantSid: string, hasError: boolean) {
if (this.localParticipant?.sid === participantSid) {
this.localParticipant.setEncryptionError(hasError);
this.updateLocalParticipant();
} else {
const participant = this.remoteParticipants.find((p) => p.sid === participantSid);
if (participant) {
participant.setEncryptionError(hasError);
this.updateRemoteParticipants();
}
}
}
/**
* Returns the local participant name.
*/