Merge pull request #5376 from tsiegleauq/integrate-streams

Integrate streams
This commit is contained in:
Emanuel Schütze 2020-06-11 13:53:04 +02:00 committed by GitHub
commit 43b13e314e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 781 additions and 158 deletions

View File

@ -25,7 +25,7 @@ matrix:
- name: "Installing npm dependencies" - name: "Installing npm dependencies"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
cache: cache:
- directories: - directories:
- "client/node_modules" - "client/node_modules"
@ -39,7 +39,7 @@ matrix:
- stage: "Run tests" - stage: "Run tests"
name: "Client: Testing" name: "Client: Testing"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
apt: apt:
sources: sources:
- google-chrome - google-chrome
@ -56,7 +56,7 @@ matrix:
- name: "Client: Production Build (ES5)" - name: "Client: Production Build (ES5)"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
install: install:
- cd client - cd client
- sed -i '/\"target\"/c\\"target\":\"es5\",' tsconfig.json - sed -i '/\"target\"/c\\"target\":\"es5\",' tsconfig.json
@ -65,7 +65,7 @@ matrix:
- name: "Client: Production Build (ES2015)" - name: "Client: Production Build (ES2015)"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
install: install:
- cd client - cd client
- echo "Firefox ESR" > browserslist - echo "Firefox ESR" > browserslist
@ -74,7 +74,7 @@ matrix:
- name: "Client: Build" - name: "Client: Build"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
script: script:
- cd client - cd client
- npm run build-debug - npm run build-debug
@ -111,14 +111,14 @@ matrix:
- name: "Client: Linting" - name: "Client: Linting"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
script: script:
- cd client - cd client
- npm run lint-check - npm run lint-check
- name: "Client: Code Formatting Check" - name: "Client: Code Formatting Check"
language: node_js language: node_js
node_js: "10.13" node_js: "12.18"
script: script:
- cd client - cd client
- npm list --depth=0 || cat --help - npm list --depth=0 || cat --help

View File

