Restructure communication components

separates the "Jitsi component" into an own module, several services and components.
This commit is contained in:
Sean 2021-06-02 15:09:26 +02:00
parent e0da18a0e6
commit 1504e33607
59 changed files with 2107 additions and 1352 deletions

View File

@ -256,7 +256,7 @@ export class DiffService {
* @returns {Element} * @returns {Element}
*/ */
public getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element { public getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element {
return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); return fragment?.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber);
} }
/** /**

View File

@ -16,6 +16,22 @@ const slideOut = [
) )
]; ];
export const fadeInOut = trigger('fadeInOut', [
state(
'true',
style({
opacity: 1
})
),
state(
'false',
style({
opacity: 0.2
})
),
transition('true <=> false', animate('1s'))
]);
export const collapseAndFade = trigger('collapse', [ export const collapseAndFade = trigger('collapse', [
state('in', style({ opacity: 1, height: '100%' })), state('in', style({ opacity: 1, height: '100%' })),
transition(':enter', [style({ opacity: 0, height: 0 }), animate(fadeSpeed.fast)]), transition(':enter', [style({ opacity: 0, height: 0 }), animate(fadeSpeed.fast)]),

View File

@ -1,295 +0,0 @@
<!-- iFrame Dialog -->
<div class="jitsi-fake-dialog-wrapper" [ngClass]="{ 'jitsi-dialog-hide': !isJitsiDialogOpen }">
<mat-card class="jitsi-fake-dialog">
<div class="jitsi-iframe-wrapper" #jitsi></div>
<div class="jitsi-dialog-actions">
<div>
<a
type="button"
mat-icon-button
color="primary"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
target="_blank"
(click)="stopJitsi()"
[href]="jitsiMeetUrl"
>
<mat-icon>open_in_new</mat-icon>
</a>
</div>
<div>
<button
class="dialog-hangup"
type="button"
mat-button
color="warn"
(click)="!!videoStreamUrl ? viewStream() : stopJitsi()"
>
<os-icon-container icon="{{ !!videoStreamUrl ? 'meeting_room' : 'call_end' }}">
<span *ngIf="videoStreamUrl">
{{ 'Exit conference' | translate }}
</span>
<span *ngIf="!videoStreamUrl">
{{ 'Hang up' | translate }}
</span>
</os-icon-container>
</button>
</div>
<div>
<button class="dialog-hide" type="button" mat-button color="primary" (click)="hideJitsiDialog()">
<os-icon-container icon="aspect_ratio">
<span>{{ 'Minimize' | translate }}</span>
</os-icon-container>
</button>
</div>
</div>
</mat-card>
</div>
<div class="jitsi-integration" *ngIf="showConferenceBar">
<!-- Audio-Conference-bar -->
<div class="jitsi-bar">
<span class="control-icon-wrapper apply-theme">
<ng-container *ngIf="currentState == state.jitsi">
<!-- Exit jitsi -->
<button
mat-mini-fab
class="indicator quick-icon"
color="accent"
(click)="viewStream()"
matTooltip="{{ 'Exit live conference and continue livestream' | translate }}"
*ngIf="videoStreamUrl && canSeeLiveStream && !isJitsiDialogOpen"
>
<mat-icon color="warn">meeting_room</mat-icon>
</button>
<!-- mute/unmute button -->
<button
class="indicator quick-icon"
mat-mini-fab
*ngIf="isJoined && !isJitsiDialogOpen"
(click)="toggleMute()"
matTooltip="{{ 'Mute / Unmute' | translate }}"
>
<mat-icon color="{{ muted ? 'primary' : 'warn' }}">{{ muted ? 'moff' : 'mic' }}</mat-icon>
</button>
<!-- disconnected icon -->
<mat-icon class="indicator" *ngIf="!isJoined && !videoStreamUrl">cloud_off</mat-icon>
</ng-container>
<ng-container *ngIf="currentState == state.stream">
<!-- Enter conference from stream -->
<button
*ngIf="enableJitsi && isAccessPermitted"
class="quick-icon indicator"
mat-mini-fab
(click)="enterConferenceRoom()"
matTooltip="{{ 'Enter live conference' | translate }}"
>
<mat-icon
color="primary"
[@fadeInOut]="isEnterMeetingRoomVisible"
(@fadeInOut.done)="triggerMeetingRoomButtonAnimation()"
>
meeting_room
</mat-icon>
</button>
<a
class="indicator"
type="button"
mat-icon-button
matTooltip="{{ 'Add yourself to the current list of speakers to join the conference' | translate }}"
*ngIf="enableJitsi && !isAccessPermitted"
[routerLink]="['/agenda/speakers']"
>
<mat-icon> no_meeting_room </mat-icon>
</a>
</ng-container>
<!-- Call support button -->
<button
class="indicator quick-icon"
mat-mini-fab
(click)="enterSupportRoom()"
[disabled]="isJitsiActive"
matTooltip="{{ 'Help desk' | translate }}"
*ngIf="canAccessSupport"
>
<mat-icon color="primary">live_help</mat-icon>
</button>
<!-- applause button -->
<button
class="quick-icon indicator"
[disabled]="applauseDisabled"
mat-mini-fab
(click)="sendApplause()"
matTooltip="{{ 'Give applause' | translate }}"
*ngIf="showApplause"
[matBadge]="showApplauseBadge ? applauseLevel : null"
matBadgeColor="accent"
>
<mat-icon svgIcon="clapping_hands"></mat-icon>
</button>
</span>
<span
class="list-wrapper apply-theme"
[ngClass]="{
'stream-width-wrapper': currentState == state.stream,
'audio-list-wrapper': currentState == state.jitsi,
'cast-shadow': showJitsiWindow
}"
>
<os-applause-bar-display *ngIf="showApplause && isApplauseTypeBar" class="applause"></os-applause-bar-display>
<!-- open-window button -->
<button class="toggle-list-button" mat-button (click)="toggleShowJitsi()">
<ng-container *ngIf="currentState == state.jitsi">
<div *ngIf="!connectToHelpDesk" class="ellipsis-overflow">{{ 'Live conference' | translate }}</div>
<div *ngIf="connectToHelpDesk" class="ellipsis-overflow">{{ 'Help desk' | translate }}</div>
<div class="one-line">
&nbsp;
<span *ngIf="currentDominantSpeaker">
» <span class="dominant-speaker">{{ currentDominantSpeaker.displayName }}</span>
</span>
<span *ngIf="!isJitsiActive">
<i>{{ 'disconnected' | translate }}</i>
</span>
<span *ngIf="isJitsiActive && !isJoined">
<i>{{ 'connecting ...' | translate }}</i>
</span>
</div>
</ng-container>
<ng-container *ngIf="currentState == state.stream">
<!-- os-icon-container does weird things here -->
<div class="ellipsis-overflow">{{ 'Livestream' | translate }}</div>
</ng-container>
<mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon>
<mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon>
</button>
<!-- unfolded list -->
<div
class="jitsi-list"
[ngClass]="{
'hide-height': !showJitsiWindow
}"
>
<ng-container *ngIf="currentState == state.jitsi">
<!-- Jitsi content window -->
<div class="content">
<!-- The "somewhere else active" warning -->
<div class="disconnected" *ngIf="isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{
'The live conference is already running in your OpenSlides session.' | translate
}}</span>
<button mat-button color="warn" (click)="forceStart()">
<span>{{ 'Reenter to live conference' | translate }}</span>
</button>
</div>
<div class="disconnected" *ngIf="!isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{ 'disconnected' | translate }}</span>
</div>
<div class="disconnected" *ngIf="isJitsiActive && !isJoined">
<span>{{ 'connecting ...' | translate }}</span>
</div>
<!-- user list -->
<div class="room-members" *ngIf="isJitsiActive && isJoined">
<os-applause-particle-display
*ngIf="isApplauseTypeParticles"
class="room-list-applause-particles"
></os-applause-particle-display>
<div class="member-list">
<ol>
<li
*ngFor="let memberId of memberList; trackBy: trackByIndex"
[ngClass]="{
focused: members[memberId].focus
}"
>
<div class="member-list-entry">
{{ members[memberId].name }}
</div>
</li>
</ol>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="currentState == state.stream">
<ng-container *ngIf="showVideoPlayer()">
<os-video-player
[videoUrl]="videoStreamUrl"
[showParticles]="isApplauseTypeParticles"
(started)="onSteamLoaded()"
></os-video-player>
</ng-container>
<div class="disconnected" *ngIf="isStreamInOtherTab()">
<button class="restart-stream-button" mat-button color="warn" (click)="deleteStreamingLock()">
<span>{{ 'Restart livestream' | translate }}</span>
</button>
</div>
</ng-container>
<ng-container *ngIf="currentState == state.jitsi">
<!-- Custom control buttons -->
<div>
<mat-divider></mat-divider>
<div class="control-grid">
<div class="control-buttons">
<!-- Hangup -->
<button
mat-mini-fab
color="warn"
(click)="stopJitsi()"
*ngIf="isJitsiActive && isJoined"
matTooltip="{{ 'Leave' | translate }}"
>
<mat-icon>call_end</mat-icon>
</button>
<!-- Enter jitsi manually -->
<button
mat-mini-fab
color="accent"
(click)="enterConferenceRoom()"
[disabled]="
!enableJitsi || isJitsiActive || isJitsiActiveInAnotherTab || !isAccessPermitted
"
*ngIf="!isJoined"
matTooltip="{{ 'Enter conference' | translate }}"
>
<mat-icon>call</mat-icon>
</button>
</div>
<!-- open dialog -->
<button
mat-icon-button
class="open-jitsi-in-tab"
color="accent"
(click)="toggleConferenceDialog()"
[disabled]="!isJitsiActive"
matTooltip="{{ 'Show/Hide video conference' | translate }}"
>
<mat-icon>
{{ isJitsiDialogOpen ? 'aspect_ratio' : 'voice_chat' }}
</mat-icon>
</button>
</div>
</div>
</ng-container>
</div>
</span>
</div>
</div>

View File

@ -1,200 +0,0 @@
.jitsi-fake-dialog-wrapper {
z-index: 98;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
padding: 20px 10% 20px 5%;
.jitsi-fake-dialog {
display: flex;
flex-direction: column;
width: 100%;
height: 90%;
.jitsi-iframe-wrapper {
flex: 1;
}
.jitsi-dialog-actions {
display: flex;
justify-content: space-between;
div {
min-width: 33%;
display: flex;
}
.dialog-hangup {
margin-left: auto;
margin-right: auto;
}
.dialog-hide {
margin-left: auto;
}
}
}
}
.jitsi-dialog-hide {
display: none;
}
.jitsi-integration {
pointer-events: none;
z-index: 99;
position: fixed;
left: 0;
right: 20px;
bottom: 0;
.cast-shadow {
box-shadow: -3px -3px 10px 0px rgba(0, 0, 0, 0.2) !important;
}
.jitsi-bar {
display: flex;
position: relative;
justify-content: flex-end;
$wrapper-padding: 5px;
$bar-height: 40px;
.control-icon-wrapper {
pointer-events: all;
z-index: 1;
min-height: $bar-height;
display: flex;
margin-top: auto;
padding-right: 0.5em;
padding: $wrapper-padding 0 $wrapper-padding $wrapper-padding;
border-top-left-radius: 4px;
.indicator {
width: 40px;
text-align: center;
margin: auto $wrapper-padding auto 0;
}
}
.stream-width-wrapper {
width: 100%;
min-width: 100px;
max-width: 500px;
}
.audio-list-wrapper {
width: 100%;
min-width: 100px;
max-width: 300px;
}
.list-wrapper {
position: relative;
pointer-events: all;
min-height: $bar-height;
padding-top: $wrapper-padding;
border-top-right-radius: 4px;
.applause {
position: absolute;
top: 0;
width: 90px;
left: -90px;
bottom: 50px;
}
.toggle-list-button {
position: relative;
line-height: normal;
width: 100%;
height: 40px;
padding: 0 2.5em;
margin-bottom: $wrapper-padding;
font-weight: normal;
text-align: right;
.opened-indicator {
position: absolute;
right: $wrapper-padding;
top: 8px;
}
.dominant-speaker {
font-weight: 500;
width: fit-content;
margin: 0 auto;
}
}
.jitsi-list {
.content {
height: 40vh;
max-height: 100%;
clear: both;
.disconnected {
display: flex;
flex-direction: column;
height: inherit;
padding-left: 1em;
padding-right: 1em;
span {
margin: auto;
}
}
.room-members {
height: 100%;
position: relative;
.room-list-applause-particles {
position: absolute;
height: 100%;
width: 70px;
right: 0;
}
.member-list {
max-height: 100%;
overflow-y: auto;
.member-list-entry {
margin: 5px;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.focused {
font-weight: bold;
}
}
}
.control-grid {
padding: $wrapper-padding 0;
display: grid;
grid-template-areas: 'exit buttons new-tab';
grid-template-columns: 40px auto 40px;
.exit-conference {
grid-area: exit;
}
.control-buttons {
grid-area: buttons;
margin: auto;
}
.open-jitsi-in-tab {
grid-area: new-tab;
}
}
}
}
}
}

View File

@ -1,37 +0,0 @@
@import '~@angular/material/theming';
@mixin os-jitsi-theme($theme) {
$primary: map-get($theme, primary);
$accent: map-get($theme, accent);
$warn: map-get($theme, warn);
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.jitsi-bar {
.apply-theme {
background-color: mat-color($primary);
.quick-icon:not([disabled]) {
background-color: mat-color($primary, default-contrast);
}
.indicator {
color: mat-color($primary, default-contrast);
svg path {
fill: mat-color($primary) !important;
}
}
.toggle-list-button {
span {
color: mat-color($primary, default-contrast);
}
}
}
}
.jitsi-list {
background-color: mat-color($background, card);
}
}

View File

@ -1,742 +0,0 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import { delay, distinctUntilChanged, map } from 'rxjs/operators';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { Deferred } from 'app/core/promises/deferred';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ApplauseService, ApplauseType } from 'app/core/ui-services/applause.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { UserMediaPermService } from 'app/core/ui-services/user-media-perm.service';
import { UserListIndexType } from 'app/site/agenda/models/view-list-of-speakers';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service';
declare var JitsiMeetExternalAPI: any;
interface JitsiMember {
id: string;
displayName: string;
}
interface ConferenceJoinedResult {
roomName: string;
id: string;
displayName: string;
formattedDisplayName: string;
}
interface DisplayNameChangeResult {
// Yes, in this case "displayname" really does not have a capital n. Thank you jitsi.
displayname: string;
formattedDisplayName: string;
id: string;
}
interface JitsiSettings {
JITSI_DOMAIN: string;
JITSI_ROOM_NAME: string;
JITSI_ROOM_PASSWORD: string;
}
interface ConferenceMember {
name: string;
focus: boolean;
}
enum ConferenceState {
stream,
jitsi
}
@Component({
selector: 'os-jitsi',
templateUrl: './jitsi.component.html',
styleUrls: ['./jitsi.component.scss'],
animations: [
trigger('fadeInOut', [
state(
'true',
style({
opacity: 1
})
),
state(
'false',
style({
opacity: 0.2
})
),
transition('true <=> false', animate('1s'))
])
],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class JitsiComponent extends BaseViewComponentDirective implements OnInit, OnDestroy {
public enableJitsi: boolean;
private autoconnect: boolean;
private defaultRoomName: string;
private actualRoomName: string;
private roomPassword: string;
private jitsiDomain: string;
private isSupportEnabled: boolean;
public connectToHelpDesk = false;
public restricted = false;
public videoStreamUrl: string;
private nextSpeakerAmount: number;
// do not set the password twice
private isPasswortSet = false;
public isJitsiDialogOpen = false;
public showJitsiWindow = true;
public muted = true;
public showApplause: boolean;
public applauseDisabled = false;
private applauseTimeout: number;
@ViewChild('jitsi')
private jitsiNode: ElementRef;
// JitsiMeet api object
private api: any | null;
public get isJitsiActive(): boolean {
return !!this.api;
}
public isJoined: boolean;
private streamRunning = false;
private options: object;
private lockLoaded: Deferred<void> = new Deferred();
private constantsLoaded: Deferred<void> = new Deferred();
private configsLoaded: Deferred<void> = new Deferred();
// storage locks
public isJitsiActiveInAnotherTab = false;
/**
* undefined is controlled behaviour, meaning, this property was not
* checked yet.
* Thus, false-checks have to be explicit
*/
private streamLoadedOnce: boolean;
private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
private STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning';
// JitsiID to ConferenceMember
public members = {};
public currentDominantSpeaker: JitsiMember;
public get memberList(): string[] {
return Object.keys(this.members);
}
public get isRoomPasswordProtected(): boolean {
return this.roomPassword?.length > 0;
}
public get canAccessSupport(): boolean {
return this.isSupportEnabled && this.enableJitsi && !!this.defaultRoomName;
}
private isOnCurrentLos: boolean;
public canSeeLiveStream: boolean;
public canManageSpeaker: boolean;
/**
* Jitsi|URL|Perm||Show
* =====|===|====||====
* 0 | 0 | 0 || 0
* 0 | 0 | 1 || 0
* 0 | 1 | 0 || 0
* 0 | 1 | 1 || 1
* 1 | 0 | 0 || 1
* 1 | 0 | 1 || 1
* 1 | 1 | 0 || 0
* 1 | 1 | 1 || 1
*/
public get showConferenceBar(): boolean {
if (this.enableJitsi) {
if (this.videoStreamUrl && !this.canSeeLiveStream) {
return false;
} else {
return true;
}
} else {
return this.videoStreamUrl && this.canSeeLiveStream;
}
}
public get isAccessPermitted(): boolean {
return !this.restricted || this.canManageSpeaker || this.isOnCurrentLos;
}
public get jitsiMeetUrl(): string {
return `https://${this.jitsiDomain}/${this.actualRoomName}`;
}
/**
* The conference state, to determine if the user consumes the stream or can
* contribute to jitsi
*/
public state = ConferenceState;
public currentState: ConferenceState;
public isEnterMeetingRoomVisible = true;
public applauseLevel = 0;
private showApplauseLevel: boolean;
private isApplausBarUsed: boolean;
public get showApplauseBadge(): boolean {
return this.showApplauseLevel && this.applauseLevel > 0 && (!this.showJitsiWindow || !this.isApplausBarUsed);
}
private get applauseType(): ApplauseType {
return this.applauseService.applauseType;
}
public get isApplauseTypeBar(): boolean {
return this.applauseType === ApplauseType.bar;
}
public get isApplauseTypeParticles(): boolean {
return this.applauseType === ApplauseType.particles;
}
private configOverwrite = {
startAudioOnly: false,
// allows jitsi on mobile devices
disableDeepLinking: true,
startWithAudioMuted: false,
startWithVideoMuted: false,
useNicks: true,
enableWelcomePage: false,
enableUserRolesBasedOnToken: false,
enableFeaturesBasedOnToken: false,
disableThirdPartyRequests: true,
enableNoAudioDetection: false,
enableNoisyMicDetection: false
};
private interfaceConfigOverwrite = {
DISABLE_VIDEO_BACKGROUND: true,
INVITATION_POWERED_BY: false,
DISABLE_JOIN_LEAVE_NOTIFICATIONS: true,
DISABLE_PRESENCE_STATUS: true,
TOOLBAR_ALWAYS_VISIBLE: true,
TOOLBAR_TIMEOUT: 10000000,
TOOLBAR_BUTTONS: [
'microphone',
'camera',
'desktop',
'fullscreen',
'fodeviceselection',
'profile',
'chat',
'recording',
'livestreaming',
'etherpad',
'sharedvideo',
'settings',
'videoquality',
'filmstrip',
'feedback',
'stats',
'shortcuts',
'tileview',
'download',
'help',
'mute-everyone'
]
};
public constructor(
titleService: Title,
translate: TranslateService,
snackBar: MatSnackBar,
private operator: OperatorService,
private storageMap: StorageMap,
private userRepo: UserRepositoryService,
private constantsService: ConstantsService,
private configService: ConfigService,
private closService: CurrentListOfSpeakersService,
private userMediaPermService: UserMediaPermService,
private applauseService: ApplauseService,
private cd: ChangeDetectorRef
) {
super(titleService, translate, snackBar);
}
public async ngOnInit(): Promise<void> {
await this.setUp();
if (this.canSeeLiveStream && this.videoStreamUrl) {
this.currentState = ConferenceState.stream;
} else {
this.currentState = ConferenceState.jitsi;
}
}
public async ngOnDestroy(): Promise<void> {
super.ngOnDestroy();
this.stopConference();
}
// closing the tab should also try to stop jitsi.
// this will usually not be cought by ngOnDestroy
@HostListener('window:beforeunload', ['$event'])
public async beforeunload($event: any): Promise<void> {
await this.stopConference();
}
public triggerMeetingRoomButtonAnimation(): void {
if (this.canManageSpeaker) {
this.isEnterMeetingRoomVisible = true;
} else {
this.isEnterMeetingRoomVisible = !this.isEnterMeetingRoomVisible;
}
}
private async stopConference(): Promise<void> {
await this.stopJitsi();
if (this.streamLoadedOnce && this.streamRunning) {
await this.deleteStreamingLock();
}
}
private async setUp(): Promise<void> {
this.subscriptions.push(
// if the operators users has changes, check if we have to start the animation
this.operator
.getUserObservable()
.pipe(delay(0))
.subscribe(() => {
this.canManageSpeaker = this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers);
this.canSeeLiveStream = this.operator.hasPerms(this.permission.coreCanSeeLiveStream);
this.isEnterMeetingRoomVisible = this.canManageSpeaker;
this.cd.markForCheck();
}),
this.storageMap
.watch(this.RTC_LOGGED_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((inUse: boolean) => {
this.isJitsiActiveInAnotherTab = inUse;
this.lockLoaded.resolve();
if (!inUse && !this.isJitsiActive) {
this.startJitsi();
this.cd.markForCheck();
}
}),
this.storageMap
.watch(this.STREAM_RUNNING_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((running: boolean) => {
this.streamLoadedOnce = !!running;
this.cd.markForCheck();
})
);
await this.lockLoaded;
this.constantsService.get<JitsiSettings>('Settings').subscribe(settings => {
if (settings) {
this.jitsiDomain = settings.JITSI_DOMAIN;
this.defaultRoomName = settings.JITSI_ROOM_NAME;
this.roomPassword = settings.JITSI_ROOM_PASSWORD;
this.constantsLoaded.resolve();
this.cd.markForCheck();
}
});
await this.constantsLoaded;
this.subscriptions.push(
this.configService.get<boolean>('general_system_conference_auto_connect').subscribe(autoconnect => {
this.autoconnect = autoconnect;
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_conference_show').subscribe(show => {
this.enableJitsi = show && !!this.jitsiDomain && !!this.defaultRoomName;
if (this.enableJitsi && this.autoconnect) {
this.startJitsi();
} else {
this.stopJitsi();
}
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_conference_los_restriction').subscribe(restricted => {
this.restricted = restricted;
this.cd.markForCheck();
}),
this.configService
.get<number>('general_system_conference_auto_connect_next_speakers')
.subscribe(nextSpeakerAmount => {
this.nextSpeakerAmount = nextSpeakerAmount;
this.cd.markForCheck();
}),
this.configService.get<string>('general_system_stream_url').subscribe(url => {
this.onLiveStreamAvailable(url);
this.configsLoaded.resolve();
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_conference_open_microphone').subscribe(open => {
this.configOverwrite.startWithAudioMuted = !open;
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_conference_open_video').subscribe(open => {
this.configOverwrite.startWithVideoMuted = !open;
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_applause_enable').subscribe(enable => {
this.showApplause = enable;
this.cd.markForCheck();
}),
this.configService.get<number>('general_system_stream_applause_timeout').subscribe(timeout => {
this.applauseTimeout = (timeout || 1) * 1000;
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_applause_show_level').subscribe(show => {
this.showApplauseLevel = show;
this.cd.markForCheck();
}),
this.configService.get<any>('general_system_applause_type').subscribe(type => {
if (type === 'applause-type-bar') {
this.isApplausBarUsed = true;
} else {
this.isApplausBarUsed = false;
}
this.cd.markForCheck();
}),
this.configService.get<boolean>('general_system_conference_enable_helpdesk').subscribe(enabled => {
this.isSupportEnabled = enabled;
this.cd.markForCheck();
})
);
await this.configsLoaded;
this.subscriptions.push(
// check if the operator is on the clos, remove from room if not permitted
this.closService.currentListOfSpeakersObservable
.pipe(
map(los => los?.findUserIndexOnList(this.operator.user.id) ?? -1),
distinctUntilChanged()
)
.subscribe(userLosIndex => {
this.autoJoinJitsiByLosIndex(userLosIndex);
this.cd.markForCheck();
}),
this.applauseService.applauseLevelObservable.subscribe(applauseLevel => {
this.applauseLevel = applauseLevel || 0;
this.cd.markForCheck();
})
);
}
public toggleMute(): void {
if (this.isJitsiActive) {
this.api.executeCommand('toggleAudio');
this.cd.markForCheck();
}
}
public async forceStart(): Promise<void> {
await this.deleteJitsiLock();
await this.stopJitsi();
await this.startJitsi();
}
private startJitsi(): void {
if (!this.isJitsiActiveInAnotherTab && this.enableJitsi && !this.isJitsiActive && this.jitsiNode) {
this.enterConferenceRoom();
this.cd.markForCheck();
}
}
private async enterConversation(): Promise<void> {
await this.operator.loaded;
try {
await this.userMediaPermService.requestMediaAccess();
this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {});
this.setConferenceState(ConferenceState.jitsi);
this.setOptions();
if (this.api) {
this.api.dispose();
this.api = undefined;
}
this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options);
const jitsiname = this.userRepo.getShortName(this.operator.user);
this.api.executeCommand('displayName', jitsiname);
this.loadApiCallbacks();
} catch (e) {
this.raiseError(e);
}
}
private loadApiCallbacks(): void {
this.api.on('videoConferenceJoined', (info: ConferenceJoinedResult) => {
this.onEnterConference(info);
});
this.api.on('participantJoined', (newMember: JitsiMember) => {
this.addMember(newMember);
});
this.api.on('participantLeft', (oldMember: { id: string }) => {
this.removeMember(oldMember);
});
this.api.on('displayNameChange', (member: DisplayNameChangeResult) => {
this.renameMember(member);
});
this.api.on('audioMuteStatusChanged', (isMuted: { muted: boolean }) => {
this.muted = isMuted.muted;
});
this.api.on('readyToClose', () => {
this.stopJitsi();
});
this.api.on('dominantSpeakerChanged', (newSpeaker: { id: string }) => {
this.newDominantSpeaker(newSpeaker.id);
});
this.api.on('passwordRequired', () => {
this.setRoomPassword();
});
}
private onEnterConference(info: ConferenceJoinedResult): void {
this.isJoined = true;
this.addMember({ displayName: info.displayName, id: info.id });
this.setRoomPassword();
if (this.videoStreamUrl) {
this.showJitsiDialog();
}
this.cd.markForCheck();
}
private autoJoinJitsiByLosIndex(operatorClosIndex: number): void {
if (operatorClosIndex !== UserListIndexType.NotOnList) {
if (!this.isOnCurrentLos) {
this.isOnCurrentLos = true;
this.triggerMeetingRoomButtonAnimation();
}
if (
this.nextSpeakerAmount &&
this.nextSpeakerAmount > 0 &&
operatorClosIndex > UserListIndexType.Active &&
operatorClosIndex <= this.nextSpeakerAmount &&
!this.isJitsiActive
) {
this.enterConferenceRoom();
}
} else {
this.isOnCurrentLos = false;
}
if (!this.isAccessPermitted) {
this.viewStream();
}
}
private setRoomPassword(): void {
if (this.roomPassword && !this.isPasswortSet) {
// You can only set the password after the server has recognized that you are
// the moderator. There is no event listener for that.
setTimeout(() => {
this.api.executeCommand('password', this.roomPassword);
this.isPasswortSet = true;
}, 1000);
}
}
private newDominantSpeaker(newSpeakerId: string): void {
if (this.currentDominantSpeaker && this.members[this.currentDominantSpeaker.id]) {
this.members[this.currentDominantSpeaker.id].focus = false;
}
this.members[newSpeakerId].focus = true;
this.currentDominantSpeaker = {
id: newSpeakerId,
displayName: this.members[newSpeakerId].name
};
this.cd.markForCheck();
}
private addMember(newMember: JitsiMember): void {
this.members[newMember.id] = {
name: newMember.displayName,
focus: false
} as ConferenceMember;
}
private removeMember(oldMember: { id: string }): void {
if (this.members[oldMember.id]) {
delete this.members[oldMember.id];
}
}
private renameMember(member: DisplayNameChangeResult): void {
if (this.members[member.id]) {
this.members[member.id].name = member.displayname;
}
if (this.currentDominantSpeaker?.id === member.id) {
this.newDominantSpeaker(member.id);
}
}
private clearMembers(): void {
this.members = {};
}
public async stopJitsi(): Promise<void> {
this.connectToHelpDesk = false;
if (this.isJitsiActive) {
this.api.executeCommand('hangup');
this.clearMembers();
await this.deleteJitsiLock();
this.api.dispose();
this.api = undefined;
this.hideJitsiDialog();
}
this.isJoined = false;
this.isPasswortSet = false;
this.currentDominantSpeaker = null;
}
private setOptions(): void {
this.options = {
roomName: this.actualRoomName,
parentNode: this.jitsiNode.nativeElement,
configOverwrite: this.configOverwrite,
interfaceConfigOverwrite: this.interfaceConfigOverwrite
};
}
public toggleShowJitsi(): void {
this.showJitsiWindow = !this.showJitsiWindow;
}
public toggleConferenceDialog(): void {
if (this.isJitsiDialogOpen) {
this.hideJitsiDialog();
} else {
this.showJitsiDialog();
}
}
public hideJitsiDialog(): void {
this.isJitsiDialogOpen = false;
this.cd.markForCheck();
}
public showJitsiDialog(): void {
this.isJitsiDialogOpen = true;
this.showJitsiWindow = false;
this.cd.markForCheck();
}
public viewStream(): void {
this.stopJitsi();
this.setConferenceState(ConferenceState.stream);
this.showJitsiWindow = true;
this.cd.markForCheck();
}
public onSteamLoaded(): void {
/**
* explicit false check, undefined would mean that this was not checked yet
*/
if (this.streamLoadedOnce === false) {
this.storageMap.set(this.STREAM_RUNNING_STORAGE_KEY, true).subscribe(() => {
this.streamRunning = true;
});
}
}
public showVideoPlayer(): boolean {
if (!this.canSeeLiveStream) {
return false;
}
return this.streamRunning || this.streamLoadedOnce === false;
}
public isStreamInOtherTab(): boolean {
return !this.streamRunning && this.streamLoadedOnce;
}
public enterConferenceRoom(): void {
this.actualRoomName = this.defaultRoomName;
this.connectToHelpDesk = false;
this.enterConversation();
}
public enterSupportRoom(): void {
this.actualRoomName = `${this.defaultRoomName}-SUPPORT`;
this.connectToHelpDesk = true;
this.enterConversation();
}
private onLiveStreamAvailable(liveStreamUrl: string): void {
this.videoStreamUrl = liveStreamUrl;
// this is the "dead" state; you would see the jitsi state; but are not connected
// or the connection is prohibited. If this occurs and a live stream
// becomes available, switch to the stream state
if (this.videoStreamUrl && this.currentState === ConferenceState.jitsi && !this.isJitsiActive) {
this.viewStream();
} else if (!this.videoStreamUrl && this.enableJitsi) {
this.setConferenceState(ConferenceState.jitsi);
}
this.cd.markForCheck();
}
private async deleteJitsiLock(): Promise<void> {
await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise();
}
public async deleteStreamingLock(): Promise<void> {
await this.storageMap.delete(this.STREAM_RUNNING_STORAGE_KEY).toPromise();
}
private setConferenceState(newState: ConferenceState): void {
this.currentState = newState;
}
public sendApplause(): void {
this.applauseDisabled = true;
this.applauseService.sendApplause();
setTimeout(() => {
this.applauseDisabled = false;
this.cd.markForCheck();
}, this.applauseTimeout);
}
}

