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

View File

@ -43,7 +43,11 @@
}
],
"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"
},
"configurations": {

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { Injectable } from '@angular/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 { 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
* prefer the observable whenever possible
*/
public getReferenceProjectorId(): number {
// TODO: After logging in, this is null this.getViewModelList() is null
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 -->
<ng-template #conferenceDialog>
<div class="jitsi-iframe-wrapper" #jitsi></div>
<div mat-dialog-actions>
<button
type="button"
mat-button
color="primary"
(click)="openExternal()"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
>
<mat-icon>open_in_new</mat-icon>
</button>
<!-- iFrame Dialog -->
<ng-template #conferenceDialog>
<div class="jitsi-iframe-wrapper" #jitsi></div>
<div mat-dialog-actions>
<button
type="button"
mat-button
color="primary"
(click)="openExternal()"
matTooltip="{{ 'Open Jitsi in new tab' | translate }}"
>
<mat-icon>open_in_new</mat-icon>
</button>
<button class="minimize-jitsi-dialog-button" type="button" mat-button color="primary" (click)="toggleConferenceDialog()">
<span>{{ 'Minimize' | translate }}</span>
<mat-icon>fullscreen_exit</mat-icon>
</button>
</div>
</ng-template>
<button
class="minimize-jitsi-dialog-button"
type="button"
mat-button
color="primary"
(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 -->
<div
class="jitsi-bar"
[ngClass]="{
'cdk-visually-hidden': !enableJitsi,
'cast-shadow': !showJitsiWindow
}"
>
@ -34,48 +39,85 @@
'cast-shadow': showJitsiWindow
}"
>
<!-- mute/unmute button -->
<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>
<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>
<!-- disconnected icon -->
<mat-icon class="indicator" *ngIf="!isJoined">cloud_off</mat-icon>
<!-- mute/unmute button -->
<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 -->
<!-- <button class="quick-icon" mat-mini-fab [disabled]="!isJoined">
<mat-icon *ngIf="isJoined" color="primary">videocam_off</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="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
class="list-wrapper apply-theme"
[ngClass]="{
'stream-width-wrapper': currentState == state.stream,
'audio-list-wrapper': currentState == state.jitsi,
'cast-shadow': showJitsiWindow
}"
>
<!-- <span class="list-wrapper apply-theme regular-shadow"> -->
<!-- open-window button -->
<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_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>
<!-- unfolded list -->
@ -85,85 +127,109 @@
'cdk-visually-hidden': !showJitsiWindow
}"
>
<!-- Jitsi content window -->
<div class="content">
<!-- The "somewhere else active" warning -->
<div class="disconnected" *ngIf="isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{
'The audio conference is already running in your OpenSlides session.' | translate
}}</span>
<button mat-button color="warn" (click)="forceStart()">
<span>{{ 'Reenter to audio conference' | translate }}</span>
<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 audio conference is already running in your OpenSlides session.' | 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>
</div>
</ng-container>
<div class="disconnected" *ngIf="!isJitsiActiveInAnotherTab && !isJitsiActive">
<span>{{ 'disconnected' | translate }}</span>
</div>
<!-- user list -->
<div class="room-members" *ngIf="isJitsiActive">
<div class="member-list">
<ol>
<li
*ngFor="let memberId of memberList; trackBy: trackByIndex"
[ngClass]="{
focused: members[memberId].focus
}"
<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 }}"
>
<div class="member-list-entry">
{{ members[memberId].name }}
</div>
</li>
</ol>
</div>
</div>
</div>
<mat-icon>call_end</mat-icon>
</button>
<!-- 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)="enterConversation()"
[disabled]="
!enableJitsi || isJitsiActive || isJitsiActiveInAnotherTab || !isAccessPermitted
"
*ngIf="!isJoined"
matTooltip="{{ 'Enter conference' | translate }}"
>
<mat-icon>call</mat-icon>
</button>
</div>
<!-- Enter jitsi manually -->
<!-- open dialog -->
<button
mat-mini-fab
mat-icon-button
class="open-jitsi-in-tab"
color="accent"
(click)="enterConversation()"
[disabled]="isJitsiActive || isJitsiActiveInAnotherTab"
*ngIf="!isJoined"
matTooltip="{{ 'Enter conference' | translate }}"
(click)="toggleConferenceDialog()"
[disabled]="!isJitsiActive"
matTooltip="{{ 'Maximize / minimize Jitsi window' | translate }}"
>
<mat-icon>call</mat-icon>
<mat-icon>
{{ isJitsiDialogOpen ? 'fullscreen_exit' : 'fullscreen' }}
</mat-icon>
</button>
</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>
</ng-container>
</div>
</span>
</div>

View File

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

View File

