Merge branch 'e2ee_support'

pull/856/head
Carlos Santos 2025-11-05 17:51:57 +01:00
commit 41bca24bfa
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({ @Component({
selector: 'ov-media-element', selector: 'ov-media-element',
template: ` 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> <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> <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 _muted: boolean = false;
private previousTrack: Track | null = null; private previousTrack: Track | null = null;
@Input() showAvatar: boolean; @Input() showAvatar: boolean = false;
@Input() avatarColor: string; @Input() avatarColor: string = '#000000';
@Input() avatarName: string; @Input() avatarName: string = 'User';
@Input() isLocal: boolean; @Input() isLocal: boolean = false;
@Input() hasEncryptionError: boolean = false;
@ViewChild('videoElement', { static: false }) @ViewChild('videoElement', { static: false })
set videoElement(element: ElementRef) { set videoElement(element: ElementRef) {

View File

@ -8,7 +8,7 @@
[style.background-color]="_participant?.colorProfile" [style.background-color]="_participant?.colorProfile"
[attr.aria-label]="'Avatar for ' + participantDisplayName" [attr.aria-label]="'Avatar for ' + participantDisplayName"
> >
<mat-icon>person</mat-icon> <mat-icon>{{_participant.hasEncryptionError ? 'lock_person' : 'person'}}</mat-icon>
</div> </div>
<!-- Content section with name and status --> <!-- Content section with name and status -->
@ -28,42 +28,48 @@
</div> </div>
</div> </div>
<div class="participant-subtitle"> @if (!_participant.hasEncryptionError) {
<span class="status-indicator"> <div class="participant-subtitle">
{{ _participant | tracksPublishedTypes }} <span class="status-indicator">
</span> {{ _participant | tracksPublishedTypes }}
</div> </span>
</div>
}
</div> </div>
<!-- Action buttons section --> <!-- Action buttons section -->
<div class="participant-action-buttons"> @if (!_participant.hasEncryptionError) {
<!-- Mute/Unmute button for remote participants --> <div class="participant-action-buttons">
<button <!-- Mute/Unmute button for remote participants -->
mat-icon-button <button
id="mute-btn" mat-icon-button
*ngIf="!isLocalParticipant && showMuteButton" id="mute-btn"
[class.warn-btn]="_participant?.isMutedForcibly" *ngIf="!isLocalParticipant && showMuteButton"
(click)="toggleMuteForcibly()" [class.warn-btn]="_participant?.isMutedForcibly"
[disabled]="!_participant" (click)="toggleMuteForcibly()"
[disableRipple]="true" [disabled]="!_participant"
[attr.aria-label]=" [disableRipple]="true"
_participant?.isMutedForcibly [attr.aria-label]="
? ('PANEL.PARTICIPANTS.UNMUTE' | translate) + ' ' + participantDisplayName _participant?.isMutedForcibly
: ('PANEL.PARTICIPANTS.MUTE' | translate) + ' ' + participantDisplayName ? ('PANEL.PARTICIPANTS.UNMUTE' | translate) + ' ' + participantDisplayName
" : ('PANEL.PARTICIPANTS.MUTE' | translate) + ' ' + participantDisplayName
[matTooltip]=" "
_participant?.isMutedForcibly ? ('PANEL.PARTICIPANTS.UNMUTE' | translate) : ('PANEL.PARTICIPANTS.MUTE' | translate) [matTooltip]="
" _participant?.isMutedForcibly
> ? ('PANEL.PARTICIPANTS.UNMUTE' | translate)
<mat-icon *ngIf="!_participant?.isMutedForcibly">volume_up</mat-icon> : ('PANEL.PARTICIPANTS.MUTE' | translate)
<mat-icon *ngIf="_participant?.isMutedForcibly">volume_off</mat-icon> "
</button> >
<mat-icon *ngIf="!_participant?.isMutedForcibly">volume_up</mat-icon>
<mat-icon *ngIf="_participant?.isMutedForcibly">volume_off</mat-icon>
</button>
<!-- External item elements with improved structure --> <!-- External item elements with improved structure -->
<div class="external-elements" *ngIf="hasExternalElements"> <div class="external-elements" *ngIf="hasExternalElements">
<ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container> <ng-container *ngTemplateOutlet="participantPanelItemElementsTemplate"></ng-container>
</div>
</div> </div>
</div> }
</div> </div>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>

View File

@ -230,7 +230,8 @@ export class SessionComponent implements OnInit, OnDestroy {
} }
// this.subscribeToCaptionLanguage(); // this.subscribeToCaptionLanguage();
this.subcribeToActiveSpeakersChanged(); this.subscribeToEncryptionErrors();
this.subscribeToActiveSpeakersChanged();
this.subscribeToParticipantConnected(); this.subscribeToParticipantConnected();
this.subscribeToTrackSubscribed(); this.subscribeToTrackSubscribed();
this.subscribeToTrackUnsubscribed(); 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); 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.room.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
this.participantService.setSpeaking(speakers); this.participantService.setSpeaking(speakers);
}); });

View File

@ -34,47 +34,50 @@
[avatarName]="_track.participant.name" [avatarName]="_track.participant.name"
[muted]="_track.isMutedForcibly" [muted]="_track.isMutedForcibly"
[isLocal]="_track.participant.isLocal" [isLocal]="_track.participant.isLocal"
[hasEncryptionError]="_track.participant.hasEncryptionError"
></ov-media-element> ></ov-media-element>
<div class="status-icons"> @if (!_track.participant.hasEncryptionError) {
<mat-icon id="status-mic" fontIcon="mic_off" *ngIf="!_track.participant?.isMicrophoneEnabled"></mat-icon> <div class="status-icons">
<mat-icon id="status-muted-forcibly" fontIcon="volume_off" *ngIf="_track.isMutedForcibly"></mat-icon> <mat-icon id="status-mic" fontIcon="mic_off" *ngIf="!_track.participant?.isMicrophoneEnabled"></mat-icon>
<mat-icon id="status-pinned" fontIcon="push_pin" *ngIf="_track.isPinned"></mat-icon> <mat-icon id="status-muted-forcibly" fontIcon="volume_off" *ngIf="_track.isMutedForcibly"></mat-icon>
</div> <mat-icon id="status-pinned" fontIcon="push_pin" *ngIf="_track.isPinned"></mat-icon>
<div class="stream-video-controls" *ngIf="!isMinimal && showVideoControls && mouseHovering">
<div class="flex-container">
<button
mat-icon-button
id="pin-btn"
(click)="toggleVideoPinned()"
[matTooltip]="_track.isPinned ? ('STREAM.UNPIN' | translate) : ('STREAM.PIN' | translate)"
>
<mat-icon *ngIf="_track.isPinned" fontSet="material-symbols-outlined" fontIcon="keep">keep_off</mat-icon>
<mat-icon *ngIf="!_track.isPinned" id="status-pinned" fontIcon="push_pin"></mat-icon>
</button>
<button
*ngIf="!_track.participant.isLocal"
mat-icon-button
id="silence-btn"
(click)="toggleMuteForcibly()"
[class.muted-btn]="_track.isMutedForcibly"
[matTooltip]="_track.isMutedForcibly ? ('STREAM.UNMUTE_SOUND' | translate) : ('STREAM.MUTE_SOUND' | translate)"
>
<mat-icon *ngIf="_track.isMutedForcibly">volume_off</mat-icon>
<mat-icon *ngIf="!_track.isMutedForcibly">volume_up</mat-icon>
</button>
<button
*ngIf="_track.participant.isLocal"
mat-icon-button
id="minimize-btn"
[disabled]="_track.isPinned"
(click)="toggleMinimize()"
[matTooltip]="_track.isMinimized ? ('STREAM.MAXIMIZE' | translate) : ('STREAM.MINIMIZE' | translate)"
>
<mat-icon *ngIf="_track.isMinimized">open_in_full</mat-icon>
<mat-icon *ngIf="!_track.isMinimized">close_fullscreen</mat-icon>
</button>
</div> </div>
</div>
<div class="stream-video-controls" *ngIf="!isMinimal && showVideoControls && mouseHovering">
<div class="flex-container">
<button
mat-icon-button
id="pin-btn"
(click)="toggleVideoPinned()"
[matTooltip]="_track.isPinned ? ('STREAM.UNPIN' | translate) : ('STREAM.PIN' | translate)"
>
<mat-icon *ngIf="_track.isPinned" fontSet="material-symbols-outlined" fontIcon="keep">keep_off</mat-icon>
<mat-icon *ngIf="!_track.isPinned" id="status-pinned" fontIcon="push_pin"></mat-icon>
</button>
<button
*ngIf="!_track.participant.isLocal"
mat-icon-button
id="silence-btn"
(click)="toggleMuteForcibly()"
[class.muted-btn]="_track.isMutedForcibly"
[matTooltip]="_track.isMutedForcibly ? ('STREAM.UNMUTE_SOUND' | translate) : ('STREAM.MUTE_SOUND' | translate)"
>
<mat-icon *ngIf="_track.isMutedForcibly">volume_off</mat-icon>
<mat-icon *ngIf="!_track.isMutedForcibly">volume_up</mat-icon>
</button>
<button
*ngIf="_track.participant.isLocal"
mat-icon-button
id="minimize-btn"
[disabled]="_track.isPinned"
(click)="toggleMinimize()"
[matTooltip]="_track.isMinimized ? ('STREAM.MAXIMIZE' | translate) : ('STREAM.MINIMIZE' | translate)"
>
<mat-icon *ngIf="_track.isMinimized">open_in_full</mat-icon>
<mat-icon *ngIf="!_track.isMinimized">close_fullscreen</mat-icon>
</button>
</div>
</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, RecordingActivityViewRecordingsButtonDirective,
RecordingActivityShowRecordingsListDirective, RecordingActivityShowRecordingsListDirective,
ToolbarRoomNameDirective, ToolbarRoomNameDirective,
ShowThemeSelectorDirective ShowThemeSelectorDirective,
E2EEKeyDirective
} from './internals.directive'; } from './internals.directive';
import { ParticipantPanelItemMuteButtonDirective } from './participant-panel-item.directive'; import { ParticipantPanelItemMuteButtonDirective } from './participant-panel-item.directive';
import { import {
@ -113,7 +114,8 @@ const directives = [
RecordingActivityViewRecordingsButtonDirective, RecordingActivityViewRecordingsButtonDirective,
RecordingActivityShowRecordingsListDirective, RecordingActivityShowRecordingsListDirective,
ToolbarRoomNameDirective, ToolbarRoomNameDirective,
ShowThemeSelectorDirective ShowThemeSelectorDirective,
E2EEKeyDirective
]; ];
@NgModule({ @NgModule({

View File

@ -570,3 +570,51 @@ export class ShowThemeSelectorDirective implements AfterViewInit, OnDestroy {
this.libService.updateGeneralConfig({ showThemeSelector: value }); 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_GENERIC": "加载视频时发生错误。",
"MEDIA_ERR_NETWORK": "网络错误导致视频下载中途失败。", "MEDIA_ERR_NETWORK": "网络错误导致视频下载中途失败。",
"MEDIA_ERR_DECODE": "由于损坏问题或视频使用了您的浏览器不支持的功能,视频播放被中止。", "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_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_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_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_GENERIC": "An error occurred while loading the video.",
"MEDIA_ERR_NETWORK": "A network error caused the video download to fail part-way.", "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_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_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_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_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_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_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_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_GENERIC": "वीडियो लोड करते समय एक त्रुटि हुई।",
"MEDIA_ERR_NETWORK": "नेटवर्क त्रुटि के कारण वीडियो डाउनलोड बीच में ही विफल हो गया।", "MEDIA_ERR_NETWORK": "नेटवर्क त्रुटि के कारण वीडियो डाउनलोड बीच में ही विफल हो गया।",
"MEDIA_ERR_DECODE": "वीडियो प्लेबैक को एक भ्रष्टाचार समस्या या क्योंकि वीडियो ने आपके ब्राउज़र द्वारा समर्थित नहीं की गई सुविधाओं का उपयोग किया था, के कारण रोक दिया गया था।", "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_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_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_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_GENERIC": "ビデオの読み込み中にエラーが発生しました。",
"MEDIA_ERR_NETWORK": "ネットワークエラーにより、ビデオのダウンロードが途中で失敗しました。", "MEDIA_ERR_NETWORK": "ネットワークエラーにより、ビデオのダウンロードが途中で失敗しました。",
"MEDIA_ERR_DECODE": "破損の問題またはビデオがブラウザでサポートされていない機能を使用したために、ビデオの再生が中止されました。", "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_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_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_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_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_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_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 room: Room | undefined;
private speaking: boolean = false; private speaking: boolean = false;
private customVideoTrack: Partial<ParticipantTrackPublication>; private customVideoTrack: Partial<ParticipantTrackPublication>;
private _hasEncryptionError: boolean = false;
constructor(props: ParticipantProperties) { constructor(props: ParticipantProperties) {
this.participant = props.participant; this.participant = props.participant;
@ -550,4 +551,22 @@ export class ParticipantModel {
setMutedForcibly(muted: boolean) { setMutedForcibly(muted: boolean) {
this.tracks.forEach((track) => (track.isMutedForcibly = muted)); 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 { AdminDashboardComponent } from './admin/admin-dashboard/admin-dashboard.component';
import { AdminLoginComponent } from './admin/admin-login/admin-login.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 { CaptionsComponent } from './components/captions/captions.component';
import { ProFeatureDialogTemplateComponent } from './components/dialogs/pro-feature-dialog.component'; import { ProFeatureDialogTemplateComponent } from './components/dialogs/pro-feature-dialog.component';
import { ActivitiesPanelComponent } from './components/panel/activities-panel/activities-panel.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 { AppMaterialModule } from './openvidu-components-angular.material.module';
import { ThemeSelectorComponent } from './components/settings/theme-selector/theme-selector.component'; import { ThemeSelectorComponent } from './components/settings/theme-selector/theme-selector.component';
import { LandscapeWarningComponent } from './components/landscape-warning/landscape-warning.component'; import { LandscapeWarningComponent } from './components/landscape-warning/landscape-warning.component';
import { VideoPosterComponent } from './components/video-poster/video-poster.component';
const publicComponents = [ const publicComponents = [
AdminDashboardComponent, AdminDashboardComponent,
@ -74,7 +74,7 @@ const privateComponents = [
ProFeatureDialogTemplateComponent, ProFeatureDialogTemplateComponent,
RecordingDialogComponent, RecordingDialogComponent,
DeleteDialogComponent, DeleteDialogComponent,
AvatarProfileComponent, VideoPosterComponent,
MediaElementComponent, MediaElementComponent,
VideoDevicesComponent, VideoDevicesComponent,
AudioDevicesComponent, AudioDevicesComponent,

View File

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

View File

@ -6,6 +6,7 @@ import {
AudioCaptureOptions, AudioCaptureOptions,
ConnectionState, ConnectionState,
CreateLocalTracksOptions, CreateLocalTracksOptions,
ExternalE2EEKeyProvider,
LocalAudioTrack, LocalAudioTrack,
LocalTrack, LocalTrack,
LocalVideoTrack, LocalVideoTrack,
@ -17,6 +18,7 @@ import {
createLocalTracks createLocalTracks
} from 'livekit-client'; } from 'livekit-client';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { OpenViduComponentsConfigService } from '../config/directive-config.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -31,6 +33,7 @@ export class OpenViduService {
// private _isSttReady: BehaviorSubject<boolean> = new BehaviorSubject(true); // private _isSttReady: BehaviorSubject<boolean> = new BehaviorSubject(true);
private room: Room; private room: Room;
private keyProvider: ExternalE2EEKeyProvider | undefined;
/** /**
* @internal * @internal
@ -53,7 +56,8 @@ export class OpenViduService {
constructor( constructor(
private loggerSrv: LoggerService, private loggerSrv: LoggerService,
private deviceService: DeviceService, private deviceService: DeviceService,
private storageService: StorageService private storageService: StorageService,
private configService: OpenViduComponentsConfigService
) { ) {
this.log = this.loggerSrv.get('OpenViduService'); this.log = this.loggerSrv.get('OpenViduService');
// this.isSttReadyObs = this._isSttReady.asObservable(); // this.isSttReadyObs = this._isSttReady.asObservable();
@ -64,14 +68,25 @@ export class OpenViduService {
* @internal * @internal
*/ */
initRoom(): void { initRoom(): void {
// If room already exists, don't recreate it // Check if E2EE configuration needs to be applied
if (this.room) { 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'); this.log.d('Room already initialized, skipping re-initialization');
return; 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 videoDeviceId = this.deviceService.getCameraSelected()?.device ?? undefined;
const audioDeviceId = this.deviceService.getMicrophoneSelected()?.device ?? undefined; const audioDeviceId = this.deviceService.getMicrophoneSelected()?.device ?? undefined;
const roomOptions: RoomOptions = { const roomOptions: RoomOptions = {
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
@ -93,15 +108,35 @@ export class OpenViduService {
stopLocalTrackOnUnpublish: true, stopLocalTrackOnUnpublish: true,
disconnectOnPageLeave: true disconnectOnPageLeave: true
}; };
// 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.room = new Room(roomOptions);
this.log.d('Room initialized successfully'); this.log.d('Room initialized successfully');
} } /**
/**
* Connects local participant to the room * Connects local participant to the room
*/ */
async connectRoom(): Promise<void> { async connectRoom(): Promise<void> {
try { 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); await this.room.connect(this.livekitUrl, this.livekitToken);
this.log.d(`Successfully connected to room ${this.room.name}`); this.log.d(`Successfully connected to room ${this.room.name}`);
const participantName = this.storageService.getParticipantName(); 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. * Returns the local participant name.
*/ */