View File

@ -1,9 +1,10 @@
import { Component, Input } from '@angular/core'; import { Component, Input, ViewEncapsulation } from '@angular/core';
@Component({ @Component({
selector: 'os-progress', selector: 'os-progress',
templateUrl: './progress.component.html', templateUrl: './progress.component.html',
styleUrls: ['./progress.component.scss'] styleUrls: ['./progress.component.scss'],
encapsulation: ViewEncapsulation.None
}) })
export class ProgressComponent { export class ProgressComponent {
@Input() @Input()

View File

@ -1,6 +1,5 @@
<div class="video-wrapper"> <div class="video-wrapper">
<os-applause-particle-display *ngIf="showParticles" class="applause-particles"></os-applause-particle-display> <div class="player-container" [ngClass]="{ hide: !isUrlOnline && usingVjs }" *ngIf="isStable">
<div class="player-container" [ngClass]="{ hide: !isUrlOnline && usingVjs }">
<div *ngIf="usingVjs"> <div *ngIf="usingVjs">
<video #vjs class="video-js" controls preload="none"></video> <video #vjs class="video-js" controls preload="none"></video>
</div> </div>

View File

@ -4,15 +4,6 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
.applause-particles {
position: absolute;
display: block;
pointer-events: none !important;
width: 100px;
height: 100%;
z-index: 1;
}
.is-offline-wrapper { .is-offline-wrapper {
width: 100%; width: 100%;
text-align: center; text-align: center;

View File

@ -1,6 +1,9 @@
import { ThrowStmt } from '@angular/compiler';
import { import {
AfterViewInit, AfterViewInit,
ApplicationRef,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@ -17,6 +20,7 @@ import { ajax, AjaxResponse } from 'rxjs/ajax';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import videojs from 'video.js'; import videojs from 'video.js';
import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
enum MimeType { enum MimeType {
@ -34,32 +38,41 @@ enum Player {
selector: 'os-video-player', selector: 'os-video-player',
templateUrl: './video-player.component.html', templateUrl: './video-player.component.html',
styleUrls: ['./video-player.component.scss'], styleUrls: ['./video-player.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class VideoPlayerComponent implements OnDestroy, AfterViewInit { export class VideoPlayerComponent implements AfterViewInit, OnDestroy {
@ViewChild('vjs', { static: false }) @ViewChild('vjs', { static: false })
private vjsPlayerElementRef: ElementRef; private vjsPlayerElementRef: ElementRef;
private _videoUrl: string; private _videoUrl: string;
public isStable = false;
private afterViewInitDone = false;
private youtubeQuerryParams = '?rel=0&iv_load_policy=3&modestbranding=1&autoplay=1';
@Input() @Input()
public set videoUrl(value: string) { public set videoUrl(value: string) {
if (!value.trim()) {
return;
}
this._videoUrl = value.trim(); this._videoUrl = value.trim();
this.playerType = this.determinePlayer(this.videoUrl); this.playerType = this.determinePlayer(this.videoUrl);
if (this.usingVjs) { if (this.usingVjs) {
this.mimeType = this.determineContentTypeByUrl(this.videoUrl); this.mimeType = this.determineContentTypeByUrl(this.videoUrl);
if (this.afterViewInitDone) {
this.initVjs(); this.initVjs();
}
} else if (this.usingYouTube) { } else if (this.usingYouTube) {
this.stopVJS(); this.stopVJS();
this.unloadVjs(); this.unloadVjs();
this.youTubeVideoId = this.getYouTubeVideoId(this.videoUrl); this.youTubeVideoId = this.getYouTubeVideoId(this.videoUrl);
} }
this.cd.markForCheck();
} }
@Input()
public showParticles: boolean;
public get videoUrl(): string { public get videoUrl(): string {
return this._videoUrl; return this._videoUrl;
} }
@ -83,18 +96,40 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit {
} }
public get youTubeVideoUrl(): string { public get youTubeVideoUrl(): string {
return `https://www.youtube.com/embed/${this.youTubeVideoId}?autoplay=1`; return `https://www.youtube.com/embed/${this.youTubeVideoId}${this.youtubeQuerryParams}`;
} }
public constructor(config: ConfigService) { public constructor(
config: ConfigService,
private cd: ChangeDetectorRef,
private osStatus: OpenSlidesStatusService
) {
config.get<string>('general_system_stream_poster').subscribe(posterUrl => { config.get<string>('general_system_stream_poster').subscribe(posterUrl => {
this.posterUrl = posterUrl?.trim(); this.posterUrl = posterUrl?.trim();
}); });
/**
* external iFrame will block loading, since for some reason the app will
* not become stable if an iFrame was loaded.
* (or just goes instable again, for some unknown reason)
* This will result in an endless spinner
* It's crucial to render external
* Videos AFTER the app was stable
*/
this.osStatus.stable.then(() => {
this.isStable = true;
this.cd.markForCheck();
});
} }
public ngAfterViewInit(): void { public ngAfterViewInit(): void {
if (this.usingVjs) {
this.initVjs();
} else {
this.started.next(); this.started.next();
} }
this.afterViewInitDone = true;
}
public ngOnDestroy(): void { public ngOnDestroy(): void {
this.unloadVjs(); this.unloadVjs();
@ -102,7 +137,6 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit {
private stopVJS(): void { private stopVJS(): void {
if (this.vjsPlayer) { if (this.vjsPlayer) {
this.vjsPlayer.src = '';
this.vjsPlayer.pause(); this.vjsPlayer.pause();
} }
} }
@ -136,6 +170,7 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit {
} else { } else {
this.isUrlOnline = false; this.isUrlOnline = false;
} }
this.cd.markForCheck();
} }
public async onRefreshVideo(): Promise<void> { public async onRefreshVideo(): Promise<void> {
@ -145,7 +180,6 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit {
private async initVjs(): Promise<void> { private async initVjs(): Promise<void> {
await this.isUrlReachable(); await this.isUrlReachable();
if (!this.vjsPlayer && this.usingVjs && this.vjsPlayerElementRef) { if (!this.vjsPlayer && this.usingVjs && this.vjsPlayerElementRef) {
this.vjsPlayer = videojs(this.vjsPlayerElementRef.nativeElement, { this.vjsPlayer = videojs(this.vjsPlayerElementRef.nativeElement, {
textTrackSettings: false, textTrackSettings: false,
@ -159,11 +193,15 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit {
} }
private playVjsVideo(): void { private playVjsVideo(): void {
if (!this.isUrlOnline) {
this.stopVJS();
}
if (this.usingVjs && this.vjsPlayer && this.isUrlOnline) { if (this.usingVjs && this.vjsPlayer && this.isUrlOnline) {
this.vjsPlayer.src({ this.vjsPlayer.src({
src: this.videoUrl, src: this.videoUrl,
type: this.mimeType type: this.mimeType
}); });
this.started.next();
} }
} }

View File

@ -127,14 +127,10 @@ import { AssignmentPollDetailContentComponent } from './components/assignment-po
import { GlobalSpinnerComponent } from './components/global-spinner/global-spinner.component'; import { GlobalSpinnerComponent } from './components/global-spinner/global-spinner.component';
import { UserMenuComponent } from './components/user-menu/user-menu.component'; import { UserMenuComponent } from './components/user-menu/user-menu.component';
import { JitsiComponent } from './components/jitsi/jitsi.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
import { ListOfSpeakersContentComponent } from './components/list-of-speakers-content/list-of-speakers-content.component'; import { ListOfSpeakersContentComponent } from './components/list-of-speakers-content/list-of-speakers-content.component';
import { ApplauseBarDisplayComponent } from './components/applause-bar-display/applause-bar-display.component';
import { ProgressComponent } from './components/progress/progress.component'; import { ProgressComponent } from './components/progress/progress.component';
import { NgParticlesModule } from 'ng-particles';
import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component';
import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/point-of-order-dialog.component'; import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/point-of-order-dialog.component';
import { VideoPlayerComponent } from './components/video-player/video-player.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -199,8 +195,7 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/
PblNgridTargetEventsModule, PblNgridTargetEventsModule,
PdfViewerModule, PdfViewerModule,
NgxMaterialTimepickerModule, NgxMaterialTimepickerModule,
ChartsModule, ChartsModule
NgParticlesModule
], ],
exports: [ exports: [
FormsModule, FormsModule,
@ -304,10 +299,11 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent,
VideoPlayerComponent,
ListOfSpeakersContentComponent, ListOfSpeakersContentComponent,
PointOfOrderDialogComponent PointOfOrderDialogComponent,
ListOfSpeakersContentComponent,
ProgressComponent,
VideoPlayerComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -369,13 +365,10 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent,
VideoPlayerComponent,
ListOfSpeakersContentComponent, ListOfSpeakersContentComponent,
ApplauseBarDisplayComponent,
ProgressComponent, ProgressComponent,
ApplauseParticleDisplayComponent, PointOfOrderDialogComponent,
PointOfOrderDialogComponent VideoPlayerComponent
], ],
providers: [ providers: [
{ {

View File

@ -0,0 +1,64 @@
<div class="interaction-bar-wrapper" *ngIf="!(isConfStateNone | async)">
<ng-container *ngIf="isConfStateJitsi | async">
<ng-container *ngIf="(isJoined | async) && !(showCallDialog | async)">
<!-- show dialog -->
<button
mat-mini-fab
class="action-bar-shadow background-default"
matTooltip="{{ 'Show conference room' | translate }}"
(click)="maximizeCallDialog()"
@fade
>
<mat-icon
color="primary"
[@fadeInOut]="meetingActiveAnimHelper"
(@fadeInOut.done)="triggerCallHiddenAnimation()"
>
fullscreen
</mat-icon>
</button>
</ng-container>
</ng-container>
<ng-container *ngIf="(showLiveConf | async) && (isConfStateStream | async)">
<!-- Enter conference from stream -->
<button
mat-mini-fab
class="action-bar-shadow"
[class.background-default]="canEnterCall"
[class.fake-disabled]="!canEnterCall"
matTooltip="{{ enterRoomTooltip | translate }}"
(click)="enterConferenceRoom(canEnterCall)"
@fade
>
<mat-icon color="primary"> phone </mat-icon>
</button>
<!-- Call support button -->
<button
mat-mini-fab
class="action-bar-shadow background-default"
(click)="enterSupportRoom()"
matTooltip="{{ 'Access help desk' | translate }}"
*ngIf="isSupportEnabled | async"
@fade
>
<mat-icon color="primary">help_outline</mat-icon>
</button>
</ng-container>
<!-- applause button -->
<button
mat-mini-fab
class="action-bar-shadow background-default"
matTooltip="{{ 'Send applause' | translate }}"
[disabled]="applauseDisabled"
[matBadge]="applauseLevelObservable | async"
[matBadgeHidden]="!(applauseLevelObservable | async)"
(click)="sendApplause()"
*ngIf="showApplause | async"
@fade
>
<mat-icon class="svg-primary" svgIcon="clapping_hands"></mat-icon>
</button>
</div>

View File

@ -0,0 +1,17 @@
:host {
margin: auto 0.5em 0 0;
}
.interaction-bar-wrapper {
width: auto;
transition: all 2s linear;
}
.action-bar-shadow {
box-shadow: 0px 0px 4px 1px rgba(0, 0, 0, 0.5) !important;
}
.mat-button-base {
margin-left: 0.5em;
margin-bottom: 0.5em;
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ActionBarComponent } from './action-bar.component';
describe('ActionBarComponent', () => {
let component: ActionBarComponent;
let fixture: ComponentFixture<ActionBarComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ActionBarComponent],
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ActionBarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,124 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewEncapsulation } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { fadeAnimation, fadeInOut } from 'app/shared/animations';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService } from '../../services/applause.service';
import { CallRestrictionService } from '../../services/call-restriction.service';
import { InteractionService } from '../../services/interaction.service';
import { RtcService } from '../../services/rtc.service';
const canEnterTooltip = _('Enter conference room');
const cannotEnterTooltip = _('Add yourself to the current list of speakers to join the conference');
@Component({
selector: 'os-action-bar',
templateUrl: './action-bar.component.html',
styleUrls: ['./action-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInOut, fadeAnimation]
})
export class ActionBarComponent extends BaseViewComponentDirective {
public applauseLevel = 0;
public applauseDisabled = false;
public showApplause: Observable<boolean> = this.applauseService.showApplause;
public applauseLevelObservable: Observable<number> = this.applauseService.applauseLevelObservable;
public applauseTimeout = this.applauseService.applauseTimeout;
public isJoined: Observable<boolean> = this.rtcService.isJoinedObservable;
public showCallDialog: Observable<boolean> = this.rtcService.showCallDialogObservable;
public showLiveConf: Observable<boolean> = this.interactionService.showLiveConfObservable;
public isSupportEnabled: Observable<boolean> = this.rtcService.isSupportEnabled;
private canEnterCallObservable: Observable<boolean> = this.callRestrictionService.canEnterCallObservable;
public canEnterCall = false;
/**
* for the pulse animation
*/
public enterCallAnimHelper = true;
public meetingActiveAnimHelper = true;
public get isConfStateStream(): Observable<boolean> {
return this.interactionService.isConfStateStream;
}
public get isConfStateJitsi(): Observable<boolean> {
return this.interactionService.isConfStateJitsi;
}
public get isConfStateNone(): Observable<boolean> {
return this.interactionService.isConfStateNone;
}
public get enterRoomTooltip(): string {
if (this.canEnterCall) {
return _(canEnterTooltip);
} else {
return _(cannotEnterTooltip);
}
}
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private router: Router,
private callRestrictionService: CallRestrictionService,
private interactionService: InteractionService,
private rtcService: RtcService,
private applauseService: ApplauseService,
private cd: ChangeDetectorRef
) {
super(titleService, translate, matSnackBar);
this.subscriptions.push(
this.canEnterCallObservable.subscribe(canEnter => {
this.canEnterCall = canEnter;
this.cd.markForCheck();
})
);
}
public async enterConferenceRoom(canEnter: boolean): Promise<void> {
if (canEnter) {
this.interactionService
.enterCall()
.then(() => this.rtcService.enterConferenceRoom())
.catch(this.raiseError);
} else {
const navUrl = '/autopilot';
this.router.navigate([navUrl]);
}
}
public enterSupportRoom(): void {
this.interactionService
.enterCall()
.then(() => this.rtcService.enterSupportRoom())
.catch(this.raiseError);
}
public maximizeCallDialog(): void {
this.rtcService.showCallDialog = true;
}
public sendApplause(): void {
this.applauseDisabled = true;
this.applauseService.sendApplause();
this.cd.markForCheck();
setTimeout(() => {
this.applauseDisabled = false;
this.cd.markForCheck();
}, this.applauseTimeout);
}
public triggerCallHiddenAnimation(): void {
this.meetingActiveAnimHelper = !this.meetingActiveAnimHelper;
}
}

View File

@ -1,4 +1,4 @@
<div class="bar-wrapper" *ngIf="isApplauseTypeBar"> <div class="bar-wrapper">
<os-progress class="progress-bar" [value]="percent"> <os-progress class="progress-bar" [value]="percent">
<div class="level-indicator"> <div class="level-indicator">
<div class="level"> <div class="level">

View File

@ -11,16 +11,10 @@
} }
.level-indicator { .level-indicator {
height: 50px;
display: block; display: block;
text-align: center; text-align: center;
.level { .level {
display: inline-block; display: inline-block;
margin-top: 25px;
} }
} }
.particle-display {
// height: 100%;
}

View File

@ -4,10 +4,10 @@ import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Applause, ApplauseService, ApplauseType } from 'app/core/ui-services/applause.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { fadeAnimation } from 'app/shared/animations'; import { fadeAnimation } from 'app/shared/animations';
import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService, ApplauseType } from 'app/site/interaction/services/applause.service';
@Component({ @Component({
selector: 'os-applause-bar-display', selector: 'os-applause-bar-display',
@ -26,10 +26,6 @@ export class ApplauseBarDisplayComponent extends BaseViewComponentDirective {
return !!this.level; return !!this.level;
} }
public get isApplauseTypeBar(): boolean {
return this.applauseService.applauseType === ApplauseType.bar;
}
public constructor( public constructor(
title: Title, title: Title,
translate: TranslateService, translate: TranslateService,

View File

@ -7,10 +7,10 @@ import { Subject } from 'rxjs';
import { auditTime } from 'rxjs/operators'; import { auditTime } from 'rxjs/operators';
import { Container } from 'tsparticles'; import { Container } from 'tsparticles';
import { ApplauseService } from 'app/core/ui-services/applause.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { ConfigService } from 'app/core/ui-services/config.service';
import { ElementSize } from 'app/shared/directives/resized.directive'; import { ElementSize } from 'app/shared/directives/resized.directive';
import { BaseViewComponentDirective } from 'app/site/base/base-view'; import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService } from 'app/site/interaction/services/applause.service';
import { particleConfig, particleOptions } from './particle-options'; import { particleConfig, particleOptions } from './particle-options';
@Component({ @Component({
@ -79,7 +79,6 @@ export class ApplauseParticleDisplayComponent extends BaseViewComponentDirective
private setParticleLevel(level: number): void { private setParticleLevel(level: number): void {
if (this.particleContainer) { if (this.particleContainer) {
const emitters = this.particleContainer.plugins.get('emitters') as any; const emitters = this.particleContainer.plugins.get('emitters') as any;
// TODO: Use `Emitters` instead of any.
if (emitters) { if (emitters) {
emitters.array[0].emitterOptions.rate.quantity = level; emitters.array[0].emitterOptions.rate.quantity = level;
} }

View File

@ -0,0 +1,54 @@
<!-- iFrame Dialog -->
<div class="jitsi-fake-dialog-wrapper" [ngClass]="{ 'dialog-mobile': (isMobile | async) }">
<mat-card class="jitsi-fake-dialog">
<div class="jitsi-dialog-actions">
<span>
<a
mat-icon-button
type="button"
color="primary"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
target="_blank"
(click)="hangUp()"
[href]="jitsiMeetUrl | async"
*osPerms="'agenda.can_manage_list_of_speakers'"
>
<mat-icon>open_in_new</mat-icon>
</a>
</span>
<span class="right">
<button
mat-icon-button
type="button"
color="primary"
matTooltip="{{ 'Minimize' | translate }}"
(click)="hideDialog()"
>
<mat-icon>minimize</mat-icon>
</button>
<!-- does not exist yet -->
<!-- <button
mat-icon-button
type="button"
color="primary"
matTooltip="{{ 'Full screen' | translate }}"
(click)="fullScreen()()"
>
<mat-icon>fullscreen</mat-icon>
</button> -->
<button
mat-icon-button
type="button"
color="primary"
matTooltip="{{ 'Exit conference room' | translate }}"
(click)="hangUp()"
>
<mat-icon>close</mat-icon>
</button>
</span>
</div>
<div class="jitsi-iframe-wrapper" #jitsi></div>
</mat-card>
</div>

View File

@ -0,0 +1,32 @@
.dialog-mobile {
left: 15px !important;
}
.jitsi-fake-dialog-wrapper {
z-index: -1;
position: fixed;
pointer-events: none;
left: 270px;
top: 20px;
right: 30px;
bottom: 0;
.jitsi-fake-dialog {
padding: 0 5px 5px 5px;
pointer-events: all;
display: flex;
flex-direction: column;
width: 100%;
height: 90%;
.jitsi-iframe-wrapper {
flex: 1;
}
.jitsi-dialog-actions {
.right {
float: right;
}
}
}
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { CallDialogComponent } from './call-dialog.component';
describe('CallDialogComponent', () => {
let component: CallDialogComponent;
let fixture: ComponentFixture<CallDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [CallDialogComponent],
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CallDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,51 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Output,
ViewChild
} from '@angular/core';
import { Observable } from 'rxjs';
import { ViewportService } from 'app/core/ui-services/viewport.service';
import { RtcService } from '../../services/rtc.service';
@Component({
selector: 'os-call-dialog',
templateUrl: './call-dialog.component.html',
styleUrls: ['./call-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CallDialogComponent implements AfterViewInit {
@ViewChild('jitsi')
private jitsiNode: ElementRef;
public jitsiMeetUrl: Observable<string> = this.rtcService.jitsiMeetUrl;
public isMobile: Observable<boolean> = this.vp.isMobileSubject;
public constructor(private cd: ChangeDetectorRef, private rtcService: RtcService, private vp: ViewportService) {}
public ngAfterViewInit(): void {
this.rtcService.setJitsiNode(this.jitsiNode);
this.cd.markForCheck();
}
public fullScreen(): void {
this.rtcService.enterFullScreen();
this.cd.markForCheck();
}
public hangUp(): void {
this.rtcService.stopJitsi();
this.cd.markForCheck();
}
public hideDialog(): void {
this.rtcService.showCallDialog = false;
}
}

View File

@ -0,0 +1,87 @@
<os-call-dialog [ngClass]="{ hide: !(isJitsiDialogOpen | async) }"></os-call-dialog>
<div class="jitsi-list">
<!-- Jitsi content window -->
<div class="content">
<!-- The "somewhere else active" warning -->
<div class="disconnected" *ngIf="(isJitsiActiveInAnotherTab | async) && !isJitsiActive">
<span>{{ 'A conference is already running in your OpenSlides session.' | translate }}</span>
<button mat-button color="warn" (click)="forceStart()">
<span>{{ 'Reenter to conference room' | translate }}</span>
</button>
</div>
<div class="disconnected" *ngIf="isDisconnected">
<mat-icon>cloud_off</mat-icon>
</div>
<div class="disconnected" *ngIf="isConnecting">
<mat-spinner></mat-spinner>
</div>
<!-- user list -->
<div class="room-members" *ngIf="isConnected">
<os-applause-particle-display
*ngIf="showParticles | async"
class="room-list-applause-particles"
></os-applause-particle-display>
<div class="member-list">
<ol>
<li
*ngFor="let memberId of memberList; trackBy: trackByIndex"
[ngClass]="{
focused: members[memberId].focus
}"
>
<div class="member-list-entry">
{{ members[memberId].name }}
</div>
</li>
</ol>
</div>
</div>
</div>
<!-- Custom control buttons -->
<div>
<mat-divider></mat-divider>
<div class="control-grid">
<div class="control-buttons">
<!-- Hangup -->
<button
mat-mini-fab
color="warn"
(click)="hangUp()"
*ngIf="showHangUp"
matTooltip="{{ 'Leave' | translate }}"
>
<mat-icon>call_end</mat-icon>
</button>
<!-- Enter jitsi manually -->
<button
mat-mini-fab
color="accent"
(click)="callRoom()"
[disabled]="!(canEnterCall | async) || isConnecting"
*ngIf="!isJoined"
matTooltip="{{ 'Enter conference room' | translate }}"
>
<mat-icon>call</mat-icon>
</button>
</div>
<div class="exit">
<!-- Exit jitsi, view stream -->
<button
mat-icon-button
color="primary"
matTooltip="{{ 'Continue livestream' | translate }}"
(click)="viewStream()"
*ngIf="!!(liveStreamUrl | async)?.trim()"
>
<mat-icon>live_tv</mat-icon>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,64 @@
$wrapper-padding: 5px;
.jitsi-list {
display: flex;
flex-direction: column;
height: 100%;
.content {
flex: 1 0 auto;
.disconnected {
display: flex;
height: 100%;
* {
margin: auto;
}
}
.room-members {
height: 100%;
position: relative;
.room-list-applause-particles {
position: absolute;
height: 100%;
width: 70px;
right: 0;
}
.member-list {
max-height: 100%;
overflow-y: auto;
.member-list-entry {
margin: 5px;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.focused {
font-weight: bold;
}
}
}
.control-grid {
padding: $wrapper-padding;
display: grid;
grid-template-areas: 'empty buttons exit';
grid-template-columns: 40px auto 40px;
.exit {
grid-area: exit;
}
.control-buttons {
grid-area: buttons;
margin: auto;
}
}
}

View File

@ -2,21 +2,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module'; import { E2EImportsModule } from 'e2e-imports.module';
import { JitsiComponent } from './jitsi.component'; import { CallComponent } from './call.component';
describe('JitsiComponent', () => { describe('CallComponent', () => {
let component: JitsiComponent; let component: CallComponent;
let fixture: ComponentFixture<JitsiComponent>; let fixture: ComponentFixture<CallComponent>;
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [E2EImportsModule], declarations: [CallComponent],
declarations: [JitsiComponent] imports: [E2EImportsModule]
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(JitsiComponent); fixture = TestBed.createComponent(CallComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@ -0,0 +1,189 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
HostListener,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService } from '../../services/applause.service';
import { CallRestrictionService } from '../../services/call-restriction.service';
import { InteractionService } from '../../services/interaction.service';
import { RtcService } from '../../services/rtc.service';
import { StreamService } from '../../services/stream.service';
const helpDeskTitle = _('Help desk');
const liveConferenceTitle = _('Conference room');
const disconnectedTitle = _('disconnected');
const connectingTitle = _('connecting ...');
@Component({
selector: 'os-call',
templateUrl: './call.component.html',
styleUrls: ['./call.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CallComponent extends BaseViewComponentDirective implements OnInit, AfterViewInit, OnDestroy {
public isJitsiActiveInAnotherTab: Observable<boolean> = this.rtcService.inOtherTab;
public canEnterCall: Observable<boolean> = this.callRestrictionService.canEnterCallObservable;
public isJitsiDialogOpen: Observable<boolean> = this.rtcService.showCallDialogObservable;
public isJitsiActive: boolean;
public isJoined: boolean;
public get showHangUp(): boolean {
return this.isJitsiActive && this.isJoined;
}
private dominantSpeaker: string;
private members = {};
public get memberList(): string[] {
return Object.keys(this.members);
}
public get isDisconnected(): boolean {
return !this.isJitsiActive && !this.isJoined;
}
public get isConnecting(): boolean {
return this.isJitsiActive && !this.isJoined;
}
public get isConnected(): boolean {
return this.isJitsiActive && this.isJoined;
}
public get showParticles(): Observable<boolean> {
return this.applauseService.showParticles;
}
public get canSeeLiveStream(): Observable<boolean> {
return this.streamService.canSeeLiveStreamObservable;
}
public get liveStreamUrl(): Observable<string> {
return this.streamService.liveStreamUrlObservable;
}
private autoConnect: boolean;
@Output()
public conferenceTitle: EventEmitter<string> = new EventEmitter();
@Output()
public conferenceSubtitle: EventEmitter<string> = new EventEmitter();
public constructor(
titleService: Title,
translate: TranslateService,
snackBar: MatSnackBar,
private callRestrictionService: CallRestrictionService,
private rtcService: RtcService,
private applauseService: ApplauseService,
private interactionService: InteractionService,
private streamService: StreamService,
private cd: ChangeDetectorRef
) {
super(titleService, translate, snackBar);
this.subscriptions.push(
this.rtcService.isJitsiActiveObservable.subscribe(active => {
this.isJitsiActive = active;
this.updateSubtitle();
this.cd.markForCheck();
}),
this.rtcService.isJoinedObservable.subscribe(isJoined => {
this.isJoined = isJoined;
this.updateSubtitle();
this.cd.markForCheck();
}),
this.rtcService.memberObservableObservable.subscribe(members => {
this.members = members;
this.cd.markForCheck();
}),
this.rtcService.dominantSpeakerObservable.subscribe(domSpeaker => {
this.dominantSpeaker = domSpeaker?.displayName;
this.updateSubtitle();
this.cd.markForCheck();
}),
this.rtcService.autoConnect.subscribe(auto => {
this.autoConnect = auto;
}),
this.rtcService.connectedToHelpDesk.subscribe(onHelpDesk => {
if (onHelpDesk) {
this.conferenceTitle.next(helpDeskTitle);
} else {
this.conferenceTitle.next(liveConferenceTitle);
}
this.cd.markForCheck();
})
);
}
public ngOnInit(): void {
this.updateSubtitle();
}
public ngAfterViewInit(): void {
if (this.autoConnect) {
this.callRoom();
}
}
// closing the tab should also try to stop jitsi.
// this will usually not be caught by ngOnDestroy
@HostListener('window:beforeunload', ['$event'])
public beforeunload($event: any): void {
this.rtcService.stopJitsi();
}
public ngOnDestroy(): void {
super.ngOnDestroy();
this.rtcService.stopJitsi();
}
private updateSubtitle(): void {
if (this.isJitsiActive && this.isJoined) {
this.conferenceSubtitle.next(this.dominantSpeaker || '');
} else if (this.isJitsiActive && !this.isJoined) {
this.conferenceSubtitle.next(connectingTitle);
} else {
this.conferenceSubtitle.next(disconnectedTitle);
}
}
public async callRoom(): Promise<void> {
await this.rtcService.enterConferenceRoom().catch(this.raiseError);
this.cd.markForCheck();
}
public forceStart(): void {
this.rtcService.enterConferenceRoom(true).catch(this.raiseError);
this.cd.markForCheck();
}
public hangUp(): void {
this.rtcService.stopJitsi();
this.cd.markForCheck();
}
public viewStream(): void {
this.interactionService.viewStream();
}
}

View File

@ -0,0 +1,36 @@
<div *ngIf="!(isConfStateNone | async)" class="interaction-container-wrapper">
<div class="container-head background-primary" (click)="showHideBody()">
<div class="container-head-wrapper">
<div class="ellipsis-overflow container-head-title">
{{ containerHeadTitle | translate }}
</div>
<div class="ellipsis-overflow">
{{ containerHeadSubtitle | translate }}
</div>
</div>
</div>
<div
class="container-body background-card"
[ngClass]="{ 'container-body-with-applause-bar': showApplauseBar | async, 'container-body-hide': !showBody }"
>
<os-applause-bar-display
*ngIf="(isApplausEnabled | async) && (showApplauseBar | async)"
></os-applause-bar-display>
<ng-container *ngIf="isConfStateStream | async">
<os-stream
class="video-player"
(streamTitle)="updateTitle($event)"
(streamSubtitle)="updateSubtitle($event)"
></os-stream>
</ng-container>
<ng-container *ngIf="isConfStateJitsi | async">
<os-call
class="call-body"
(conferenceTitle)="updateTitle($event)"
(conferenceSubtitle)="updateSubtitle($event)"
></os-call>
</ng-container>
</div>
</div>

View File

@ -0,0 +1,59 @@
$radius: 5px;
:host {
margin-top: auto;
}
.interaction-container-wrapper {
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2);
border-top-left-radius: $radius;
border-top-right-radius: $radius;
min-width: 250px;
max-width: 70vw;
}
.container-head {
height: 50px;
display: flex;
border-top-left-radius: $radius;
border-top-right-radius: $radius;
cursor: pointer;
.container-head-wrapper {
margin-left: 1em;
margin-top: auto;
margin-bottom: auto;
}
.container-head-title {
font-weight: bold;
}
}
.container-body {
display: flex;
max-width: 500px;
max-height: 280px;
transition: all 350ms ease-out;
}
.container-body-with-applause-bar {
max-width: 530px !important;
}
.video-player {
display: block;
width: 500px;
height: 280px;
}
.call-body {
display: block;
width: 250px;
height: 280px;
}
.container-body-hide {
max-width: 250px;
max-height: 0px;
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { InteractionContainerComponent } from './interaction-container.component';
describe('InteractionContainerComponent', () => {
let component: InteractionContainerComponent;
let fixture: ComponentFixture<InteractionContainerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [InteractionContainerComponent],
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(InteractionContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest, forkJoin, merge, Observable } from 'rxjs';
import { distinctUntilChanged, filter, mergeAll, mergeMap, withLatestFrom } from 'rxjs/operators';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService } from '../../services/applause.service';
import { InteractionService } from '../../services/interaction.service';
import { RtcService } from '../../services/rtc.service';
import { StreamService } from '../../services/stream.service';
@Component({
selector: 'os-interaction-container',
templateUrl: './interaction-container.component.html',
styleUrls: ['./interaction-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InteractionContainerComponent extends BaseViewComponentDirective {
public showBody = false;
private streamRunning = false;
private streamLoadedOnce = false;
public containerHeadTitle = '';
public containerHeadSubtitle = '';
public get isApplausEnabled(): Observable<boolean> {
return this.applauseService.showApplause;
}
public get showApplauseBar(): Observable<boolean> {
return this.applauseService.showBar;
}
public get isConfStateStream(): Observable<boolean> {
return this.interactionService.isConfStateStream;
}
public get isConfStateJitsi(): Observable<boolean> {
return this.interactionService.isConfStateJitsi;
}
public get isConfStateNone(): Observable<boolean> {
return this.interactionService.isConfStateNone;
}
public get isStreamInOtherTab(): boolean {
return !this.streamRunning && this.streamLoadedOnce;
}
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
rtcService: RtcService,
streamService: StreamService,
private interactionService: InteractionService,
private applauseService: ApplauseService,
private cd: ChangeDetectorRef
) {
super(titleService, translate, matSnackBar);
this.subscriptions.push(
interactionService.conferenceStateObservable.pipe(distinctUntilChanged()).subscribe(state => {
if (state) {
this.clearTitles();
}
}),
rtcService.showCallDialogObservable.subscribe(show => {
if (show) {
this.showBody = false;
}
this.cd.markForCheck();
}),
streamService.streamLoadedOnceObservable.subscribe(loadedOnce => {
this.streamLoadedOnce = loadedOnce;
if (!this.isStreamInOtherTab) {
this.showBody = true;
}
this.cd.markForCheck();
}),
streamService.isStreamRunningObservable.subscribe(running => {
this.streamRunning = running || false;
if (!this.isStreamInOtherTab) {
this.showBody = true;
}
this.cd.markForCheck();
})
);
}
private clearTitles(): void {
this.containerHeadTitle = '';
this.containerHeadSubtitle = '';
this.cd.detectChanges();
}
public showHideBody(): void {
this.showBody = !this.showBody;
}
public updateTitle(title: string): void {
if (title !== this.containerHeadTitle) {
this.containerHeadTitle = title ?? '';
this.cd.detectChanges();
}
}
public updateSubtitle(title: string): void {
if (title !== this.containerHeadSubtitle) {
this.containerHeadSubtitle = title ?? '';
this.cd.detectChanges();
}
}
}

View File

@ -0,0 +1,15 @@
<div *osPerms="permission.coreCanSeeLiveStream; and: showVideoPlayer">
<os-applause-particle-display
*ngIf="showParticles | async"
class="applause-particles"
></os-applause-particle-display>
<os-video-player [videoUrl]="liveStreamUrl" (started)="onSteamLoaded()"></os-video-player>
</div>
<div *osPerms="permission.coreCanSeeLiveStream; complement: true">
<span> {{ 'You are not allowed to see the live stream' | translate }} </span>
</div>
<div class="disconnected" *ngIf="isStreamInOtherTab">
<button class="restart-stream-button" mat-button color="warn" (click)="forceLoadStream()">
<span>{{ 'Restart livestream' | translate }}</span>
</button>
</div>

View File

@ -0,0 +1,8 @@
.applause-particles {
position: absolute;
display: block;
pointer-events: none !important;
width: 100px;
height: 100%;
z-index: 1;
}

View File

@ -0,0 +1,27 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { StreamComponent } from './stream.component';
describe('StreamComponent', () => {
let component: StreamComponent;
let fixture: ComponentFixture<StreamComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [StreamComponent],
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(StreamComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,115 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
HostListener,
OnDestroy,
OnInit,
Output
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ApplauseService } from '../../services/applause.service';
import { StreamService } from '../../services/stream.service';
@Component({
selector: 'os-stream',
templateUrl: './stream.component.html',
styleUrls: ['./stream.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StreamComponent extends BaseViewComponentDirective implements AfterViewInit, OnDestroy {
private streamRunning = false;
public liveStreamUrl: string;
private streamLoadedOnce: boolean;
public get showParticles(): Observable<boolean> {
return this.applauseService.showParticles;
}
public get showVideoPlayer(): boolean {
return this.streamRunning || this.streamLoadedOnce === false;
}
public get isStreamInOtherTab(): boolean {
return !this.streamRunning && this.streamLoadedOnce;
}
@Output()
public streamTitle: EventEmitter<string> = new EventEmitter();
@Output()
public streamSubtitle: EventEmitter<string> = new EventEmitter();
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private streamService: StreamService,
private applauseService: ApplauseService,
private cd: ChangeDetectorRef
) {
super(titleService, translate, matSnackBar);
this.subscriptions.push(
this.streamService.liveStreamUrlObservable.subscribe(url => {
this.liveStreamUrl = url?.trim();
this.cd.markForCheck();
}),
this.streamService.streamLoadedOnceObservable.subscribe(loadedOnce => {
this.streamLoadedOnce = loadedOnce || false;
this.cd.markForCheck();
}),
this.streamService.isStreamRunningObservable.subscribe(running => {
this.streamRunning = running || false;
this.cd.markForCheck();
})
);
}
public ngAfterViewInit(): void {
this.streamTitle.next('Livestream');
this.streamSubtitle.next('');
this.cd.detectChanges();
}
// closing the tab should also try to stop jitsi.
// this will usually not be caught by ngOnDestroy
@HostListener('window:beforeunload', ['$event'])
public async beforeunload($event: any): Promise<void> {
this.beforeViewCloses();
}
public ngOnDestroy(): void {
super.ngOnDestroy();
this.beforeViewCloses();
}
private beforeViewCloses(): void {
if (this.streamLoadedOnce && this.streamRunning) {
this.streamService.deleteStreamingLock();
}
}
public forceLoadStream(): void {
this.streamService.deleteStreamingLock();
}
public onSteamLoaded(): void {
/**
* explicit false check, undefined would mean that this was not checked yet
*/
if (this.streamLoadedOnce === false) {
this.streamService.setStreamingLock();
this.streamService.setStreamRunning(true);
}
}
}

View File

@ -0,0 +1,28 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgParticlesModule } from 'ng-particles';
import { ActionBarComponent } from './components/action-bar/action-bar.component';
import { ApplauseBarDisplayComponent } from './components/applause-bar-display/applause-bar-display.component';
import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component';
import { CallDialogComponent } from './components/call-dialog/call-dialog.component';
import { CallComponent } from './components/call/call.component';
import { InteractionContainerComponent } from './components/interaction-container/interaction-container.component';
import { SharedModule } from '../../shared/shared.module';
import { StreamComponent } from './components/stream/stream.component';
@NgModule({
declarations: [
ApplauseBarDisplayComponent,
ApplauseParticleDisplayComponent,
ActionBarComponent,
InteractionContainerComponent,
StreamComponent,
CallComponent,
CallDialogComponent
],
imports: [CommonModule, SharedModule, NgParticlesModule],
exports: [ActionBarComponent, InteractionContainerComponent]
})
export class InteractionModule {}

View File

@ -3,9 +3,9 @@ import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { ConfigService } from './config.service'; import { ConfigService } from '../../../core/ui-services/config.service';
import { HttpService } from '../core-services/http.service'; import { HttpService } from '../../../core/core-services/http.service';
import { NotifyService } from '../core-services/notify.service'; import { NotifyService } from '../../../core/core-services/notify.service';
export interface Applause { export interface Applause {
level: number; level: number;
@ -21,35 +21,54 @@ const applausePath = '/system/applause';
const applauseNotifyMessageName = 'applause'; const applauseNotifyMessageName = 'applause';
@Injectable({ @Injectable({
// providedIn: InteractionModule
providedIn: 'root' providedIn: 'root'
// provided: InteractionModule
}) })
export class ApplauseService { export class ApplauseService {
private minApplauseLevel: number; private minApplauseLevel: number;
private maxApplauseLevel: number; private maxApplauseLevel: number;
private presentApplauseUsers: number; private presentApplauseUsers: number;
private applauseTypeObservable: Observable<ApplauseType>;
public applauseType: ApplauseType; public showApplause: Observable<boolean>;
public showApplauseLevel: boolean;
public applauseTimeout: number;
private applauseLevelSubject: Subject<number> = new Subject<number>(); private applauseLevelSubject: Subject<number> = new Subject<number>();
public applauseLevelObservable = this.applauseLevelSubject.asObservable(); public applauseLevelObservable: Observable<number> = this.applauseLevelSubject.asObservable();
private get maxApplause(): number { private get maxApplause(): number {
return this.maxApplauseLevel || this.presentApplauseUsers || 0; return this.maxApplauseLevel || this.presentApplauseUsers || 0;
} }
public get showParticles(): Observable<boolean> {
return this.applauseTypeObservable.pipe(map(type => type === ApplauseType.particles));
}
public get showBar(): Observable<boolean> {
return this.applauseTypeObservable.pipe(map(type => type === ApplauseType.bar));
}
public constructor( public constructor(
configService: ConfigService, configService: ConfigService,
private httpService: HttpService, private httpService: HttpService,
private notifyService: NotifyService private notifyService: NotifyService
) { ) {
this.showApplause = configService.get<boolean>('general_system_applause_enable');
this.applauseTypeObservable = configService.get<ApplauseType>('general_system_applause_type');
configService.get<number>('general_system_applause_min_amount').subscribe(minLevel => { configService.get<number>('general_system_applause_min_amount').subscribe(minLevel => {
this.minApplauseLevel = minLevel; this.minApplauseLevel = minLevel;
}); });
configService.get<number>('general_system_applause_max_amount').subscribe(maxLevel => { configService.get<number>('general_system_applause_max_amount').subscribe(maxLevel => {
this.maxApplauseLevel = maxLevel; this.maxApplauseLevel = maxLevel;
}); });
configService.get<ApplauseType>('general_system_applause_type').subscribe((type: ApplauseType) => { configService.get<boolean>('general_system_applause_show_level').subscribe(show => {
this.applauseType = type; this.showApplauseLevel = show;
});
configService.get<number>('general_system_stream_applause_timeout').subscribe(timeout => {
this.applauseTimeout = (timeout || 1) * 1000;
}); });
this.notifyService this.notifyService
.getMessageObservable<Applause>(applauseNotifyMessageName) .getMessageObservable<Applause>(applauseNotifyMessageName)

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { CallRestrictionService } from './call-restriction.service';
describe('CallRestrictionService', () => {
let service: CallRestrictionService;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [E2EImportsModule] });
service = TestBed.inject(CallRestrictionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,95 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { UserListIndexType } from 'app/site/agenda/models/view-list-of-speakers';
import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service';
@Injectable({
providedIn: 'root'
})
export class CallRestrictionService {
private canManageSpeaker: boolean;
private restricted: boolean;
private isOnCurrentLos: boolean;
private nextSpeakerAmount: number;
public isAccessRestricted: Observable<boolean>;
private canEnterCallSubject = new BehaviorSubject<boolean>(false);
public canEnterCallObservable = this.canEnterCallSubject.asObservable();
private hasToEnterCallSubject = new Subject<void>();
public hasToEnterCallObservable = this.hasToEnterCallSubject.asObservable();
private hasToLeaveCallSubject = new Subject<void>();
public hasToLeaveCallObservable = this.hasToLeaveCallSubject.asObservable();
public constructor(
configService: ConfigService,
operator: OperatorService,
closService: CurrentListOfSpeakersService
) {
/**
* general access perm
*/
operator.getUserObservable().subscribe(() => {
this.canManageSpeaker = operator.hasPerms(Permission.agendaCanManageListOfSpeakers);
this.updateCanEnterCall();
});
/**
* LosRestriction
*/
this.isAccessRestricted = configService.get<boolean>('general_system_conference_los_restriction');
this.isAccessRestricted.subscribe(restricted => {
this.restricted = restricted;
this.updateCanEnterCall();
});
/**
* Is User In Clos
*/
closService.currentListOfSpeakersObservable
.pipe(
map(los => los?.findUserIndexOnList(operator.user.id) ?? -1),
distinctUntilChanged()
)
.subscribe(userLosIndex => {
this.isOnCurrentLos = userLosIndex !== UserListIndexType.NotOnList;
this.updateCanEnterCall();
this.updateAutoJoinJitsiByLosIndex(userLosIndex);
});
/**
* Amount of next speakers
*/
configService
.get<number>('general_system_conference_auto_connect_next_speakers')
.subscribe(nextSpeakerAmount => {
this.nextSpeakerAmount = nextSpeakerAmount;
});
}
private updateCanEnterCall(): void {
this.canEnterCallSubject.next(!this.restricted || this.canManageSpeaker || this.isOnCurrentLos);
}
private updateAutoJoinJitsiByLosIndex(operatorClosIndex: number): void {
if (operatorClosIndex !== UserListIndexType.NotOnList) {
if (
this.nextSpeakerAmount &&
this.nextSpeakerAmount > 0 &&
operatorClosIndex > UserListIndexType.Active &&
operatorClosIndex <= this.nextSpeakerAmount
) {
this.hasToEnterCallSubject.next();
}
} else if (operatorClosIndex === UserListIndexType.NotOnList && this.restricted) {
this.hasToLeaveCallSubject.next();
}
}
}

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { InteractionService } from './interaction.service';
describe('InteractionService', () => {
let service: InteractionService;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [E2EImportsModule] });
service = TestBed.inject(InteractionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,153 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigService } from 'app/core/ui-services/config.service';
import { CallRestrictionService } from './call-restriction.service';
import { RtcService } from './rtc.service';
import { StreamService } from './stream.service';
export enum ConferenceState {
none,
stream,
jitsi
}
@Injectable({
providedIn: 'root'
})
export class InteractionService {
private conferenceStateSubject = new BehaviorSubject<ConferenceState>(ConferenceState.none);
public conferenceStateObservable = this.conferenceStateSubject.asObservable();
public showLiveConfObservable: Observable<boolean>;
private get conferenceState(): ConferenceState {
return this.conferenceStateSubject.value;
}
private isJitsiEnabled: boolean;
private isInCall: boolean;
private isJitsiActive: boolean;
private hasLiveStreamUrl: boolean;
private canSeeLiveStream: boolean;
private showLiveConf: boolean;
public get isConfStateStream(): Observable<boolean> {
return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.stream));
}
public get isConfStateJitsi(): Observable<boolean> {
return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.jitsi));
}
public get isConfStateNone(): Observable<boolean> {
return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.none));
}
public constructor(
private configService: ConfigService,
private streamService: StreamService,
private rtcService: RtcService,
private callRestrictionService: CallRestrictionService
) {
this.showLiveConfObservable = this.configService.get<boolean>('general_system_conference_show');
/**
* If you want to somehow simplify this using rxjs merge-map magic or something
* be my guest.
*/
this.streamService.liveStreamUrlObservable.subscribe(url => {
this.hasLiveStreamUrl = !!url?.trim() ?? false;
this.detectDeadState();
});
this.streamService.canSeeLiveStreamObservable.subscribe(canSee => {
this.canSeeLiveStream = canSee;
this.detectDeadState();
});
this.rtcService.isJitsiEnabledObservable.subscribe(enabled => {
this.isJitsiEnabled = enabled;
this.detectDeadState();
});
this.rtcService.isJoinedObservable.subscribe(joined => {
this.isInCall = joined;
this.detectDeadState();
});
this.rtcService.isJitsiActiveObservable.subscribe(isActive => {
this.isJitsiActive = isActive;
this.detectDeadState();
});
this.callRestrictionService.hasToEnterCallObservable.subscribe(() => {
if (!this.isInCall) {
this.enterCall();
this.rtcService.enterConferenceRoom();
}
});
this.callRestrictionService.hasToLeaveCallObservable.subscribe(() => {
this.viewStream();
});
this.showLiveConfObservable.subscribe(showConf => {
this.showLiveConf = showConf;
this.detectDeadState();
});
this.detectDeadState();
}
public async enterCall(): Promise<void> {
if (this.conferenceState !== ConferenceState.jitsi) {
this.setConferenceState(ConferenceState.jitsi);
}
}
public viewStream(): void {
if (this.conferenceState !== ConferenceState.stream) {
this.setConferenceState(ConferenceState.stream);
}
}
private setConferenceState(newState: ConferenceState): void {
if (newState !== this.conferenceState) {
this.conferenceStateSubject.next(newState);
}
}
/**
* this is the "dead" state; you would see the jitsi state; but are not connected
* or the connection is prohibited. If this occurs and a live stream
* becomes available, switch to the stream state
*/
private detectDeadState(): void {
if (
this.isInCall === undefined ||
this.isJitsiActive === undefined ||
this.hasLiveStreamUrl === undefined ||
this.conferenceState === undefined ||
this.canSeeLiveStream === undefined ||
this.isJitsiEnabled === undefined
) {
return;
}
/**
* most importantly, if there is a call, to not change the state!
*/
if (this.isInCall || this.isJitsiActive) {
return;
}
if (this.hasLiveStreamUrl && this.canSeeLiveStream) {
this.viewStream();
} else if (this.showLiveConf && (!this.hasLiveStreamUrl || !this.canSeeLiveStream) && this.isJitsiEnabled) {
this.enterCall();
} else {
this.setConferenceState(ConferenceState.none);
}
}
}

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { RtcService } from './rtc.service';
describe('RtcService', () => {
let service: RtcService;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [E2EImportsModule] });
service = TestBed.inject(RtcService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,397 @@
import { ElementRef, Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { OperatorService } from 'app/core/core-services/operator.service';
import { ConfigService } from 'app/core/ui-services/config.service';
import { CallRestrictionService } from './call-restriction.service';
import { UserMediaPermService } from './user-media-perm.service';
export const RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
interface JitsiMember {
id: string;
displayName: string;
}
interface ConferenceJoinedResult {
roomName: string;
id: string;
displayName: string;
formattedDisplayName: string;
}
interface ConferenceMember {
name: string;
focus: boolean;
}
interface DisplayNameChangeResult {
// Yes, in this case "displayname" really does not have a capital n. Thank you jitsi.
displayname: string;
formattedDisplayName: string;
id: string;
}
interface MemberKicked {
kicked: {
id: string;
local: boolean;
};
kicker: {
id: string;
};
}
/**
* Jitsi
*/
declare var JitsiMeetExternalAPI: any;
const configOverwrite = {
startAudioOnly: false,
// allows jitsi on mobile devices
disableDeepLinking: true,
startWithAudioMuted: true,
startWithVideoMuted: true,
useNicks: true,
enableWelcomePage: false,
enableUserRolesBasedOnToken: false,
enableFeaturesBasedOnToken: false,
disableThirdPartyRequests: true,
enableNoAudioDetection: false,
enableNoisyMicDetection: false
};
const interfaceConfigOverwrite = {
DISABLE_VIDEO_BACKGROUND: true,
INVITATION_POWERED_BY: false,
DISABLE_JOIN_LEAVE_NOTIFICATIONS: true,
DISABLE_PRESENCE_STATUS: true,
TOOLBAR_ALWAYS_VISIBLE: true,
TOOLBAR_TIMEOUT: 10000000,
TOOLBAR_BUTTONS: [
'microphone',
'camera',
'desktop',
'fullscreen',
'fodeviceselection',
'profile',
'chat',
'recording',
'livestreaming',
'etherpad',
'sharedvideo',
'settings',
'videoquality',
'filmstrip',
'feedback',
'stats',
'shortcuts',
'tileview',
'download',
'help',
'mute-everyone',
'hangup'
]
};
export interface JitsiConfig {
JITSI_DOMAIN: string;
JITSI_ROOM_NAME: string;
JITSI_ROOM_PASSWORD: string;
}
@Injectable({
providedIn: 'root'
})
export class RtcService {
private jitsiConfig: JitsiConfig;
private isJitsiEnabledSubject = new BehaviorSubject<boolean>(false);
public isJitsiEnabledObservable = this.isJitsiEnabledSubject.asObservable();
public autoConnect: Observable<boolean>;
// JitsiMeet api object
private api: any | null;
private get isJitsiActive(): boolean {
return !!this.api;
}
private options: Object;
private jitsiNode: ElementRef;
private actualRoomName: string;
public isSupportEnabled: Observable<boolean>;
private connectedToHelpDeskSubject = new BehaviorSubject<boolean>(false);
public connectedToHelpDesk = this.connectedToHelpDeskSubject.asObservable();
public isJitsiActiveInAnotherTab = false;
private isJoinedSubject = new BehaviorSubject<boolean>(false);
public isJoinedObservable = this.isJoinedSubject.asObservable();
private isPasswordSet = false;
private isJitsiActiveSubject = new BehaviorSubject<boolean>(false);
public isJitsiActiveObservable = this.isJitsiActiveSubject.asObservable();
private get defaultRoomName(): string {
return this.jitsiConfig?.JITSI_ROOM_NAME;
}
private jitsiMeetUrlSubject = new Subject<string>();
public jitsiMeetUrl = this.jitsiMeetUrlSubject.asObservable();
private members = {};
private memberSubject = new BehaviorSubject<Object>(this.members);
public memberObservableObservable = this.memberSubject.asObservable();
private dominantSpeaker: JitsiMember;
private dominantSpeakerSubject = new Subject<JitsiMember>();
public dominantSpeakerObservable = this.dominantSpeakerSubject.asObservable();
private isMutedSubject = new BehaviorSubject<boolean>(false);
public isMuted = this.isMutedSubject.asObservable();
private canEnterCall: boolean;
public inOtherTab: Observable<boolean>;
private showCallDialogSubject = new BehaviorSubject<boolean>(false);
public showCallDialogObservable = this.showCallDialogSubject.asObservable();
public set showCallDialog(show: boolean) {
this.showCallDialogSubject.next(show);
}
public constructor(
constantsService: ConstantsService,
configService: ConfigService,
callRestrictionService: CallRestrictionService,
private userMediaPermService: UserMediaPermService,
private storageMap: StorageMap,
private operator: OperatorService
) {
this.isSupportEnabled = configService.get<boolean>('general_system_conference_enable_helpdesk');
this.autoConnect = configService.get<boolean>('general_system_conference_auto_connect');
constantsService.get<JitsiConfig>('Settings').subscribe(settings => {
this.jitsiConfig = settings;
this.isJitsiEnabledSubject.next(!!settings.JITSI_DOMAIN && !!settings.JITSI_ROOM_NAME);
});
configService.get<boolean>('general_system_conference_open_microphone').subscribe(open => {
configOverwrite.startWithAudioMuted = !open;
});
configService.get<boolean>('general_system_conference_open_video').subscribe(open => {
configOverwrite.startWithVideoMuted = !open;
});
callRestrictionService.canEnterCallObservable.subscribe(canEnter => {
this.canEnterCall = canEnter;
});
this.inOtherTab = this.storageMap
.watch(RTC_LOGGED_STORAGE_KEY, { type: 'boolean' })
.pipe(distinctUntilChanged());
}
public setJitsiNode(jitsiNode: ElementRef): void {
this.jitsiNode = jitsiNode;
}
public toggleMute(): void {
if (this.isJitsiActive) {
this.api.executeCommand('toggleAudio');
}
}
public async enterSupportRoom(): Promise<void> {
this.connectedToHelpDeskSubject.next(true);
this.actualRoomName = `${this.defaultRoomName}-SUPPORT`;
await this.enterConversation();
}
public async enterConferenceRoom(force?: boolean): Promise<void> {
if (!this.canEnterCall) {
return;
}
if (force) {
this.disconnect();
}
this.connectedToHelpDeskSubject.next(false);
this.actualRoomName = this.defaultRoomName;
await this.enterConversation();
}
private setRoomPassword(): void {
if (this.jitsiConfig?.JITSI_ROOM_PASSWORD && !this.isPasswordSet) {
// You can only set the password after the server has recognized that you are
// the moderator. There is no event listener for that.
setTimeout(() => {
this.api.executeCommand('password', this.jitsiConfig?.JITSI_ROOM_PASSWORD);
this.isPasswordSet = true;
}, 1000);
}
}
private async enterConversation(): Promise<void> {
await this.operator.loaded;
await this.userMediaPermService.requestMediaAccess();
this.storageMap.set(RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {});
this.setOptions();
if (this.api) {
this.api.dispose();
this.api = undefined;
}
this.api = new JitsiMeetExternalAPI(this.jitsiConfig?.JITSI_DOMAIN, this.options);
this.isJitsiActiveSubject.next(true);
const jitsiName = this.operator.viewUser.getShortName();
this.api.executeCommand('displayName', jitsiName);
this.loadApiCallbacks();
}
private loadApiCallbacks(): void {
this.isMutedSubject.next(configOverwrite.startWithAudioMuted);
this.api.on('videoConferenceJoined', (info: ConferenceJoinedResult) => {
this.onEnterConference(info);
});
this.api.on('participantJoined', (newMember: JitsiMember) => {
this.addMember(newMember);
});
this.api.on('participantLeft', (oldMember: { id: string }) => {
this.removeMember(oldMember);
});
this.api.on('displayNameChange', (member: DisplayNameChangeResult) => {
this.renameMember(member);
});
this.api.on('audioMuteStatusChanged', (isMuted: { muted: boolean }) => {
this.isMutedSubject.next(isMuted.muted);
});
this.api.on('readyToClose', () => {
this.stopJitsi();
});
this.api.on('dominantSpeakerChanged', (newSpeaker: { id: string }) => {
this.newDominantSpeaker(newSpeaker.id);
});
this.api.on('passwordRequired', () => {
this.setRoomPassword();
});
this.api.on('participantKickedOut', (kicked: MemberKicked) => {
this.onMemberKicked(kicked);
});
}
private onEnterConference(info: ConferenceJoinedResult): void {
this.isJoinedSubject.next(true);
this.showCallDialogSubject.next(true);
this.addMember({ displayName: info.displayName, id: info.id });
this.setRoomPassword();
}
private addMember(newMember: JitsiMember): void {
this.members[newMember.id] = {
name: newMember.displayName,
focus: false
} as ConferenceMember;
this.updateMemberSubject();
}
private onMemberKicked(kick: MemberKicked): void {
if (kick.kicked.local) {
this.stopJitsi();
}
}
private removeMember(oldMember: { id: string }): void {
if (this.members[oldMember.id]) {
delete this.members[oldMember.id];
this.updateMemberSubject();
}
}
private renameMember(member: DisplayNameChangeResult): void {
if (this.members[member.id]) {
this.members[member.id].name = member.displayname;
this.updateMemberSubject();
}
if (this.dominantSpeaker?.id === member.id) {
this.newDominantSpeaker(member.id);
}
}
private newDominantSpeaker(newSpeakerId: string): void {
if (this.dominantSpeaker && this.members[this.dominantSpeaker.id]) {
this.members[this.dominantSpeaker.id].focus = false;
}
this.members[newSpeakerId].focus = true;
this.updateMemberSubject();
this.dominantSpeaker = {
id: newSpeakerId,
displayName: this.members[newSpeakerId].name
};
this.dominantSpeakerSubject.next(this.dominantSpeaker);
}
private clearMembers(): void {
this.members = {};
this.updateMemberSubject();
}
private updateMemberSubject(): void {
this.memberSubject.next(this.members);
}
/**
* https://github.com/jitsi/jitsi-meet/issues/8244
*/
public enterFullScreen(): void {}
private disconnect(): void {
if (this.isJitsiActive) {
this.api?.executeCommand('hangup');
this.api?.dispose();
this.api = undefined;
}
this.clearMembers();
this.deleteJitsiLock();
this.dominantSpeaker = null;
this.dominantSpeakerSubject.next(this.dominantSpeaker);
this.isJoinedSubject.next(false);
this.showCallDialogSubject.next(false);
this.isPasswordSet = false;
}
public stopJitsi(): void {
this.disconnect();
this.connectedToHelpDeskSubject.next(false);
this.isJitsiActiveSubject.next(false);
}
private setOptions(): void {
this.setJitsiMeetUrl();
this.options = {
roomName: this.actualRoomName,
parentNode: this.jitsiNode.nativeElement,
configOverwrite: configOverwrite,
interfaceConfigOverwrite: interfaceConfigOverwrite
};
}
private setJitsiMeetUrl(): void {
this.jitsiMeetUrlSubject.next(`https://${this.jitsiConfig.JITSI_DOMAIN}/${this.actualRoomName}`);
}
private deleteJitsiLock(): void {
this.storageMap.delete(RTC_LOGGED_STORAGE_KEY).subscribe();
}
}

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { StreamService } from './stream.service';
describe('StreamService', () => {
let service: StreamService;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [E2EImportsModule] });
service = TestBed.inject(StreamService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { Observable, Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { OperatorService, Permission } from 'app/core/core-services/operator.service';
import { ConfigService } from 'app/core/ui-services/config.service';
const STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning';
@Injectable({
providedIn: 'root'
})
export class StreamService {
public liveStreamUrlObservable: Observable<string>;
/**
* undefined is controlled behavior, meaning, this property was not
* checked yet.
* Thus, false-checks have to be explicit
*/
public streamLoadedOnceObservable: Observable<boolean>;
private isStreamRunningSubject = new Subject<boolean>();
public isStreamRunningObservable = this.isStreamRunningSubject.asObservable();
private canSeeLiveStreamSubject = new Subject<boolean>();
public canSeeLiveStreamObservable = this.canSeeLiveStreamSubject.asObservable();
public constructor(private storageMap: StorageMap, operator: OperatorService, configService: ConfigService) {
this.liveStreamUrlObservable = configService.get<string>('general_system_stream_url');
this.streamLoadedOnceObservable = this.storageMap
.watch(STREAM_RUNNING_STORAGE_KEY, { type: 'boolean' })
.pipe(distinctUntilChanged());
operator.getUserObservable().subscribe(() => {
this.canSeeLiveStreamSubject.next(operator.hasPerms(Permission.coreCanSeeLiveStream));
});
}
public setStreamingLock(): void {
this.storageMap.set(STREAM_RUNNING_STORAGE_KEY, true).subscribe(() => {});
}
public setStreamRunning(running: boolean): void {
this.isStreamRunningSubject.next(running);
}
public deleteStreamingLock(): void {
/**
* subscriptions are faster than promises. This will fire more reliable
* than converting it to promise first
*/
this.storageMap.delete(STREAM_RUNNING_STORAGE_KEY).subscribe();
}
}

View File

@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { OverlayService } from './overlay.service'; import { OverlayService } from '../../../core/ui-services/overlay.service';
const givePermsMessage = _('Please allow OpenSlides to access your microphone and/or camera'); const givePermsMessage = _('Please allow OpenSlides to access your microphone and/or camera');
const accessDeniedMessage = _('Media access is denied'); const accessDeniedMessage = _('Media access is denied');

View File

@ -114,7 +114,8 @@
</main> </main>
</div> </div>
<div class="toolbars"> <div class="toolbars">
<os-jitsi></os-jitsi> <os-action-bar></os-action-bar>
<os-interaction-container></os-interaction-container>
</div> </div>
</mat-sidenav-content> </mat-sidenav-content>
</mat-sidenav-container> </mat-sidenav-container>

View File

@ -136,9 +136,16 @@ mat-sidenav-container {
.toolbars { .toolbars {
display: flex; display: flex;
position: fixed;
bottom: 0;
/**
* right 0 would overlap the browser scrollbar
*/
right: 20px;
z-index: 99;
pointer-events: none;
* { * {
margin-top: auto; pointer-events: initial;
} }
} }

View File

@ -2,11 +2,12 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module'; import { SharedModule } from 'app/shared/shared.module';
import { InteractionModule } from './interaction/interaction.module';
import { SiteRoutingModule } from './site-routing.module'; import { SiteRoutingModule } from './site-routing.module';
import { SiteComponent } from './site.component'; import { SiteComponent } from './site.component';
@NgModule({ @NgModule({
imports: [CommonModule, SharedModule, SiteRoutingModule], imports: [CommonModule, SharedModule, SiteRoutingModule, InteractionModule],
declarations: [SiteComponent] declarations: [SiteComponent]
}) })
export class SiteModule {} export class SiteModule {}

View File

@ -42,7 +42,6 @@ $narrow-spacing: (
@import '~app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; @import '~app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss';
@import '~app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss'; @import '~app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss';
@import '~app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss'; @import '~app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss';
@import '~app/shared/components/jitsi/jitsi.component.scss-theme.scss';
@import '~app/shared/components/list-view-table/list-view-table.component.scss-theme.scss'; @import '~app/shared/components/list-view-table/list-view-table.component.scss-theme.scss';
@import '~app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss'; @import '~app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss';
@import '~app/site/login/components/login-wrapper/login-wrapper.component.scss-theme.scss'; @import '~app/site/login/components/login-wrapper/login-wrapper.component.scss-theme.scss';
@ -69,7 +68,6 @@ $narrow-spacing: (
@include os-motion-poll-detail-style($theme); @include os-motion-poll-detail-style($theme);
@include os-assignment-poll-detail-style($theme); @include os-assignment-poll-detail-style($theme);
@include os-progress-snack-bar-style($theme); @include os-progress-snack-bar-style($theme);
@include os-jitsi-theme($theme);
@include os-list-view-table-theme($theme); @include os-list-view-table-theme($theme);
@include os-user-statistics-style($theme); @include os-user-statistics-style($theme);
@include os-login-wrapper-theme($theme); @include os-login-wrapper-theme($theme);

View File

@ -35,10 +35,32 @@
color: mat-color(if($is-dark-theme, $accent, $primary)); color: mat-color(if($is-dark-theme, $accent, $primary));
} }
//custom table header for search button, filtering and more. Used in ListViews /**
* normal current mat bg color with primary text color.
* important is required to overwrite materials default
* button color
*/
.custom-table-header, .custom-table-header,
.background--default { .background-default {
background: mat-color($background, background); background: mat-color($background, background) !important;
}
.fake-disabled {
background: mat-color($background, unselected-chip) !important;
opacity: 1 !important;
.mat-button-wrapper {
.mat-icon {
color: mat-color($foreground, disabled-button) !important;
svg path {
fill: mat-color($foreground, disabled-button) !important;
}
}
}
}
.background-default[disabled] {
@extend .fake-disabled;
} }
.underline { .underline {
@ -192,6 +214,11 @@
color: mat-color($primary, default-contrast) !important; color: mat-color($primary, default-contrast) !important;
} }
.background-card {
background: mat-color($background, card);
color: mat-color($foreground, text);
}
.primary-foreground { .primary-foreground {
color: mat-color($primary); color: mat-color($primary);
} }
@ -199,4 +226,10 @@
.accent-foreground { .accent-foreground {
color: mat-color($accent); color: mat-color($accent);
} }
.svg-primary {
svg path {
fill: mat-color($primary) !important;
}
}
} }

View File

@ -164,7 +164,7 @@ def get_config_variables():
name="general_system_stream_poster", name="general_system_stream_poster",
default_value="", default_value="",
label="Livestream poster image url", label="Livestream poster image url",
help_text="Shows if livestream is not started. Recommended image format: 500x200px, PNG or JPG", help_text="Shows if livestream is not started. Recommended image format: 500x280px, PNG or JPG",
weight=147, weight=147,
subgroup="Live conference", subgroup="Live conference",
) )