@ -43,7 +43,11 @@
} }
], ],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": ["node_modules/tinymce/tinymce.min.js", "src/assets/jitsi/external_api.js"], "scripts": [
"node_modules/tinymce/tinymce.min.js",
"node_modules/video.js/dist/video.min.js",
"src/assets/jitsi/external_api.js"
],
"webWorkerTsConfig": "tsconfig.worker.json" "webWorkerTsConfig": "tsconfig.worker.json"
}, },
"configurations": { "configurations": {

View File

@ -51,6 +51,7 @@
"@pebula/ngrid-material": "2.0.0-rc.1", "@pebula/ngrid-material": "2.0.0-rc.1",
"@pebula/utils": "1.0.2", "@pebula/utils": "1.0.2",
"@tinymce/tinymce-angular": "^3.3.1", "@tinymce/tinymce-angular": "^3.3.1",
"@videojs/http-streaming": "^1.13.3",
"acorn": "^7.1.0", "acorn": "^7.1.0",
"chart.js": "^2.9.2", "chart.js": "^2.9.2",
"core-js": "^3.6.4", "core-js": "^3.6.4",
@ -61,8 +62,8 @@
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git", "material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
"moment": "^2.24.0", "moment": "^2.24.0",
"ng2-charts": "^2.3.0", "ng2-charts": "^2.3.0",
"ngx-file-drop": "^8.0.8",
"ng2-pdf-viewer": "^6.1.2", "ng2-pdf-viewer": "^6.1.2",
"ngx-file-drop": "^8.0.8",
"ngx-mat-select-search": "^2.1.2", "ngx-mat-select-search": "^2.1.2",
"ngx-material-timepicker": "^5.5.1", "ngx-material-timepicker": "^5.5.1",
"ngx-papaparse": "^4.0.2", "ngx-papaparse": "^4.0.2",
@ -71,6 +72,7 @@
"rxjs": "^6.5.4", "rxjs": "^6.5.4",
"tinymce": "5.2.2", "tinymce": "5.2.2",
"tslib": "^1.10.0", "tslib": "^1.10.0",
"video.js": "^7.7.6",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -2,9 +2,10 @@ import { Injectable } from '@angular/core';
import { environment } from 'environments/environment'; import { environment } from 'environments/environment';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { auditTime, filter } from 'rxjs/operators'; import { auditTime, filter, map } from 'rxjs/operators';
import { Group } from 'app/shared/models/users/group'; import { Group } from 'app/shared/models/users/group';
import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service';
import { ViewUser } from 'app/site/users/models/view-user'; import { ViewUser } from 'app/site/users/models/view-user';
import { CollectionStringMapperService } from './collection-string-mapper.service'; import { CollectionStringMapperService } from './collection-string-mapper.service';
import { DataStoreService } from './data-store.service'; import { DataStoreService } from './data-store.service';
@ -39,6 +40,7 @@ export enum Permission {
coreCanSeeFrontpage = 'core.can_see_frontpage', coreCanSeeFrontpage = 'core.can_see_frontpage',
coreCanSeeProjector = 'core.can_see_projector', coreCanSeeProjector = 'core.can_see_projector',
coreCanManageTags = 'core.can_manage_tags', coreCanManageTags = 'core.can_manage_tags',
coreCanSeeLiveStream = 'core.can_see_livestream',
mediafilesCanManage = 'mediafiles.can_manage', mediafilesCanManage = 'mediafiles.can_manage',
mediafilesCanSee = 'mediafiles.can_see', mediafilesCanSee = 'mediafiles.can_see',
motionsCanCreate = 'motions.can_create', motionsCanCreate = 'motions.can_create',
@ -208,7 +210,8 @@ export class OperatorService implements OnAfterAppsLoaded {
private offlineService: OfflineService, private offlineService: OfflineService,
private collectionStringMapper: CollectionStringMapperService, private collectionStringMapper: CollectionStringMapperService,
private storageService: StorageService, private storageService: StorageService,
private OSStatus: OpenSlidesStatusService private OSStatus: OpenSlidesStatusService,
private closService: CurrentListOfSpeakersService
) { ) {
this.DS.getChangeObservable(User).subscribe(newModel => { this.DS.getChangeObservable(User).subscribe(newModel => {
if (this._user && this._user.id === newModel.id) { if (this._user && this._user.id === newModel.id) {
@ -459,6 +462,16 @@ export class OperatorService implements OnAfterAppsLoaded {
await this.http.post(environment.urlPrefix + '/users/setpresence/', isPresent); await this.http.post(environment.urlPrefix + '/users/setpresence/', isPresent);
} }
public isOnCurrentListOfSpeakersObservable(): Observable<boolean> {
return this.closService.currentListOfSpeakersObservable.pipe(
map(los => {
if (los) {
return los.isUserOnList(this.user.id);
}
})
);
}
/** /**
* Returns a default WhoAmI response * Returns a default WhoAmI response
*/ */

View File

@ -1,6 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HttpService } from 'app/core/core-services/http.service'; import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service'; import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
@ -138,8 +140,21 @@ export class ProjectorRepositoryService extends BaseRepository<ViewProjector, Pr
/** /**
* return the id of the current reference projector * return the id of the current reference projector
* prefer the observable whenever possible
*/ */
public getReferenceProjectorId(): number { public getReferenceProjectorId(): number {
// TODO: After logging in, this is null this.getViewModelList() is null
return this.getViewModelList().find(projector => projector.isReferenceProjector).id; return this.getViewModelList().find(projector => projector.isReferenceProjector).id;
} }
public getReferenceProjectorIdObservable(): Observable<number> {
return this.getViewModelListObservable().pipe(
map(projectors => {
const refProjector = projectors.find(projector => projector.isReferenceProjector);
if (refProjector) {
return refProjector.id;
}
})
);
}
} }

View File

@ -1,30 +1,35 @@
<div class="jitsi-integration"> <!-- iFrame Dialog -->
<!-- iFrame Dialog --> <ng-template #conferenceDialog>
<ng-template #conferenceDialog> <div class="jitsi-iframe-wrapper" #jitsi></div>
<div class="jitsi-iframe-wrapper" #jitsi></div> <div mat-dialog-actions>
<div mat-dialog-actions> <button
<button type="button"
type="button" mat-button
mat-button color="primary"
color="primary" (click)="openExternal()"
(click)="openExternal()" matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}" >
> <mat-icon>open_in_new</mat-icon>
<mat-icon>open_in_new</mat-icon> </button>
</button>
<button class="minimize-jitsi-dialog-button" type="button" mat-button color="primary" (click)="toggleConferenceDialog()"> <button
<span>{{ 'Minimize' | translate }}</span> class="minimize-jitsi-dialog-button"
<mat-icon>fullscreen_exit</mat-icon> type="button"
</button> mat-button
</div> color="primary"
</ng-template> (click)="hideJitsiDialog()"
>
<span>{{ 'Minimize' | translate }}</span>
<mat-icon>fullscreen_exit</mat-icon>
</button>
</div>
</ng-template>
<div class="jitsi-integration" *ngIf="canSeeLiveStream && (enableJitsi || videoStreamUrl)">
<!-- Audio-Conference-bar --> <!-- Audio-Conference-bar -->
<div <div
class="jitsi-bar" class="jitsi-bar"
[ngClass]="{ [ngClass]="{
'cdk-visually-hidden': !enableJitsi,
'cast-shadow': !showJitsiWindow 'cast-shadow': !showJitsiWindow
}" }"
> >
@ -34,48 +39,85 @@
'cast-shadow': showJitsiWindow 'cast-shadow': showJitsiWindow
}" }"
> >
<!-- mute/unmute button --> <ng-container *ngIf="currentState == state.jitsi">
<button <!-- Exit jitsi -->
class="indicator quick-icon" <button
mat-mini-fab mat-mini-fab
*ngIf="isJoined" class="indicator quick-icon"
(click)="toggleMute()" color="accent"
matTooltip="{{ 'Mute / Unmute' | translate }}" (click)="viewStream()"
> matTooltip="{{ 'Exit live conference and view the live stream' | translate }}"
<mat-icon color="{{ muted ? 'primary' : 'warn' }}">{{ muted ? 'moff' : 'mic' }}</mat-icon> *ngIf="videoStreamUrl"
</button> >
<mat-icon color="warn">
meeting_room
</mat-icon>
</button>
<!-- disconnected icon --> <!-- mute/unmute button -->
<mat-icon class="indicator" *ngIf="!isJoined">cloud_off</mat-icon> <button
class="indicator quick-icon"
mat-mini-fab
*ngIf="isJoined"
(click)="toggleMute()"
matTooltip="{{ 'Mute / Unmute' | translate }}"
>
<mat-icon color="{{ muted ? 'primary' : 'warn' }}">{{ muted ? 'moff' : 'mic' }}</mat-icon>
</button>
<!-- hide unhide video --> <!-- disconnected icon -->
<!-- <button class="quick-icon" mat-mini-fab [disabled]="!isJoined"> <mat-icon class="indicator" *ngIf="!isJoined && !videoStreamUrl">cloud_off</mat-icon>
<mat-icon *ngIf="isJoined" color="primary">videocam_off</mat-icon> </ng-container>
</button> -->
<ng-container *ngIf="currentState == state.stream">
<!-- Enter conference from stream -->
<button
*ngIf="enableJitsi && isAccessPermitted"
class="indicator quick-icon"
mat-mini-fab
(click)="enterConversation()"
matTooltip="{{ 'Enter live conference' | translate }}"
>
<mat-icon color="primary">meeting_room</mat-icon>
</button>
<mat-icon *ngIf="enableJitsi && !isAccessPermitted" class="indicator">no_meeting_room</mat-icon>
</ng-container>
</span> </span>
<span <span
class="list-wrapper apply-theme" class="list-wrapper apply-theme"
[ngClass]="{ [ngClass]="{
'stream-width-wrapper': currentState == state.stream,
'audio-list-wrapper': currentState == state.jitsi,
'cast-shadow': showJitsiWindow 'cast-shadow': showJitsiWindow
}" }"
> >
<!-- <span class="list-wrapper apply-theme regular-shadow"> -->
<!-- open-window button --> <!-- open-window button -->
<button class="toggle-list-button" mat-button (click)="toggleShowJitsi()"> <button class="toggle-list-button" mat-button (click)="toggleShowJitsi()">
<span> {{ 'Audio conference' | translate }}</span> <ng-container *ngIf="currentState == state.jitsi">
<span> {{ 'Live conference' | translate }}</span>
<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 weid things here -->
<span> {{ 'Live stream' | translate }}</span>
</ng-container>
<mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon> <mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon>
<mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon> <mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon>
<div class="one-line">
&nbsp;
<span *ngIf="currentDominantSpeaker">
» <span class="dominant-speaker">{{ currentDominantSpeaker.displayName }}</span>
</span>
<span *ngIf="!isJitsiActive">
<i>{{ 'disconnected' | translate }}</i>
</span>
</div>
</button> </button>
<!-- unfolded list --> <!-- unfolded list -->
@ -85,85 +127,109 @@
'cdk-visually-hidden': !showJitsiWindow 'cdk-visually-hidden': !showJitsiWindow
}" }"
> >
<!-- Jitsi content window --> <ng-container *ngIf="currentState == state.jitsi">
<div class="content"> <!-- Jitsi content window -->
<!-- The "somewhere else active" warning --> <div class="content">
<div class="disconnected" *ngIf="isJitsiActiveInAnotherTab && !isJitsiActive"> <!-- The "somewhere else active" warning -->
<span>{{ <div class="disconnected" *ngIf="isJitsiActiveInAnotherTab && !isJitsiActive">
'The audio conference is already running in your OpenSlides session.' | translate <span>{{
}}</span> 'The audio conference is already running in your OpenSlides session.' | translate
<button mat-button color="warn" (click)="forceStart()"> }}</span>
<span>{{ 'Reenter to audio conference' | translate }}</span> <button mat-button color="warn" (click)="forceStart()">
<span>{{ 'Reenter to audio 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">
<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">
<os-vjs-player
[videoUrl]="videoStreamUrl"
(started)="onSteamStarted()"
*ngIf="!streamActiveInAnotherTab || streamRunning"
></os-vjs-player>
<div class="disconnected" *ngIf="streamActiveInAnotherTab && !streamRunning">
<span>{{ 'The video stream is already running in your OpenSlides session.' | translate }}</span>
<button mat-button color="warn" (click)="deleteStreamingLock()">
<span>{{ 'Force the stream to reload' | translate }}</span>
</button> </button>
</div> </div>
</ng-container>
<div class="disconnected" *ngIf="!isJitsiActiveInAnotherTab && !isJitsiActive"> <ng-container *ngIf="currentState == state.jitsi">
<span>{{ 'disconnected' | translate }}</span> <!-- Custom control buttons -->
</div> <div>
<mat-divider></mat-divider>
<!-- user list --> <div class="control-grid">
<div class="room-members" *ngIf="isJitsiActive"> <div class="control-buttons">
<div class="member-list"> <!-- Hangup -->
<ol> <button
<li mat-mini-fab
*ngFor="let memberId of memberList; trackBy: trackByIndex" color="warn"
[ngClass]="{ (click)="stopJitsi()"
focused: members[memberId].focus *ngIf="isJitsiActive && isJoined"
}" matTooltip="{{ 'Leave' | translate }}"
> >
<div class="member-list-entry"> <mat-icon>call_end</mat-icon>
{{ members[memberId].name }} </button>
</div>
</li>
</ol>
</div>
</div>
</div>
<!-- Custom control buttons --> <!-- Enter jitsi manually -->
<div> <button
<mat-divider></mat-divider> mat-mini-fab
<div class="control-grid"> color="accent"
<div class="control-buttons"> (click)="enterConversation()"
<!-- Hangup --> [disabled]="
<button !enableJitsi || isJitsiActive || isJitsiActiveInAnotherTab || !isAccessPermitted
mat-mini-fab "
color="warn" *ngIf="!isJoined"
(click)="stopJitsi()" matTooltip="{{ 'Enter conference' | translate }}"
*ngIf="isJitsiActive && isJoined" >
matTooltip="{{ 'Leave' | translate }}" <mat-icon>call</mat-icon>
> </button>
<mat-icon>call_end</mat-icon> </div>
</button>
<!-- Enter jitsi manually --> <!-- open dialog -->
<button <button
mat-mini-fab mat-icon-button
class="open-jitsi-in-tab"
color="accent" color="accent"
(click)="enterConversation()" (click)="toggleConferenceDialog()"
[disabled]="isJitsiActive || isJitsiActiveInAnotherTab" [disabled]="!isJitsiActive"
*ngIf="!isJoined" matTooltip="{{ 'Maximize / minimize Jitsi window' | translate }}"
matTooltip="{{ 'Enter conference' | translate }}"
> >
<mat-icon>call</mat-icon> <mat-icon>
{{ isJitsiDialogOpen ? 'fullscreen_exit' : 'fullscreen' }}
</mat-icon>
</button> </button>
</div> </div>
<!-- open dialog -->
<button
mat-icon-button
class="open-jitsi-in-tab"
color="accent"
(click)="toggleConferenceDialog()"
[disabled]="!isJitsiActive"
matTooltip="{{ 'Maximize Jitsi window' | translate }}"
>
<mat-icon>
fullscreen
</mat-icon>
</button>
</div> </div>
</div> </ng-container>
</div> </div>
</span> </span>
</div> </div>

View File

@ -19,11 +19,8 @@
} }
.jitsi-bar { .jitsi-bar {
z-index: 99;
display: flex; display: flex;
position: fixed; margin-right: 20px;
right: 20px;
bottom: 0;
$wrapper-padding: 5px; $wrapper-padding: 5px;
$bar-height: 40px; $bar-height: 40px;
@ -43,17 +40,26 @@
} }
} }
.stream-width-wrapper {
width: 500px;
max-width: 500px;
}
.audio-list-wrapper {
width: 300px;
max-width: 300px;
}
.list-wrapper { .list-wrapper {
min-height: $bar-height; min-height: $bar-height;
padding-top: $wrapper-padding; padding-top: $wrapper-padding;
border-top-right-radius: 4px; border-top-right-radius: 4px;
width: 250px;
max-width: 250px;
.toggle-list-button { .toggle-list-button {
position: relative; position: relative;
line-height: normal; line-height: normal;
width: 100%; width: 100%;
height: 40px;
padding: 0 2.5em; padding: 0 2.5em;
margin-bottom: $wrapper-padding; margin-bottom: $wrapper-padding;
font-weight: normal; font-weight: normal;
@ -62,7 +68,7 @@
.opened-indicator { .opened-indicator {
position: absolute; position: absolute;
right: $wrapper-padding; right: $wrapper-padding;
top: $wrapper-padding; top: 8px;
} }
.dominant-speaker { .dominant-speaker {
@ -113,9 +119,13 @@
.control-grid { .control-grid {
padding: $wrapper-padding 0; padding: $wrapper-padding 0;
display: grid; display: grid;
grid-template-areas: 'helper buttons new-tab'; grid-template-areas: 'exit buttons new-tab';
grid-template-columns: 40px auto 40px; grid-template-columns: 40px auto 40px;
.exit-conference {
grid-area: exit;
}
.control-buttons { .control-buttons {
grid-area: buttons; grid-area: buttons;
margin: auto; margin: auto;

View File

@ -55,6 +55,11 @@ interface ConferenceMember {
focus: boolean; focus: boolean;
} }
enum ConferenceState {
stream,
jitsi
}
@Component({ @Component({
selector: 'os-jitsi', selector: 'os-jitsi',
templateUrl: './jitsi.component.html', templateUrl: './jitsi.component.html',
@ -63,14 +68,19 @@ interface ConferenceMember {
}) })
export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy { export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
public enableJitsi: boolean; public enableJitsi: boolean;
private autoconnect: boolean; private autoconnect: boolean;
private roomName: string; private roomName: string;
private roomPassword: string; private roomPassword: string;
private jitsiDomain: string; private jitsiDomain: string;
public restricted = false;
public videoStreamUrl: string;
// do not set the password twice // do not set the password twice
private isPasswortSet = false; private isPasswortSet = false;
public isJitsiDialogOpen = false;
public showJitsiWindow = false; public showJitsiWindow = false;
public muted = true; public muted = true;
@ -90,15 +100,21 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
} }
public isJoined: boolean; public isJoined: boolean;
public streamRunning: boolean;
private options: object; private options: object;
private lockLoaded: Deferred<void> = new Deferred(); private lockLoaded: Deferred<void> = new Deferred();
private constantsLoaded: Deferred<void> = new Deferred(); private constantsLoaded: Deferred<void> = new Deferred();
private configsLoaded: Deferred<void> = new Deferred();
// storage locks // storage locks
public isJitsiActiveInAnotherTab: boolean; public isJitsiActiveInAnotherTab: boolean;
public streamActiveInAnotherTab: boolean;
private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn'; private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
private STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning';
private CONFERENCE_STATE_STORAGE_KEY = 'conferenceState';
// JitsiID to ConferenceMember // JitsiID to ConferenceMember
public members = {}; public members = {};
@ -112,6 +128,27 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
return this.roomPassword?.length > 0; return this.roomPassword?.length > 0;
} }
public get canSeeLiveStream(): boolean {
return this.operator.hasPerms(this.permission.coreCanSeeLiveStream);
}
private isOnCurrentLos: boolean;
public get isAccessPermitted(): boolean {
return (
!this.restricted ||
this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers) ||
this.isOnCurrentLos
);
}
/**
* The conference state, to determine if the user consumes the stream or can
* contribute to jitsi
*/
public state = ConferenceState;
public currentState: ConferenceState;
private configOverwrite = { private configOverwrite = {
startAudioOnly: false, startAudioOnly: false,
// allows jitsi on mobile devices // allows jitsi on mobile devices
@ -158,15 +195,22 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
this.setUp(); this.setUp();
} }
public ngOnDestroy(): void { public async ngOnDestroy(): Promise<void> {
this.stopJitsi(); this.stopConference();
} }
// closing the tab should also try to stop jitsi. // closing the tab should also try to stop jitsi.
// this will usually not be cought by ngOnDestroy // this will usually not be cought by ngOnDestroy
@HostListener('window:beforeunload', ['$event']) @HostListener('window:beforeunload', ['$event'])
public async beforeunload($event: any): Promise<void> { public async beforeunload($event: any): Promise<void> {
await this.stopConference();
}
private async stopConference(): Promise<void> {
await this.stopJitsi(); await this.stopJitsi();
if (this.streamActiveInAnotherTab && this.streamRunning) {
await this.deleteStreamingLock();
}
} }
private async setUp(): Promise<void> { private async setUp(): Promise<void> {
@ -181,6 +225,13 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
} }
}); });
this.storageMap
.watch(this.STREAM_RUNNING_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((running: boolean) => {
this.streamActiveInAnotherTab = running;
});
await this.lockLoaded; await this.lockLoaded;
this.constantsService.get<JitsiSettings>('Settings').subscribe(settings => { this.constantsService.get<JitsiSettings>('Settings').subscribe(settings => {
if (settings) { if (settings) {
@ -193,10 +244,10 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
await this.constantsLoaded; await this.constantsLoaded;
this.configService this.configService
.get<boolean>('general_system_conference_show') .get<boolean>('general_system_conference_auto_connect')
.subscribe(autoconnect => (this.autoconnect = autoconnect)); .subscribe(autoconnect => (this.autoconnect = autoconnect));
this.configService.get<boolean>('general_system_conference_auto_connect').subscribe(show => { this.configService.get<boolean>('general_system_conference_show').subscribe(show => {
this.enableJitsi = show && !!this.jitsiDomain && !!this.roomName; this.enableJitsi = show && !!this.jitsiDomain && !!this.roomName;
if (this.enableJitsi && this.autoconnect) { if (this.enableJitsi && this.autoconnect) {
this.startJitsi(); this.startJitsi();
@ -204,6 +255,51 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
this.stopJitsi(); this.stopJitsi();
} }
}); });
this.configService.get<boolean>('general_system_conference_los_restriction').subscribe(restricted => {
this.restricted = restricted;
});
this.configService.get<string>('general_system_stream_url').subscribe(url => {
this.videoStreamUrl = url;
this.configsLoaded.resolve();
});
await this.configsLoaded;
// after configs are loaded
this.storageMap
.watch(this.CONFERENCE_STATE_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((confState: ConferenceState) => {
if (confState in ConferenceState) {
if (this.enableJitsi && !this.videoStreamUrl) {
this.currentState = ConferenceState.jitsi;
} else if (!this.enableJitsi && this.videoStreamUrl) {
this.currentState = ConferenceState.stream;
} else {
this.currentState = confState;
}
} else {
this.setDefaultConfState();
}
// show stream window when the state changes to stream
if (this.currentState === ConferenceState.stream && !this.streamActiveInAnotherTab) {
this.showJitsiWindow = true;
}
});
// check if the user is on the clos, remove from room if not permitted
this.operator
.isOnCurrentListOfSpeakersObservable()
.pipe(distinctUntilChanged())
.subscribe(isOnList => {
this.isOnCurrentLos = isOnList;
console.log('this.isOnCurrentLos: ', this.isOnCurrentLos);
if (!this.isAccessPermitted) {
this.viewStream();
}
});
} }
public toggleMute(): void { public toggleMute(): void {
@ -227,6 +323,7 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
public async enterConversation(): Promise<void> { public async enterConversation(): Promise<void> {
await this.operator.loaded; await this.operator.loaded;
this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {}); this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {});
this.setConferenceState(ConferenceState.jitsi);
this.setOptions(); this.setOptions();
this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options); this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options);
@ -273,6 +370,9 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
this.isJoined = true; this.isJoined = true;
this.addMember({ displayName: info.displayName, id: info.id }); this.addMember({ displayName: info.displayName, id: info.id });
this.setRoomPassword(); this.setRoomPassword();
if (this.videoStreamUrl) {
this.showJitsiDialog();
}
} }
private setRoomPassword(): void { private setRoomPassword(): void {
@ -355,21 +455,26 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
} }
public toggleConferenceDialog(): void { public toggleConferenceDialog(): void {
// there is no good way to detect the current classes in MatDialogRef or conferenceDialog. if (this.isJitsiDialogOpen) {
// searching the global cdk-overlay-pane is the only thing which works this.hideJitsiDialog();
const pane = document.querySelector('.cdk-overlay-pane') as HTMLElement;
if (pane.classList.contains('jitsi-dialog-hide')) {
this.confDialogRef.removePanelClass('jitsi-dialog-hide');
} else { } else {
this.confDialogRef.addPanelClass('jitsi-dialog-hide'); this.showJitsiDialog();
} }
} }
private hideJitsiDialog(): void { public hideJitsiDialog(): void {
const pane = document.querySelector('.cdk-overlay-pane') as HTMLElement; this.confDialogRef.addPanelClass('jitsi-dialog-hide');
if (!pane.classList.contains('jitsi-dialog-hide')) { this.isJitsiDialogOpen = false;
this.confDialogRef.addPanelClass('jitsi-dialog-hide'); }
}
public showJitsiDialog(): void {
this.confDialogRef.removePanelClass('jitsi-dialog-hide');
this.isJitsiDialogOpen = true;
}
public async viewStream(): Promise<void> {
this.stopJitsi();
this.setConferenceState(ConferenceState.stream);
} }
public openExternal(): void { public openExternal(): void {
@ -377,7 +482,28 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
window.open(this.getJitsiMeetUrl(), '_blank'); window.open(this.getJitsiMeetUrl(), '_blank');
} }
public onSteamStarted(): void {
this.streamRunning = true;
this.storageMap.set(this.STREAM_RUNNING_STORAGE_KEY, true).subscribe(() => {});
}
private async deleteJitsiLock(): Promise<void> { private async deleteJitsiLock(): Promise<void> {
await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise(); 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 setDefaultConfState(): void {
this.videoStreamUrl
? this.setConferenceState(ConferenceState.stream)
: this.setConferenceState(ConferenceState.jitsi);
}
private setConferenceState(newState: ConferenceState): void {
if (this.currentState !== newState) {
this.storageMap.set(this.CONFERENCE_STATE_STORAGE_KEY, newState).subscribe(() => {});
}
}
} }

View File

@ -0,0 +1,29 @@
<div class="stream-integration">
<div
class="stream-wrapper"
[ngClass]="{
'cdk-visually-hidden': !showStream
}"
>
<div *ngIf="!isUserInConference">
<os-vjs-player></os-vjs-player>
</div>
<div class="user-in-conf-warning" *ngIf="isUserInConference">
<span>
{{ 'The stream is disableb because you are inside a conference' | translate }}
</span>
<button mat-raised-button color="warn" (click)="forceReloadStream()">
{{ 'Force the stream to reload' | translate }}
</button>
</div>
</div>
<div class="stream-bar">
<button class="toggle-list-button" color="primary" mat-flat-button (click)="toggleShowStream()">
<span> {{ 'Live stream' | translate }}</span>
<mat-icon class="opened-indicator" *ngIf="!showStream">keyboard_arrow_up</mat-icon>
<mat-icon class="opened-indicator" *ngIf="showStream">keyboard_arrow_down </mat-icon>
</button>
</div>
</div>

View File

@ -0,0 +1,40 @@
.stream-integration {
.stream-wrapper {
// position: absolute;
margin-right: 20px;
margin-bottom: 5px;
float: right;
}
.user-in-conf-warning {
width: 300px;
height: 200px;
padding: 20px;
background-color: white;
button {
display: block;
}
}
.stream-bar {
margin-right: 20px;
// display: flex;
margin-right: 20px;
$wrapper-padding: 5px;
$bar-height: 40px;
.toggle-list-button {
height: 50px;
display: block;
margin-left: auto;
padding-right: 0.5em;
font-weight: normal;
text-align: right;
line-height: normal;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
}

View File

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

View File

@ -0,0 +1,43 @@
import { Component, OnInit } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'os-live-stream',
templateUrl: './live-stream.component.html',
styleUrls: ['./live-stream.component.scss']
})
export class LiveStreamComponent implements OnInit {
public showStream = false;
private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
public isUserInConference: boolean;
public constructor(private storageMap: StorageMap) {}
public ngOnInit(): void {
this.storageMap
.watch(this.RTC_LOGGED_STORAGE_KEY)
.pipe(distinctUntilChanged())
.subscribe((inUse: boolean) => {
this.isUserInConference = inUse;
});
}
public toggleShowStream(): void {
this.showStream = !this.showStream;
}
public async forceReloadStream(): Promise<void> {
await this.deleteJitsiLock();
}
/**
* todo: DUP
*/
private async deleteJitsiLock(): Promise<void> {
await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise();
}
}

View File

@ -0,0 +1,3 @@
<div class="video-wrapper">
<video #videoPlayer class="video-js" controls preload="none"></video>
</div>

View File

@ -0,0 +1,25 @@
.video-wrapper {
display: flex;
width: 100%;
.video-js {
margin: auto;
.vjs-control-bar {
.vjs-subs-caps-button {
display: none !important;
}
.vjs-descriptions-button {
display: none !important;
}
.vjs-picture-in-picture-control {
display: none !important;
}
.vjs-audio-button {
display: none !important;
}
}
}
}

View File

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

View File

@ -0,0 +1,99 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import videojs from 'video.js';
interface VideoSource {
src: string;
type: MimeType;
}
enum MimeType {
mp4 = 'video/mp4',
mpd = 'application/dash+xml',
m3u8 = 'application/x-mpegURL'
}
@Component({
selector: 'os-vjs-player',
templateUrl: './vjs-player.component.html',
styleUrls: ['./vjs-player.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class VjsPlayerComponent implements OnInit, OnDestroy {
@ViewChild('videoPlayer', { static: true }) private videoPlayer: ElementRef;
private _videoUrl: string;
@Input()
public set videoUrl(value: string) {
this._videoUrl = value;
this.playVideo();
}
@Output()
private started: EventEmitter<void> = new EventEmitter();
public get videoUrl(): string {
return this._videoUrl;
}
public player: videojs.Player;
private get videoSource(): VideoSource {
return {
src: this.videoUrl,
type: this.determineContentTypeByUrl(this.videoUrl)
};
}
public constructor() {}
public async ngOnInit(): Promise<void> {
this.player = videojs(this.videoPlayer.nativeElement, {
textTrackSettings: false,
fluid: true,
autoplay: 'any',
liveui: true
});
this.playVideo();
}
public ngOnDestroy(): void {
if (this.player) {
this.player.dispose();
}
}
private playVideo(): void {
if (this.player) {
this.player.src(this.videoSource);
this.started.next();
}
}
private determineContentTypeByUrl(url: string): MimeType {
if (url) {
if (url.startsWith('rtmp')) {
throw new Error(`$rtmp (flash) streams cannot be supported`);
} else {
const extension = url?.split('.')?.pop();
const mimeType = MimeType[extension];
if (mimeType) {
return mimeType;
} else {
throw new Error(`${url} has an unknown mime type`);
}
}
}
}
}

View File

@ -127,6 +127,8 @@ 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 { JitsiComponent } from './components/jitsi/jitsi.component';
import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component';
import { LiveStreamComponent } from './components/live-stream/live-stream.component';
/** /**
* Share Module for all "dumb" components and pipes. * Share Module for all "dumb" components and pipes.
@ -294,7 +296,9 @@ import { JitsiComponent } from './components/jitsi/jitsi.component';
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent JitsiComponent,
VjsPlayerComponent,
LiveStreamComponent
], ],
declarations: [ declarations: [
PermsDirective, PermsDirective,
@ -355,7 +359,9 @@ import { JitsiComponent } from './components/jitsi/jitsi.component';
VotingPrivacyWarningComponent, VotingPrivacyWarningComponent,
MotionPollDetailContentComponent, MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent, AssignmentPollDetailContentComponent,
JitsiComponent JitsiComponent,
VjsPlayerComponent,
LiveStreamComponent
], ],
providers: [ providers: [
{ {

View File

@ -63,6 +63,10 @@ export class ViewListOfSpeakers extends BaseViewModelWithContentObject<ListOfSpe
public hasSpeakerSpoken(checkSpeaker: ViewSpeaker): boolean { public hasSpeakerSpoken(checkSpeaker: ViewSpeaker): boolean {
return this.finishedSpeakers.findIndex(speaker => speaker.user_id === checkSpeaker.user_id) !== -1; return this.finishedSpeakers.findIndex(speaker => speaker.user_id === checkSpeaker.user_id) !== -1;
} }
public isUserOnList(userId: number): boolean {
return !!this.speakers.find(speaker => speaker.user_id === userId);
}
} }
interface IListOfSpeakersRelations { interface IListOfSpeakersRelations {
speakers: ViewSpeaker[]; speakers: ViewSpeaker[];

View File

@ -18,6 +18,11 @@ import { ViewProjector } from '../models/view-projector';
providedIn: 'root' providedIn: 'root'
}) })
export class CurrentListOfSpeakersService { export class CurrentListOfSpeakersService {
/**
* Id of the current lost of speakers projector. Filled through observer
*/
private closId: number;
/** /**
* This map holds the current (number or null) los-id for the the projector. * This map holds the current (number or null) los-id for the the projector.
* It is used to check, if the reference has changed (this clos id changed for one projector). * It is used to check, if the reference has changed (this clos id changed for one projector).
@ -34,6 +39,10 @@ export class CurrentListOfSpeakersService {
*/ */
private currentListOfSpeakers: { [projectorId: number]: BehaviorSubject<ViewListOfSpeakers | null> } = {}; private currentListOfSpeakers: { [projectorId: number]: BehaviorSubject<ViewListOfSpeakers | null> } = {};
private currentListOfSpeakerSubject = new BehaviorSubject<ViewListOfSpeakers>(null);
public currentListOfSpeakersObservable = this.currentListOfSpeakerSubject.asObservable();
public constructor( public constructor(
private projectorService: ProjectorService, private projectorService: ProjectorService,
private projectorRepo: ProjectorRepositoryService, private projectorRepo: ProjectorRepositoryService,
@ -46,6 +55,21 @@ export class CurrentListOfSpeakersService {
this.setListOfSpeakersForProjector(projector); this.setListOfSpeakersForProjector(projector);
} }
}); });
this.projectorRepo.getReferenceProjectorIdObservable().subscribe(closId => {
if (closId) {
this.closId = closId;
this.currentListOfSpeakerSubject.next(this.getCurrentListOfSpeakers());
}
});
}
/**
* Use the subject to get it
*/
private getCurrentListOfSpeakers(): ViewListOfSpeakers | null {
const refProjector = this.projectorRepo.getViewModel(this.closId);
return this.getCurrentListOfSpeakersForProjector(refProjector);
} }
/** /**

View File

@ -89,12 +89,16 @@
<mat-icon>arrow_forward_ios</mat-icon> <mat-icon>arrow_forward_ios</mat-icon>
</button> </button>
</div> </div>
<os-jitsi></os-jitsi>
<div (touchstart)="swipe($event, 'start')" (touchend)="swipe($event, 'end')" class="content"> <div (touchstart)="swipe($event, 'start')" (touchend)="swipe($event, 'end')" class="content">
<main> <main>
<router-outlet #o="outlet"></router-outlet> <router-outlet #o="outlet"></router-outlet>
</main> </main>
</div> </div>
<div class="toolbars">
<!-- test -->
<os-jitsi></os-jitsi>
<!-- <os-live-stream></os-live-stream> -->
</div>
</mat-sidenav-content> </mat-sidenav-content>
</mat-sidenav-container> </mat-sidenav-container>

View File

@ -134,3 +134,15 @@ mat-sidenav-container {
} }
} }
} }
.toolbars {
z-index: 99;
position: fixed;
right: 0;
bottom: 0;
display: flex;
* {
margin-top: auto;
}
}

View File

@ -46,6 +46,9 @@ $narrow-spacing: (
spacing: $pbl-spacing-theme-narrow spacing: $pbl-spacing-theme-narrow
); );
/** Videjs */
@import '~video.js/dist/video-js.css';
/** Mix the component-related style-rules */ /** Mix the component-related style-rules */
@mixin openslides-components-theme($theme) { @mixin openslides-components-theme($theme) {
@include os-site-theme($theme); @include os-site-theme($theme);

View File

@ -97,30 +97,48 @@ def get_config_variables():
) )
yield ConfigVariable( yield ConfigVariable(
name="general_system_conference_auto_connect", name="general_system_conference_show",
default_value=False, default_value=False,
input_type="boolean", input_type="boolean",
label="Show audio conference window", label="Show live conference window",
help_text="Server settings required to activate Jitsi Meet integration.", help_text="Server settings required to activate Jitsi Meet integration.",
weight=140, weight=140,
subgroup="System", subgroup="System",
) )
yield ConfigVariable( yield ConfigVariable(
name="general_system_conference_show", name="general_system_conference_auto_connect",
default_value=False, default_value=False,
input_type="boolean", input_type="boolean",
label="Connect all users to audio conference automatically", label="Connect all users to live conference automatically",
help_text="Server settings required to activate Jitsi Meet integration.",
weight=141,
subgroup="System",
)
yield ConfigVariable(
name="general_system_conference_los_restriction",
default_value=False,
input_type="boolean",
label="Allow only speakers and permitted users to enter the live conference",
help_text="Server settings required to activate Jitsi Meet integration.", help_text="Server settings required to activate Jitsi Meet integration.",
weight=142, weight=142,
subgroup="System", subgroup="System",
) )
yield ConfigVariable(
name="general_system_stream_url",
default_value="",
label="Live stream url",
weight=143,
subgroup="System",
)
yield ConfigVariable( yield ConfigVariable(
name="general_login_info_text", name="general_login_info_text",
default_value="", default_value="",
label="Show this text on the login page", label="Show this text on the login page",
weight=144, weight=145,
subgroup="System", subgroup="System",
) )

View File

@ -0,0 +1,25 @@
# Generated by Django 2.2.12 on 2020-06-05 09:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0032_add_monospace_font"),
]
operations = [
migrations.AlterModelOptions(
name="projector",
options={
"default_permissions": (),
"permissions": (
("can_see_projector", "Can see the projector"),
("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"),
("can_see_livestream", "Can see the live stream"),
),
},
),
]

View File

@ -118,6 +118,7 @@ class Projector(RESTModelMixin, models.Model):
("can_see_projector", "Can see the projector"), ("can_see_projector", "Can see the projector"),
("can_manage_projector", "Can manage the projector"), ("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"), ("can_see_frontpage", "Can see the front page"),
("can_see_livestream", "Can see the live stream"),
) )