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:
parent
fbb0be6fb4
commit
0d9738b72d
14
.travis.yml
14
.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
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
<div class="jitsi-integration">
|
||||
<!-- iFrame Dialog -->
|
||||
<ng-template #conferenceDialog>
|
||||
<!-- iFrame Dialog -->
|
||||
<ng-template #conferenceDialog>
|
||||
<div class="jitsi-iframe-wrapper" #jitsi></div>
|
||||
<div mat-dialog-actions>
|
||||
<button
|
||||
@ -13,18 +12,24 @@
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
|
||||
<button class="minimize-jitsi-dialog-button" type="button" mat-button color="primary" (click)="toggleConferenceDialog()">
|
||||
<button
|
||||
class="minimize-jitsi-dialog-button"
|
||||
type="button"
|
||||
mat-button
|
||||
color="primary"
|
||||
(click)="hideJitsiDialog()"
|
||||
>
|
||||
<span>{{ 'Minimize' | translate }}</span>
|
||||
<mat-icon>fullscreen_exit</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</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,6 +39,21 @@
|
||||
'cast-shadow': showJitsiWindow
|
||||
}"
|
||||
>
|
||||
<ng-container *ngIf="currentState == state.jitsi">
|
||||
<!-- Exit jitsi -->
|
||||
<button
|
||||
mat-mini-fab
|
||||
class="indicator quick-icon"
|
||||
color="accent"
|
||||
(click)="viewStream()"
|
||||
matTooltip="{{ 'Exit live conference and view the live stream' | translate }}"
|
||||
*ngIf="videoStreamUrl"
|
||||
>
|
||||
<mat-icon color="warn">
|
||||
meeting_room
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- mute/unmute button -->
|
||||
<button
|
||||
class="indicator quick-icon"
|
||||
@ -46,27 +66,37 @@
|
||||
</button>
|
||||
|
||||
<!-- disconnected icon -->
|
||||
<mat-icon class="indicator" *ngIf="!isJoined">cloud_off</mat-icon>
|
||||
<mat-icon class="indicator" *ngIf="!isJoined && !videoStreamUrl">cloud_off</mat-icon>
|
||||
</ng-container>
|
||||
|
||||
<!-- hide unhide video -->
|
||||
<!-- <button class="quick-icon" mat-mini-fab [disabled]="!isJoined">
|
||||
<mat-icon *ngIf="isJoined" color="primary">videocam_off</mat-icon>
|
||||
</button> -->
|
||||
<ng-container *ngIf="currentState == state.stream">
|
||||
<!-- Enter conference from stream -->
|
||||
<button
|
||||
*ngIf="enableJitsi && isAccessPermitted"
|
||||
class="indicator quick-icon"
|
||||
mat-mini-fab
|
||||
(click)="enterConversation()"
|
||||
matTooltip="{{ 'Enter live conference' | translate }}"
|
||||
>
|
||||
<mat-icon color="primary">meeting_room</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-icon *ngIf="enableJitsi && !isAccessPermitted" class="indicator">no_meeting_room</mat-icon>
|
||||
</ng-container>
|
||||
</span>
|
||||
|
||||
<span
|
||||
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>
|
||||
<mat-icon class="opened-indicator" *ngIf="!showJitsiWindow">keyboard_arrow_up</mat-icon>
|
||||
<mat-icon class="opened-indicator" *ngIf="showJitsiWindow">keyboard_arrow_down </mat-icon>
|
||||
|
||||
<ng-container *ngIf="currentState == state.jitsi">
|
||||
<span> {{ 'Live conference' | translate }}</span>
|
||||
<div class="one-line">
|
||||
|
||||
<span *ngIf="currentDominantSpeaker">
|
||||
@ -75,7 +105,19 @@
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<!-- unfolded list -->
|
||||
@ -85,6 +127,7 @@
|
||||
'cdk-visually-hidden': !showJitsiWindow
|
||||
}"
|
||||
>
|
||||
<ng-container *ngIf="currentState == state.jitsi">
|
||||
<!-- Jitsi content window -->
|
||||
<div class="content">
|
||||
<!-- The "somewhere else active" warning -->
|
||||
@ -101,8 +144,12 @@
|
||||
<span>{{ 'disconnected' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="disconnected" *ngIf="isJitsiActive && !isJoined">
|
||||
<span>{{ 'connecting...' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<!-- user list -->
|
||||
<div class="room-members" *ngIf="isJitsiActive">
|
||||
<div class="room-members" *ngIf="isJitsiActive && isJoined">
|
||||
<div class="member-list">
|
||||
<ol>
|
||||
<li
|
||||
@ -119,7 +166,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="currentState == state.stream">
|
||||
<os-vjs-player
|
||||
[videoUrl]="videoStreamUrl"
|
||||
(started)="onSteamStarted()"
|
||||
*ngIf="!streamActiveInAnotherTab || streamRunning"
|
||||
></os-vjs-player>
|
||||
<div class="disconnected" *ngIf="streamActiveInAnotherTab && !streamRunning">
|
||||
<span>{{ 'The video stream is already running in your OpenSlides session.' | translate }}</span>
|
||||
<button mat-button color="warn" (click)="deleteStreamingLock()">
|
||||
<span>{{ 'Force the stream to reload' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="currentState == state.jitsi">
|
||||
<!-- Custom control buttons -->
|
||||
<div>
|
||||
<mat-divider></mat-divider>
|
||||
@ -141,7 +204,9 @@
|
||||
mat-mini-fab
|
||||
color="accent"
|
||||
(click)="enterConversation()"
|
||||
[disabled]="isJitsiActive || isJitsiActiveInAnotherTab"
|
||||
[disabled]="
|
||||
!enableJitsi || isJitsiActive || isJitsiActiveInAnotherTab || !isAccessPermitted
|
||||
"
|
||||
*ngIf="!isJoined"
|
||||
matTooltip="{{ 'Enter conference' | translate }}"
|
||||
>
|
||||
@ -156,14 +221,15 @@
|
||||
color="accent"
|
||||
(click)="toggleConferenceDialog()"
|
||||
[disabled]="!isJitsiActive"
|
||||
matTooltip="{{ 'Maximize Jitsi window' | translate }}"
|
||||
matTooltip="{{ 'Maximize / minimize Jitsi window' | translate }}"
|
||||
>
|
||||
<mat-icon>
|
||||
fullscreen
|
||||
{{ isJitsiDialogOpen ? 'fullscreen_exit' : 'fullscreen' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -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;
|
||||
|
@ -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')) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<div class="video-wrapper">
|
||||
<video #videoPlayer class="video-js" controls preload="none"></video>
|
||||
</div>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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: [
|
||||
{
|
||||
|
@ -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[];
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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>
|
||||
|
||||
|
@ -134,3 +134,15 @@ mat-sidenav-container {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbars {
|
||||
z-index: 99;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
|
||||
* {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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",
|
||||
)
|
||||
|
||||
|
25
openslides/core/migrations/0033_live_stream_permission.py
Normal file
25
openslides/core/migrations/0033_live_stream_permission.py
Normal 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"),
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user