Integrate streams

Integrate live streaming inside the jitsi/rtc components.
Live streaming works without jitsi, but is using the same components for
a fluid integration.
A streaming URL can be set in the settings page.
Users EITHER consume the live stream OR are presend in a jitsi live
conference.

To consume both the live stream and the jitsi conference, users may use
a dedicated jitsi tab in their session.

The jitsi users can be restricted to only allow thouse with the right
the manage speakers or being present on the "current list of speakers",
automatically simulating a virtual plenum
This commit is contained in:
Sean 2020-06-11 11:20:00 +02:00
parent fbb0be6fb4
commit 0d9738b72d
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,6 +1,5 @@
<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
@ -13,18 +12,24 @@
<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
class="minimize-jitsi-dialog-button"
type="button"
mat-button
color="primary"
(click)="hideJitsiDialog()"
>
<span>{{ 'Minimize' | translate }}</span> <span>{{ 'Minimize' | translate }}</span>
<mat-icon>fullscreen_exit</mat-icon> <mat-icon>fullscreen_exit</mat-icon>
</button> </button>
</div> </div>
</ng-template> </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,6 +39,21 @@
'cast-shadow': showJitsiWindow 'cast-shadow': showJitsiWindow
}" }"
> >
<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 view the live stream' | translate }}"
*ngIf="videoStreamUrl"
>
<mat-icon color="warn">
meeting_room
</mat-icon>
</button>
<!-- mute/unmute button --> <!-- mute/unmute button -->
<button <button
class="indicator quick-icon" class="indicator quick-icon"
@ -46,27 +66,37 @@
</button> </button>
<!-- disconnected icon --> <!-- disconnected icon -->
<mat-icon class="indicator" *ngIf="!isJoined">cloud_off</mat-icon> <mat-icon class="indicator" *ngIf="!isJoined && !videoStreamUrl">cloud_off</mat-icon>
</ng-container>
<!-- hide unhide video --> <ng-container *ngIf="currentState == state.stream">
<!-- <button class="quick-icon" mat-mini-fab [disabled]="!isJoined"> <!-- Enter conference from stream -->
<mat-icon *ngIf="isJoined" color="primary">videocam_off</mat-icon> <button
</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">
<mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon> <span> {{ 'Live conference' | translate }}</span>
<mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon>
<div class="one-line"> <div class="one-line">
&nbsp; &nbsp;
<span *ngIf="currentDominantSpeaker"> <span *ngIf="currentDominantSpeaker">
@ -75,7 +105,19 @@
<span *ngIf="!isJitsiActive"> <span *ngIf="!isJitsiActive">
<i>{{ 'disconnected' | translate }}</i> <i>{{ 'disconnected' | translate }}</i>
</span> </span>
<span *ngIf="isJitsiActive && !isJoined">
<i>{{ 'connecting...' | translate }}</i>
</span>
</div> </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_down </mat-icon>
</button> </button>
<!-- unfolded list --> <!-- unfolded list -->
@ -85,6 +127,7 @@
'cdk-visually-hidden': !showJitsiWindow 'cdk-visually-hidden': !showJitsiWindow
}" }"
> >
<ng-container *ngIf="currentState == state.jitsi">
<!-- Jitsi content window --> <!-- Jitsi content window -->
<div class="content"> <div class="content">
<!-- The "somewhere else active" warning --> <!-- The "somewhere else active" warning -->
@ -101,8 +144,12 @@
<span>{{ 'disconnected' | translate }}</span> <span>{{ 'disconnected' | translate }}</span>
</div> </div>
<div class="disconnected" *ngIf="isJitsiActive && !isJoined">
<span>{{ 'connecting...' | translate }}</span>
</div>
<!-- user list --> <!-- user list -->
<div class="room-members" *ngIf="isJitsiActive"> <div class="room-members" *ngIf="isJitsiActive && isJoined">
<div class="member-list"> <div class="member-list">
<ol> <ol>
<li <li
@ -119,7 +166,23 @@
</div> </div>
</div> </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>
</div>
</ng-container>
<ng-container *ngIf="currentState == state.jitsi">
<!-- Custom control buttons --> <!-- Custom control buttons -->
<div> <div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@ -141,7 +204,9 @@
mat-mini-fab mat-mini-fab
color="accent" color="accent"
(click)="enterConversation()" (click)="enterConversation()"
[disabled]="isJitsiActive || isJitsiActiveInAnotherTab" [disabled]="
!enableJitsi || isJitsiActive || isJitsiActiveInAnotherTab || !isAccessPermitted
"
*ngIf="!isJoined" *ngIf="!isJoined"
matTooltip="{{ 'Enter conference' | translate }}" matTooltip="{{ 'Enter conference' | translate }}"
> >
@ -156,14 +221,15 @@
color="accent" color="accent"
(click)="toggleConferenceDialog()" (click)="toggleConferenceDialog()"
[disabled]="!isJitsiActive" [disabled]="!isJitsiActive"
matTooltip="{{ 'Maximize Jitsi window' | translate }}" matTooltip="{{ 'Maximize / minimize Jitsi window' | translate }}"
> >
<mat-icon> <mat-icon>
fullscreen {{ isJitsiDialogOpen ? 'fullscreen_exit' : 'fullscreen' }}
</mat-icon> </mat-icon>
</button> </button>
</div> </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;
if (!pane.classList.contains('jitsi-dialog-hide')) {
this.confDialogRef.addPanelClass('jitsi-dialog-hide'); this.confDialogRef.addPanelClass('jitsi-dialog-hide');
this.isJitsiDialogOpen = false;
} }
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"),
) )