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"), )