@ -55,6 +55,11 @@ interface ConferenceMember {
focus: boolean;
}
enum ConferenceState {
stream,
jitsi
}
@Component({
selector: 'os-jitsi',
templateUrl: './jitsi.component.html',
@ -63,14 +68,19 @@ interface ConferenceMember {
})
export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
public enableJitsi: boolean;
private autoconnect: boolean;
private roomName: string;
private roomPassword: string;
private jitsiDomain: string;
public restricted = false;
public videoStreamUrl: string;
// do not set the password twice
private isPasswortSet = false;
public isJitsiDialogOpen = false;
public showJitsiWindow = false;
public muted = true;
@ -90,15 +100,21 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
}
public isJoined: boolean;
public streamRunning: boolean;
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: boolean;
public streamActiveInAnotherTab: boolean;
private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn';
private STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning';
private CONFERENCE_STATE_STORAGE_KEY = 'conferenceState';
// JitsiID to ConferenceMember
public members = {};
@ -112,6 +128,27 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
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 = {
startAudioOnly: false,
// allows jitsi on mobile devices
@ -158,15 +195,22 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
this.setUp();
}
public ngOnDestroy(): void {
this.stopJitsi();
public async ngOnDestroy(): Promise<void> {
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();
}
private async stopConference(): Promise<void> {
await this.stopJitsi();
if (this.streamActiveInAnotherTab && this.streamRunning) {
await this.deleteStreamingLock();
}
}
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;
this.constantsService.get<JitsiSettings>('Settings').subscribe(settings => {
if (settings) {
@ -193,10 +244,10 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
await this.constantsLoaded;
this.configService
.get<boolean>('general_system_conference_show')
.get<boolean>('general_system_conference_auto_connect')
.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;
if (this.enableJitsi && this.autoconnect) {
this.startJitsi();
@ -204,6 +255,51 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
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 {
@ -227,6 +323,7 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
public async enterConversation(): Promise<void> {
await this.operator.loaded;
this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {});
this.setConferenceState(ConferenceState.jitsi);
this.setOptions();
this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options);
@ -273,6 +370,9 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
this.isJoined = true;
this.addMember({ displayName: info.displayName, id: info.id });
this.setRoomPassword();
if (this.videoStreamUrl) {
this.showJitsiDialog();
}
}
private setRoomPassword(): void {
@ -355,21 +455,26 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
}
public toggleConferenceDialog(): void {
// there is no good way to detect the current classes in MatDialogRef or conferenceDialog.
// searching the global cdk-overlay-pane is the only thing which works
const pane = document.querySelector('.cdk-overlay-pane') as HTMLElement;
if (pane.classList.contains('jitsi-dialog-hide')) {
this.confDialogRef.removePanelClass('jitsi-dialog-hide');
if (this.isJitsiDialogOpen) {
this.hideJitsiDialog();
} else {
this.confDialogRef.addPanelClass('jitsi-dialog-hide');
this.showJitsiDialog();
}
}
private hideJitsiDialog(): void {
const pane = document.querySelector('.cdk-overlay-pane') as HTMLElement;
if (!pane.classList.contains('jitsi-dialog-hide')) {
this.confDialogRef.addPanelClass('jitsi-dialog-hide');
}
public hideJitsiDialog(): void {
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 {
@ -377,7 +482,28 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy {
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> {
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 { UserMenuComponent } from './components/user-menu/user-menu.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.
@ -294,7 +296,9 @@ import { JitsiComponent } from './components/jitsi/jitsi.component';
VotingPrivacyWarningComponent,
MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent,
JitsiComponent
JitsiComponent,
VjsPlayerComponent,
LiveStreamComponent
],
declarations: [
PermsDirective,
@ -355,7 +359,9 @@ import { JitsiComponent } from './components/jitsi/jitsi.component';
VotingPrivacyWarningComponent,
MotionPollDetailContentComponent,
AssignmentPollDetailContentComponent,
JitsiComponent
JitsiComponent,
VjsPlayerComponent,
LiveStreamComponent
],
providers: [
{

View File

@ -63,6 +63,10 @@ export class ViewListOfSpeakers extends BaseViewModelWithContentObject<ListOfSpe
public hasSpeakerSpoken(checkSpeaker: ViewSpeaker): boolean {
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 {
speakers: ViewSpeaker[];

View File

@ -18,6 +18,11 @@ import { ViewProjector } from '../models/view-projector';
providedIn: 'root'
})
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.
* 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 currentListOfSpeakerSubject = new BehaviorSubject<ViewListOfSpeakers>(null);
public currentListOfSpeakersObservable = this.currentListOfSpeakerSubject.asObservable();
public constructor(
private projectorService: ProjectorService,
private projectorRepo: ProjectorRepositoryService,
@ -46,6 +55,21 @@ export class CurrentListOfSpeakersService {
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>
</button>
</div>
<os-jitsi></os-jitsi>
<div (touchstart)="swipe($event, 'start')" (touchend)="swipe($event, 'end')" class="content">
<main>
<router-outlet #o="outlet"></router-outlet>
</main>
</div>
<div class="toolbars">
<!-- test -->
<os-jitsi></os-jitsi>
<!-- <os-live-stream></os-live-stream> -->
</div>
</mat-sidenav-content>
</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
);
/** Videjs */
@import '~video.js/dist/video-js.css';
/** Mix the component-related style-rules */
@mixin openslides-components-theme($theme) {
@include os-site-theme($theme);

View File

@ -97,30 +97,48 @@ def get_config_variables():
)
yield ConfigVariable(
name="general_system_conference_auto_connect",
name="general_system_conference_show",
default_value=False,
input_type="boolean",
label="Show audio conference window",
label="Show live conference window",
help_text="Server settings required to activate Jitsi Meet integration.",
weight=140,
subgroup="System",
)
yield ConfigVariable(
name="general_system_conference_show",
name="general_system_conference_auto_connect",
default_value=False,
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.",
weight=142,
subgroup="System",
)
yield ConfigVariable(
name="general_system_stream_url",
default_value="",
label="Live stream url",
weight=143,
subgroup="System",
)
yield ConfigVariable(
name="general_login_info_text",
default_value="",
label="Show this text on the login page",
weight=144,
weight=145,
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_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"),
("can_see_livestream", "Can see the live stream"),
)