From 0d9738b72d2bb4b065e536309a56b49e16db5cac Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 11 Jun 2020 11:20:00 +0200 Subject: [PATCH] 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 --- .travis.yml | 14 +- client/angular.json | 6 +- client/package.json | 4 +- .../core/core-services/operator.service.ts | 17 +- .../projector/projector-repository.service.ts | 15 + .../components/jitsi/jitsi.component.html | 298 +++++++++++------- .../components/jitsi/jitsi.component.scss | 26 +- .../components/jitsi/jitsi.component.ts | 156 ++++++++- .../live-stream/live-stream.component.html | 29 ++ .../live-stream/live-stream.component.scss | 40 +++ .../live-stream/live-stream.component.spec.ts | 27 ++ .../live-stream/live-stream.component.ts | 43 +++ .../vjs-player/vjs-player.component.html | 3 + .../vjs-player/vjs-player.component.scss | 25 ++ .../vjs-player/vjs-player.component.spec.ts | 24 ++ .../vjs-player/vjs-player.component.ts | 99 ++++++ client/src/app/shared/shared.module.ts | 10 +- .../agenda/models/view-list-of-speakers.ts | 4 + .../current-list-of-speakers.service.ts | 24 ++ client/src/app/site/site.component.html | 6 +- client/src/app/site/site.component.scss | 12 + client/src/styles.scss | 3 + openslides/core/config_variables.py | 28 +- .../migrations/0033_live_stream_permission.py | 25 ++ openslides/core/models.py | 1 + 25 files changed, 781 insertions(+), 158 deletions(-) create mode 100644 client/src/app/shared/components/live-stream/live-stream.component.html create mode 100644 client/src/app/shared/components/live-stream/live-stream.component.scss create mode 100644 client/src/app/shared/components/live-stream/live-stream.component.spec.ts create mode 100644 client/src/app/shared/components/live-stream/live-stream.component.ts create mode 100644 client/src/app/shared/components/vjs-player/vjs-player.component.html create mode 100644 client/src/app/shared/components/vjs-player/vjs-player.component.scss create mode 100644 client/src/app/shared/components/vjs-player/vjs-player.component.spec.ts create mode 100644 client/src/app/shared/components/vjs-player/vjs-player.component.ts create mode 100644 openslides/core/migrations/0033_live_stream_permission.py diff --git a/.travis.yml b/.travis.yml index ec90f89d9..df4b4a6a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/client/angular.json b/client/angular.json index 30c0361f1..e3e99f169 100644 --- a/client/angular.json +++ b/client/angular.json @@ -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": { diff --git a/client/package.json b/client/package.json index e1dd8589f..1b4962f69 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src/app/core/core-services/operator.service.ts b/client/src/app/core/core-services/operator.service.ts index 822a5f855..51d374268 100644 --- a/client/src/app/core/core-services/operator.service.ts +++ b/client/src/app/core/core-services/operator.service.ts @@ -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 { + return this.closService.currentListOfSpeakersObservable.pipe( + map(los => { + if (los) { + return los.isUserOnList(this.user.id); + } + }) + ); + } + /** * Returns a default WhoAmI response */ diff --git a/client/src/app/core/repositories/projector/projector-repository.service.ts b/client/src/app/core/repositories/projector/projector-repository.service.ts index d2ef0ae99..bbecea1b4 100644 --- a/client/src/app/core/repositories/projector/projector-repository.service.ts +++ b/client/src/app/core/repositories/projector/projector-repository.service.ts @@ -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 projector.isReferenceProjector).id; } + + public getReferenceProjectorIdObservable(): Observable { + return this.getViewModelListObservable().pipe( + map(projectors => { + const refProjector = projectors.find(projector => projector.isReferenceProjector); + if (refProjector) { + return refProjector.id; + } + }) + ); + } } diff --git a/client/src/app/shared/components/jitsi/jitsi.component.html b/client/src/app/shared/components/jitsi/jitsi.component.html index f70ea1b35..d26143696 100644 --- a/client/src/app/shared/components/jitsi/jitsi.component.html +++ b/client/src/app/shared/components/jitsi/jitsi.component.html @@ -1,30 +1,35 @@ -
- - -
-
- + + +
+
+ - -
-
+ +
+
+
@@ -34,48 +39,85 @@ 'cast-shadow': showJitsiWindow }" > - - + + + - - cloud_off + + - - + + cloud_off + + + + + + + no_meeting_room + - @@ -85,85 +127,109 @@ 'cdk-visually-hidden': !showJitsiWindow }" > - -
- -
- {{ - 'The audio conference is already running in your OpenSlides session.' | translate - }} - +
+ +
+ {{ 'disconnected' | translate }} +
+ +
+ {{ 'connecting...' | translate }} +
+ + +
+
+
    +
  1. +
    + {{ members[memberId].name }} +
    +
  2. +
+
+
+
+ + + + +
+ {{ 'The video stream is already running in your OpenSlides session.' | translate }} +
+
-
- {{ 'disconnected' | translate }} -
- - -
-
-
    -
  1. + +
    + +
    +
    + +
  2. -
-
-
-
+ call_end + - -
- -
-
- - + + +
- +
- - -
-
+
diff --git a/client/src/app/shared/components/jitsi/jitsi.component.scss b/client/src/app/shared/components/jitsi/jitsi.component.scss index 0b411edbc..58985deed 100644 --- a/client/src/app/shared/components/jitsi/jitsi.component.scss +++ b/client/src/app/shared/components/jitsi/jitsi.component.scss @@ -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; diff --git a/client/src/app/shared/components/jitsi/jitsi.component.ts b/client/src/app/shared/components/jitsi/jitsi.component.ts index 10883009e..148264e72 100644 --- a/client/src/app/shared/components/jitsi/jitsi.component.ts +++ b/client/src/app/shared/components/jitsi/jitsi.component.ts @@ -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 = new Deferred(); private constantsLoaded: Deferred = new Deferred(); + private configsLoaded: Deferred = 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 { + 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 { + await this.stopConference(); + } + + private async stopConference(): Promise { await this.stopJitsi(); + if (this.streamActiveInAnotherTab && this.streamRunning) { + await this.deleteStreamingLock(); + } } private async setUp(): Promise { @@ -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('Settings').subscribe(settings => { if (settings) { @@ -193,10 +244,10 @@ export class JitsiComponent extends BaseComponent implements OnInit, OnDestroy { await this.constantsLoaded; this.configService - .get('general_system_conference_show') + .get('general_system_conference_auto_connect') .subscribe(autoconnect => (this.autoconnect = autoconnect)); - this.configService.get('general_system_conference_auto_connect').subscribe(show => { + this.configService.get('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('general_system_conference_los_restriction').subscribe(restricted => { + this.restricted = restricted; + }); + + this.configService.get('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 { 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 { + 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 { await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise(); } + + public async deleteStreamingLock(): Promise { + 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(() => {}); + } + } } diff --git a/client/src/app/shared/components/live-stream/live-stream.component.html b/client/src/app/shared/components/live-stream/live-stream.component.html new file mode 100644 index 000000000..4ebb30834 --- /dev/null +++ b/client/src/app/shared/components/live-stream/live-stream.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+ +
+ + {{ 'The stream is disableb because you are inside a conference' | translate }} + + +
+
+ +
+ +
+
diff --git a/client/src/app/shared/components/live-stream/live-stream.component.scss b/client/src/app/shared/components/live-stream/live-stream.component.scss new file mode 100644 index 000000000..e1b007018 --- /dev/null +++ b/client/src/app/shared/components/live-stream/live-stream.component.scss @@ -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; + } + } +} diff --git a/client/src/app/shared/components/live-stream/live-stream.component.spec.ts b/client/src/app/shared/components/live-stream/live-stream.component.spec.ts new file mode 100644 index 000000000..0412aed72 --- /dev/null +++ b/client/src/app/shared/components/live-stream/live-stream.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/client/src/app/shared/components/live-stream/live-stream.component.ts b/client/src/app/shared/components/live-stream/live-stream.component.ts new file mode 100644 index 000000000..03892092c --- /dev/null +++ b/client/src/app/shared/components/live-stream/live-stream.component.ts @@ -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 { + await this.deleteJitsiLock(); + } + + /** + * todo: DUP + */ + private async deleteJitsiLock(): Promise { + await this.storageMap.delete(this.RTC_LOGGED_STORAGE_KEY).toPromise(); + } +} diff --git a/client/src/app/shared/components/vjs-player/vjs-player.component.html b/client/src/app/shared/components/vjs-player/vjs-player.component.html new file mode 100644 index 000000000..bd0a96978 --- /dev/null +++ b/client/src/app/shared/components/vjs-player/vjs-player.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/client/src/app/shared/components/vjs-player/vjs-player.component.scss b/client/src/app/shared/components/vjs-player/vjs-player.component.scss new file mode 100644 index 000000000..a5d6f99ab --- /dev/null +++ b/client/src/app/shared/components/vjs-player/vjs-player.component.scss @@ -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; + } + } + } +} diff --git a/client/src/app/shared/components/vjs-player/vjs-player.component.spec.ts b/client/src/app/shared/components/vjs-player/vjs-player.component.spec.ts new file mode 100644 index 000000000..36fc25132 --- /dev/null +++ b/client/src/app/shared/components/vjs-player/vjs-player.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [VjsPlayerComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(VjsPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/vjs-player/vjs-player.component.ts b/client/src/app/shared/components/vjs-player/vjs-player.component.ts new file mode 100644 index 000000000..bc3dd9636 --- /dev/null +++ b/client/src/app/shared/components/vjs-player/vjs-player.component.ts @@ -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 = 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 { + 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`); + } + } + } + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 1aef46782..a96e2296b 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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: [ { diff --git a/client/src/app/site/agenda/models/view-list-of-speakers.ts b/client/src/app/site/agenda/models/view-list-of-speakers.ts index 51e0431de..610f5c751 100644 --- a/client/src/app/site/agenda/models/view-list-of-speakers.ts +++ b/client/src/app/site/agenda/models/view-list-of-speakers.ts @@ -63,6 +63,10 @@ export class ViewListOfSpeakers extends BaseViewModelWithContentObject 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[]; diff --git a/client/src/app/site/projector/services/current-list-of-speakers.service.ts b/client/src/app/site/projector/services/current-list-of-speakers.service.ts index 396f0486a..7b7cf11b2 100644 --- a/client/src/app/site/projector/services/current-list-of-speakers.service.ts +++ b/client/src/app/site/projector/services/current-list-of-speakers.service.ts @@ -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 } = {}; + private currentListOfSpeakerSubject = new BehaviorSubject(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); } /** diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 6b762b894..0fbd729b8 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -89,12 +89,16 @@ arrow_forward_ios -
+
+ + + +
diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss index 12169d589..884823065 100644 --- a/client/src/app/site/site.component.scss +++ b/client/src/app/site/site.component.scss @@ -134,3 +134,15 @@ mat-sidenav-container { } } } + +.toolbars { + z-index: 99; + position: fixed; + right: 0; + bottom: 0; + display: flex; + + * { + margin-top: auto; + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index 37d6a1824..c1229a129 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -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); diff --git a/openslides/core/config_variables.py b/openslides/core/config_variables.py index 278aa22ec..5fc7c8451 100644 --- a/openslides/core/config_variables.py +++ b/openslides/core/config_variables.py @@ -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", ) diff --git a/openslides/core/migrations/0033_live_stream_permission.py b/openslides/core/migrations/0033_live_stream_permission.py new file mode 100644 index 000000000..813833414 --- /dev/null +++ b/openslides/core/migrations/0033_live_stream_permission.py @@ -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"), + ), + }, + ), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index 47fd60372..d0b490009 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -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"), )