diff --git a/client/src/app/core/ui-services/diff.service.ts b/client/src/app/core/ui-services/diff.service.ts index b1b46c13a..529fb0529 100644 --- a/client/src/app/core/ui-services/diff.service.ts +++ b/client/src/app/core/ui-services/diff.service.ts @@ -256,7 +256,7 @@ export class DiffService { * @returns {Element} */ public getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element { - return fragment.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); + return fragment?.querySelector('os-linebreak.os-line-number.line-number-' + lineNumber); } /** diff --git a/client/src/app/shared/animations.ts b/client/src/app/shared/animations.ts index 72f2747a1..301b25d09 100644 --- a/client/src/app/shared/animations.ts +++ b/client/src/app/shared/animations.ts @@ -16,6 +16,22 @@ const slideOut = [ ) ]; +export const fadeInOut = trigger('fadeInOut', [ + state( + 'true', + style({ + opacity: 1 + }) + ), + state( + 'false', + style({ + opacity: 0.2 + }) + ), + transition('true <=> false', animate('1s')) +]); + export const collapseAndFade = trigger('collapse', [ state('in', style({ opacity: 1, height: '100%' })), transition(':enter', [style({ opacity: 0, height: 0 }), animate(fadeSpeed.fast)]), diff --git a/client/src/app/shared/components/jitsi/jitsi.component.html b/client/src/app/shared/components/jitsi/jitsi.component.html deleted file mode 100644 index c99db9840..000000000 --- a/client/src/app/shared/components/jitsi/jitsi.component.html +++ /dev/null @@ -1,295 +0,0 @@ - -
- -
-
-
- - open_in_new - -
- -
- -
- -
- -
-
-
-
- -
- -
- - - - - - - - - - cloud_off - - - - - - - - no_meeting_room - - - - - - - - - - - - - - - - - -
- - -
- -
- {{ - 'The live conference is already running in your OpenSlides session.' | translate - }} - -
- -
- {{ 'disconnected' | translate }} -
- -
- {{ 'connecting ...' | translate }} -
- - -
- -
-
    -
  1. -
    - {{ members[memberId].name }} -
    -
  2. -
-
-
-
-
- - - - - -
- -
-
- - - -
- -
-
- - - - - -
- - - -
-
-
-
-
-
-
diff --git a/client/src/app/shared/components/jitsi/jitsi.component.scss b/client/src/app/shared/components/jitsi/jitsi.component.scss deleted file mode 100644 index 4c061e65c..000000000 --- a/client/src/app/shared/components/jitsi/jitsi.component.scss +++ /dev/null @@ -1,200 +0,0 @@ -.jitsi-fake-dialog-wrapper { - z-index: 98; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - padding: 20px 10% 20px 5%; - - .jitsi-fake-dialog { - display: flex; - flex-direction: column; - width: 100%; - height: 90%; - - .jitsi-iframe-wrapper { - flex: 1; - } - - .jitsi-dialog-actions { - display: flex; - justify-content: space-between; - - div { - min-width: 33%; - display: flex; - } - - .dialog-hangup { - margin-left: auto; - margin-right: auto; - } - - .dialog-hide { - margin-left: auto; - } - } - } -} - -.jitsi-dialog-hide { - display: none; -} - -.jitsi-integration { - pointer-events: none; - z-index: 99; - position: fixed; - left: 0; - right: 20px; - bottom: 0; - - .cast-shadow { - box-shadow: -3px -3px 10px 0px rgba(0, 0, 0, 0.2) !important; - } - - .jitsi-bar { - display: flex; - position: relative; - justify-content: flex-end; - $wrapper-padding: 5px; - $bar-height: 40px; - - .control-icon-wrapper { - pointer-events: all; - z-index: 1; - min-height: $bar-height; - display: flex; - margin-top: auto; - padding-right: 0.5em; - padding: $wrapper-padding 0 $wrapper-padding $wrapper-padding; - border-top-left-radius: 4px; - - .indicator { - width: 40px; - text-align: center; - margin: auto $wrapper-padding auto 0; - } - } - - .stream-width-wrapper { - width: 100%; - min-width: 100px; - max-width: 500px; - } - - .audio-list-wrapper { - width: 100%; - min-width: 100px; - max-width: 300px; - } - - .list-wrapper { - position: relative; - pointer-events: all; - min-height: $bar-height; - padding-top: $wrapper-padding; - border-top-right-radius: 4px; - - .applause { - position: absolute; - top: 0; - width: 90px; - left: -90px; - bottom: 50px; - } - - .toggle-list-button { - position: relative; - line-height: normal; - width: 100%; - height: 40px; - padding: 0 2.5em; - margin-bottom: $wrapper-padding; - font-weight: normal; - text-align: right; - - .opened-indicator { - position: absolute; - right: $wrapper-padding; - top: 8px; - } - - .dominant-speaker { - font-weight: 500; - width: fit-content; - margin: 0 auto; - } - } - - .jitsi-list { - .content { - height: 40vh; - max-height: 100%; - clear: both; - - .disconnected { - display: flex; - flex-direction: column; - height: inherit; - padding-left: 1em; - padding-right: 1em; - - span { - margin: auto; - } - } - - .room-members { - height: 100%; - position: relative; - - .room-list-applause-particles { - position: absolute; - height: 100%; - width: 70px; - right: 0; - } - - .member-list { - max-height: 100%; - overflow-y: auto; - - .member-list-entry { - margin: 5px; - overflow-x: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - .focused { - font-weight: bold; - } - } - } - - .control-grid { - padding: $wrapper-padding 0; - display: grid; - 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; - } - - .open-jitsi-in-tab { - grid-area: new-tab; - } - } - } - } - } -} diff --git a/client/src/app/shared/components/jitsi/jitsi.component.scss-theme.scss b/client/src/app/shared/components/jitsi/jitsi.component.scss-theme.scss deleted file mode 100644 index 2c40b5c18..000000000 --- a/client/src/app/shared/components/jitsi/jitsi.component.scss-theme.scss +++ /dev/null @@ -1,37 +0,0 @@ -@import '~@angular/material/theming'; - -@mixin os-jitsi-theme($theme) { - $primary: map-get($theme, primary); - $accent: map-get($theme, accent); - $warn: map-get($theme, warn); - $foreground: map-get($theme, foreground); - $background: map-get($theme, background); - - .jitsi-bar { - .apply-theme { - background-color: mat-color($primary); - - .quick-icon:not([disabled]) { - background-color: mat-color($primary, default-contrast); - } - - .indicator { - color: mat-color($primary, default-contrast); - - svg path { - fill: mat-color($primary) !important; - } - } - - .toggle-list-button { - span { - color: mat-color($primary, default-contrast); - } - } - } - } - - .jitsi-list { - background-color: mat-color($background, card); - } -} diff --git a/client/src/app/shared/components/jitsi/jitsi.component.ts b/client/src/app/shared/components/jitsi/jitsi.component.ts deleted file mode 100644 index b184a79a8..000000000 --- a/client/src/app/shared/components/jitsi/jitsi.component.ts +++ /dev/null @@ -1,742 +0,0 @@ -import { animate, state, style, transition, trigger } from '@angular/animations'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - HostListener, - OnDestroy, - OnInit, - ViewChild, - ViewEncapsulation -} from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Title } from '@angular/platform-browser'; - -import { StorageMap } from '@ngx-pwa/local-storage'; -import { TranslateService } from '@ngx-translate/core'; -import { delay, distinctUntilChanged, map } from 'rxjs/operators'; - -import { ConstantsService } from 'app/core/core-services/constants.service'; -import { OperatorService } from 'app/core/core-services/operator.service'; -import { Deferred } from 'app/core/promises/deferred'; -import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; -import { ApplauseService, ApplauseType } from 'app/core/ui-services/applause.service'; -import { ConfigService } from 'app/core/ui-services/config.service'; -import { UserMediaPermService } from 'app/core/ui-services/user-media-perm.service'; -import { UserListIndexType } from 'app/site/agenda/models/view-list-of-speakers'; -import { BaseViewComponentDirective } from 'app/site/base/base-view'; -import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service'; - -declare var JitsiMeetExternalAPI: any; - -interface JitsiMember { - id: string; - displayName: string; -} - -interface ConferenceJoinedResult { - roomName: string; - id: string; - displayName: string; - formattedDisplayName: string; -} - -interface DisplayNameChangeResult { - // Yes, in this case "displayname" really does not have a capital n. Thank you jitsi. - displayname: string; - formattedDisplayName: string; - id: string; -} - -interface JitsiSettings { - JITSI_DOMAIN: string; - JITSI_ROOM_NAME: string; - JITSI_ROOM_PASSWORD: string; -} - -interface ConferenceMember { - name: string; - focus: boolean; -} - -enum ConferenceState { - stream, - jitsi -} - -@Component({ - selector: 'os-jitsi', - templateUrl: './jitsi.component.html', - styleUrls: ['./jitsi.component.scss'], - animations: [ - trigger('fadeInOut', [ - state( - 'true', - style({ - opacity: 1 - }) - ), - state( - 'false', - style({ - opacity: 0.2 - }) - ), - transition('true <=> false', animate('1s')) - ]) - ], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class JitsiComponent extends BaseViewComponentDirective implements OnInit, OnDestroy { - public enableJitsi: boolean; - - private autoconnect: boolean; - private defaultRoomName: string; - private actualRoomName: string; - private roomPassword: string; - private jitsiDomain: string; - private isSupportEnabled: boolean; - - public connectToHelpDesk = false; - - public restricted = false; - public videoStreamUrl: string; - private nextSpeakerAmount: number; - - // do not set the password twice - private isPasswortSet = false; - - public isJitsiDialogOpen = false; - public showJitsiWindow = true; - public muted = true; - - public showApplause: boolean; - public applauseDisabled = false; - private applauseTimeout: number; - - @ViewChild('jitsi') - private jitsiNode: ElementRef; - - // JitsiMeet api object - private api: any | null; - - public get isJitsiActive(): boolean { - return !!this.api; - } - - public isJoined: boolean; - private streamRunning = false; - - private options: object; - - private lockLoaded: Deferred = new Deferred(); - private constantsLoaded: Deferred = new Deferred(); - private configsLoaded: Deferred = new Deferred(); - - // storage locks - public isJitsiActiveInAnotherTab = false; - - /** - * undefined is controlled behaviour, meaning, this property was not - * checked yet. - * Thus, false-checks have to be explicit - */ - private streamLoadedOnce: boolean; - - private RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn'; - private STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning'; - - // JitsiID to ConferenceMember - public members = {}; - public currentDominantSpeaker: JitsiMember; - - public get memberList(): string[] { - return Object.keys(this.members); - } - - public get isRoomPasswordProtected(): boolean { - return this.roomPassword?.length > 0; - } - - public get canAccessSupport(): boolean { - return this.isSupportEnabled && this.enableJitsi && !!this.defaultRoomName; - } - - private isOnCurrentLos: boolean; - - public canSeeLiveStream: boolean; - - public canManageSpeaker: boolean; - - /** - * Jitsi|URL|Perm||Show - * =====|===|====||==== - * 0 | 0 | 0 || 0 - * 0 | 0 | 1 || 0 - * 0 | 1 | 0 || 0 - * 0 | 1 | 1 || 1 - * 1 | 0 | 0 || 1 - * 1 | 0 | 1 || 1 - * 1 | 1 | 0 || 0 - * 1 | 1 | 1 || 1 - */ - public get showConferenceBar(): boolean { - if (this.enableJitsi) { - if (this.videoStreamUrl && !this.canSeeLiveStream) { - return false; - } else { - return true; - } - } else { - return this.videoStreamUrl && this.canSeeLiveStream; - } - } - - public get isAccessPermitted(): boolean { - return !this.restricted || this.canManageSpeaker || this.isOnCurrentLos; - } - - public get jitsiMeetUrl(): string { - return `https://${this.jitsiDomain}/${this.actualRoomName}`; - } - - /** - * The conference state, to determine if the user consumes the stream or can - * contribute to jitsi - */ - public state = ConferenceState; - public currentState: ConferenceState; - public isEnterMeetingRoomVisible = true; - - public applauseLevel = 0; - private showApplauseLevel: boolean; - private isApplausBarUsed: boolean; - - public get showApplauseBadge(): boolean { - return this.showApplauseLevel && this.applauseLevel > 0 && (!this.showJitsiWindow || !this.isApplausBarUsed); - } - - private get applauseType(): ApplauseType { - return this.applauseService.applauseType; - } - - public get isApplauseTypeBar(): boolean { - return this.applauseType === ApplauseType.bar; - } - - public get isApplauseTypeParticles(): boolean { - return this.applauseType === ApplauseType.particles; - } - - private configOverwrite = { - startAudioOnly: false, - // allows jitsi on mobile devices - disableDeepLinking: true, - startWithAudioMuted: false, - startWithVideoMuted: false, - useNicks: true, - enableWelcomePage: false, - enableUserRolesBasedOnToken: false, - enableFeaturesBasedOnToken: false, - disableThirdPartyRequests: true, - enableNoAudioDetection: false, - enableNoisyMicDetection: false - }; - - private interfaceConfigOverwrite = { - DISABLE_VIDEO_BACKGROUND: true, - INVITATION_POWERED_BY: false, - DISABLE_JOIN_LEAVE_NOTIFICATIONS: true, - DISABLE_PRESENCE_STATUS: true, - TOOLBAR_ALWAYS_VISIBLE: true, - TOOLBAR_TIMEOUT: 10000000, - TOOLBAR_BUTTONS: [ - 'microphone', - 'camera', - 'desktop', - 'fullscreen', - 'fodeviceselection', - 'profile', - 'chat', - 'recording', - 'livestreaming', - 'etherpad', - 'sharedvideo', - 'settings', - 'videoquality', - 'filmstrip', - 'feedback', - 'stats', - 'shortcuts', - 'tileview', - 'download', - 'help', - 'mute-everyone' - ] - }; - - public constructor( - titleService: Title, - translate: TranslateService, - snackBar: MatSnackBar, - private operator: OperatorService, - private storageMap: StorageMap, - private userRepo: UserRepositoryService, - private constantsService: ConstantsService, - private configService: ConfigService, - private closService: CurrentListOfSpeakersService, - private userMediaPermService: UserMediaPermService, - private applauseService: ApplauseService, - private cd: ChangeDetectorRef - ) { - super(titleService, translate, snackBar); - } - - public async ngOnInit(): Promise { - await this.setUp(); - if (this.canSeeLiveStream && this.videoStreamUrl) { - this.currentState = ConferenceState.stream; - } else { - this.currentState = ConferenceState.jitsi; - } - } - - public async ngOnDestroy(): Promise { - super.ngOnDestroy(); - 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(); - } - - public triggerMeetingRoomButtonAnimation(): void { - if (this.canManageSpeaker) { - this.isEnterMeetingRoomVisible = true; - } else { - this.isEnterMeetingRoomVisible = !this.isEnterMeetingRoomVisible; - } - } - - private async stopConference(): Promise { - await this.stopJitsi(); - if (this.streamLoadedOnce && this.streamRunning) { - await this.deleteStreamingLock(); - } - } - - private async setUp(): Promise { - this.subscriptions.push( - // if the operators users has changes, check if we have to start the animation - this.operator - .getUserObservable() - .pipe(delay(0)) - .subscribe(() => { - this.canManageSpeaker = this.operator.hasPerms(this.permission.agendaCanManageListOfSpeakers); - this.canSeeLiveStream = this.operator.hasPerms(this.permission.coreCanSeeLiveStream); - this.isEnterMeetingRoomVisible = this.canManageSpeaker; - this.cd.markForCheck(); - }), - - this.storageMap - .watch(this.RTC_LOGGED_STORAGE_KEY) - .pipe(distinctUntilChanged()) - .subscribe((inUse: boolean) => { - this.isJitsiActiveInAnotherTab = inUse; - this.lockLoaded.resolve(); - if (!inUse && !this.isJitsiActive) { - this.startJitsi(); - this.cd.markForCheck(); - } - }), - this.storageMap - .watch(this.STREAM_RUNNING_STORAGE_KEY) - .pipe(distinctUntilChanged()) - .subscribe((running: boolean) => { - this.streamLoadedOnce = !!running; - this.cd.markForCheck(); - }) - ); - - await this.lockLoaded; - - this.constantsService.get('Settings').subscribe(settings => { - if (settings) { - this.jitsiDomain = settings.JITSI_DOMAIN; - this.defaultRoomName = settings.JITSI_ROOM_NAME; - this.roomPassword = settings.JITSI_ROOM_PASSWORD; - this.constantsLoaded.resolve(); - this.cd.markForCheck(); - } - }); - - await this.constantsLoaded; - - this.subscriptions.push( - this.configService.get('general_system_conference_auto_connect').subscribe(autoconnect => { - this.autoconnect = autoconnect; - this.cd.markForCheck(); - }), - this.configService.get('general_system_conference_show').subscribe(show => { - this.enableJitsi = show && !!this.jitsiDomain && !!this.defaultRoomName; - if (this.enableJitsi && this.autoconnect) { - this.startJitsi(); - } else { - this.stopJitsi(); - } - this.cd.markForCheck(); - }), - this.configService.get('general_system_conference_los_restriction').subscribe(restricted => { - this.restricted = restricted; - this.cd.markForCheck(); - }), - this.configService - .get('general_system_conference_auto_connect_next_speakers') - .subscribe(nextSpeakerAmount => { - this.nextSpeakerAmount = nextSpeakerAmount; - this.cd.markForCheck(); - }), - this.configService.get('general_system_stream_url').subscribe(url => { - this.onLiveStreamAvailable(url); - this.configsLoaded.resolve(); - this.cd.markForCheck(); - }), - this.configService.get('general_system_conference_open_microphone').subscribe(open => { - this.configOverwrite.startWithAudioMuted = !open; - this.cd.markForCheck(); - }), - this.configService.get('general_system_conference_open_video').subscribe(open => { - this.configOverwrite.startWithVideoMuted = !open; - this.cd.markForCheck(); - }), - this.configService.get('general_system_applause_enable').subscribe(enable => { - this.showApplause = enable; - this.cd.markForCheck(); - }), - this.configService.get('general_system_stream_applause_timeout').subscribe(timeout => { - this.applauseTimeout = (timeout || 1) * 1000; - this.cd.markForCheck(); - }), - this.configService.get('general_system_applause_show_level').subscribe(show => { - this.showApplauseLevel = show; - this.cd.markForCheck(); - }), - this.configService.get('general_system_applause_type').subscribe(type => { - if (type === 'applause-type-bar') { - this.isApplausBarUsed = true; - } else { - this.isApplausBarUsed = false; - } - this.cd.markForCheck(); - }), - this.configService.get('general_system_conference_enable_helpdesk').subscribe(enabled => { - this.isSupportEnabled = enabled; - this.cd.markForCheck(); - }) - ); - - await this.configsLoaded; - - this.subscriptions.push( - // check if the operator is on the clos, remove from room if not permitted - this.closService.currentListOfSpeakersObservable - .pipe( - map(los => los?.findUserIndexOnList(this.operator.user.id) ?? -1), - distinctUntilChanged() - ) - .subscribe(userLosIndex => { - this.autoJoinJitsiByLosIndex(userLosIndex); - this.cd.markForCheck(); - }), - this.applauseService.applauseLevelObservable.subscribe(applauseLevel => { - this.applauseLevel = applauseLevel || 0; - this.cd.markForCheck(); - }) - ); - } - - public toggleMute(): void { - if (this.isJitsiActive) { - this.api.executeCommand('toggleAudio'); - this.cd.markForCheck(); - } - } - - public async forceStart(): Promise { - await this.deleteJitsiLock(); - await this.stopJitsi(); - await this.startJitsi(); - } - - private startJitsi(): void { - if (!this.isJitsiActiveInAnotherTab && this.enableJitsi && !this.isJitsiActive && this.jitsiNode) { - this.enterConferenceRoom(); - this.cd.markForCheck(); - } - } - - private async enterConversation(): Promise { - await this.operator.loaded; - try { - await this.userMediaPermService.requestMediaAccess(); - this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {}); - this.setConferenceState(ConferenceState.jitsi); - this.setOptions(); - if (this.api) { - this.api.dispose(); - this.api = undefined; - } - this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options); - const jitsiname = this.userRepo.getShortName(this.operator.user); - this.api.executeCommand('displayName', jitsiname); - this.loadApiCallbacks(); - } catch (e) { - this.raiseError(e); - } - } - - private loadApiCallbacks(): void { - this.api.on('videoConferenceJoined', (info: ConferenceJoinedResult) => { - this.onEnterConference(info); - }); - - this.api.on('participantJoined', (newMember: JitsiMember) => { - this.addMember(newMember); - }); - - this.api.on('participantLeft', (oldMember: { id: string }) => { - this.removeMember(oldMember); - }); - - this.api.on('displayNameChange', (member: DisplayNameChangeResult) => { - this.renameMember(member); - }); - - this.api.on('audioMuteStatusChanged', (isMuted: { muted: boolean }) => { - this.muted = isMuted.muted; - }); - - this.api.on('readyToClose', () => { - this.stopJitsi(); - }); - - this.api.on('dominantSpeakerChanged', (newSpeaker: { id: string }) => { - this.newDominantSpeaker(newSpeaker.id); - }); - - this.api.on('passwordRequired', () => { - this.setRoomPassword(); - }); - } - - private onEnterConference(info: ConferenceJoinedResult): void { - this.isJoined = true; - this.addMember({ displayName: info.displayName, id: info.id }); - this.setRoomPassword(); - if (this.videoStreamUrl) { - this.showJitsiDialog(); - } - this.cd.markForCheck(); - } - - private autoJoinJitsiByLosIndex(operatorClosIndex: number): void { - if (operatorClosIndex !== UserListIndexType.NotOnList) { - if (!this.isOnCurrentLos) { - this.isOnCurrentLos = true; - this.triggerMeetingRoomButtonAnimation(); - } - - if ( - this.nextSpeakerAmount && - this.nextSpeakerAmount > 0 && - operatorClosIndex > UserListIndexType.Active && - operatorClosIndex <= this.nextSpeakerAmount && - !this.isJitsiActive - ) { - this.enterConferenceRoom(); - } - } else { - this.isOnCurrentLos = false; - } - - if (!this.isAccessPermitted) { - this.viewStream(); - } - } - - private setRoomPassword(): void { - if (this.roomPassword && !this.isPasswortSet) { - // You can only set the password after the server has recognized that you are - // the moderator. There is no event listener for that. - setTimeout(() => { - this.api.executeCommand('password', this.roomPassword); - this.isPasswortSet = true; - }, 1000); - } - } - - private newDominantSpeaker(newSpeakerId: string): void { - if (this.currentDominantSpeaker && this.members[this.currentDominantSpeaker.id]) { - this.members[this.currentDominantSpeaker.id].focus = false; - } - this.members[newSpeakerId].focus = true; - this.currentDominantSpeaker = { - id: newSpeakerId, - displayName: this.members[newSpeakerId].name - }; - this.cd.markForCheck(); - } - - private addMember(newMember: JitsiMember): void { - this.members[newMember.id] = { - name: newMember.displayName, - focus: false - } as ConferenceMember; - } - - private removeMember(oldMember: { id: string }): void { - if (this.members[oldMember.id]) { - delete this.members[oldMember.id]; - } - } - - private renameMember(member: DisplayNameChangeResult): void { - if (this.members[member.id]) { - this.members[member.id].name = member.displayname; - } - if (this.currentDominantSpeaker?.id === member.id) { - this.newDominantSpeaker(member.id); - } - } - - private clearMembers(): void { - this.members = {}; - } - - public async stopJitsi(): Promise { - this.connectToHelpDesk = false; - if (this.isJitsiActive) { - this.api.executeCommand('hangup'); - this.clearMembers(); - await this.deleteJitsiLock(); - this.api.dispose(); - this.api = undefined; - this.hideJitsiDialog(); - } - this.isJoined = false; - this.isPasswortSet = false; - this.currentDominantSpeaker = null; - } - - private setOptions(): void { - this.options = { - roomName: this.actualRoomName, - parentNode: this.jitsiNode.nativeElement, - configOverwrite: this.configOverwrite, - interfaceConfigOverwrite: this.interfaceConfigOverwrite - }; - } - - public toggleShowJitsi(): void { - this.showJitsiWindow = !this.showJitsiWindow; - } - - public toggleConferenceDialog(): void { - if (this.isJitsiDialogOpen) { - this.hideJitsiDialog(); - } else { - this.showJitsiDialog(); - } - } - - public hideJitsiDialog(): void { - this.isJitsiDialogOpen = false; - this.cd.markForCheck(); - } - - public showJitsiDialog(): void { - this.isJitsiDialogOpen = true; - this.showJitsiWindow = false; - this.cd.markForCheck(); - } - - public viewStream(): void { - this.stopJitsi(); - this.setConferenceState(ConferenceState.stream); - this.showJitsiWindow = true; - this.cd.markForCheck(); - } - - public onSteamLoaded(): void { - /** - * explicit false check, undefined would mean that this was not checked yet - */ - if (this.streamLoadedOnce === false) { - this.storageMap.set(this.STREAM_RUNNING_STORAGE_KEY, true).subscribe(() => { - this.streamRunning = true; - }); - } - } - - public showVideoPlayer(): boolean { - if (!this.canSeeLiveStream) { - return false; - } - return this.streamRunning || this.streamLoadedOnce === false; - } - - public isStreamInOtherTab(): boolean { - return !this.streamRunning && this.streamLoadedOnce; - } - - public enterConferenceRoom(): void { - this.actualRoomName = this.defaultRoomName; - this.connectToHelpDesk = false; - this.enterConversation(); - } - - public enterSupportRoom(): void { - this.actualRoomName = `${this.defaultRoomName}-SUPPORT`; - this.connectToHelpDesk = true; - this.enterConversation(); - } - - private onLiveStreamAvailable(liveStreamUrl: string): void { - this.videoStreamUrl = liveStreamUrl; - // this is the "dead" state; you would see the jitsi state; but are not connected - // or the connection is prohibited. If this occurs and a live stream - // becomes available, switch to the stream state - if (this.videoStreamUrl && this.currentState === ConferenceState.jitsi && !this.isJitsiActive) { - this.viewStream(); - } else if (!this.videoStreamUrl && this.enableJitsi) { - this.setConferenceState(ConferenceState.jitsi); - } - this.cd.markForCheck(); - } - - 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 setConferenceState(newState: ConferenceState): void { - this.currentState = newState; - } - - public sendApplause(): void { - this.applauseDisabled = true; - this.applauseService.sendApplause(); - setTimeout(() => { - this.applauseDisabled = false; - this.cd.markForCheck(); - }, this.applauseTimeout); - } -} diff --git a/client/src/app/shared/components/progress/progress.component.ts b/client/src/app/shared/components/progress/progress.component.ts index 87c38a2bb..3f4fabdb1 100644 --- a/client/src/app/shared/components/progress/progress.component.ts +++ b/client/src/app/shared/components/progress/progress.component.ts @@ -1,9 +1,10 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, ViewEncapsulation } from '@angular/core'; @Component({ selector: 'os-progress', templateUrl: './progress.component.html', - styleUrls: ['./progress.component.scss'] + styleUrls: ['./progress.component.scss'], + encapsulation: ViewEncapsulation.None }) export class ProgressComponent { @Input() diff --git a/client/src/app/shared/components/video-player/video-player.component.html b/client/src/app/shared/components/video-player/video-player.component.html index 3b41595a6..70f3f3c6b 100644 --- a/client/src/app/shared/components/video-player/video-player.component.html +++ b/client/src/app/shared/components/video-player/video-player.component.html @@ -1,6 +1,5 @@
- -
+
diff --git a/client/src/app/shared/components/video-player/video-player.component.scss b/client/src/app/shared/components/video-player/video-player.component.scss index e845eccaa..c9b7c4b3f 100644 --- a/client/src/app/shared/components/video-player/video-player.component.scss +++ b/client/src/app/shared/components/video-player/video-player.component.scss @@ -4,15 +4,6 @@ height: 100%; width: 100%; - .applause-particles { - position: absolute; - display: block; - pointer-events: none !important; - width: 100px; - height: 100%; - z-index: 1; - } - .is-offline-wrapper { width: 100%; text-align: center; diff --git a/client/src/app/shared/components/video-player/video-player.component.ts b/client/src/app/shared/components/video-player/video-player.component.ts index dc4cd9297..f4381ab5a 100644 --- a/client/src/app/shared/components/video-player/video-player.component.ts +++ b/client/src/app/shared/components/video-player/video-player.component.ts @@ -1,6 +1,9 @@ +import { ThrowStmt } from '@angular/compiler'; import { AfterViewInit, + ApplicationRef, ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, EventEmitter, @@ -17,6 +20,7 @@ import { ajax, AjaxResponse } from 'rxjs/ajax'; import { catchError, map } from 'rxjs/operators'; import videojs from 'video.js'; +import { OpenSlidesStatusService } from 'app/core/core-services/openslides-status.service'; import { ConfigService } from 'app/core/ui-services/config.service'; enum MimeType { @@ -34,32 +38,41 @@ enum Player { selector: 'os-video-player', templateUrl: './video-player.component.html', styleUrls: ['./video-player.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class VideoPlayerComponent implements OnDestroy, AfterViewInit { +export class VideoPlayerComponent implements AfterViewInit, OnDestroy { @ViewChild('vjs', { static: false }) private vjsPlayerElementRef: ElementRef; private _videoUrl: string; + public isStable = false; + private afterViewInitDone = false; + + private youtubeQuerryParams = '?rel=0&iv_load_policy=3&modestbranding=1&autoplay=1'; + @Input() public set videoUrl(value: string) { + if (!value.trim()) { + return; + } this._videoUrl = value.trim(); this.playerType = this.determinePlayer(this.videoUrl); if (this.usingVjs) { this.mimeType = this.determineContentTypeByUrl(this.videoUrl); - this.initVjs(); + if (this.afterViewInitDone) { + this.initVjs(); + } } else if (this.usingYouTube) { this.stopVJS(); this.unloadVjs(); this.youTubeVideoId = this.getYouTubeVideoId(this.videoUrl); } + this.cd.markForCheck(); } - @Input() - public showParticles: boolean; - public get videoUrl(): string { return this._videoUrl; } @@ -83,17 +96,39 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit { } public get youTubeVideoUrl(): string { - return `https://www.youtube.com/embed/${this.youTubeVideoId}?autoplay=1`; + return `https://www.youtube.com/embed/${this.youTubeVideoId}${this.youtubeQuerryParams}`; } - public constructor(config: ConfigService) { + public constructor( + config: ConfigService, + private cd: ChangeDetectorRef, + private osStatus: OpenSlidesStatusService + ) { config.get('general_system_stream_poster').subscribe(posterUrl => { this.posterUrl = posterUrl?.trim(); }); + + /** + * external iFrame will block loading, since for some reason the app will + * not become stable if an iFrame was loaded. + * (or just goes instable again, for some unknown reason) + * This will result in an endless spinner + * It's crucial to render external + * Videos AFTER the app was stable + */ + this.osStatus.stable.then(() => { + this.isStable = true; + this.cd.markForCheck(); + }); } public ngAfterViewInit(): void { - this.started.next(); + if (this.usingVjs) { + this.initVjs(); + } else { + this.started.next(); + } + this.afterViewInitDone = true; } public ngOnDestroy(): void { @@ -102,7 +137,6 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit { private stopVJS(): void { if (this.vjsPlayer) { - this.vjsPlayer.src = ''; this.vjsPlayer.pause(); } } @@ -136,6 +170,7 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit { } else { this.isUrlOnline = false; } + this.cd.markForCheck(); } public async onRefreshVideo(): Promise { @@ -145,7 +180,6 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit { private async initVjs(): Promise { await this.isUrlReachable(); - if (!this.vjsPlayer && this.usingVjs && this.vjsPlayerElementRef) { this.vjsPlayer = videojs(this.vjsPlayerElementRef.nativeElement, { textTrackSettings: false, @@ -159,11 +193,15 @@ export class VideoPlayerComponent implements OnDestroy, AfterViewInit { } private playVjsVideo(): void { + if (!this.isUrlOnline) { + this.stopVJS(); + } if (this.usingVjs && this.vjsPlayer && this.isUrlOnline) { this.vjsPlayer.src({ src: this.videoUrl, type: this.mimeType }); + this.started.next(); } } diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index daa4d480c..f5467ec94 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -127,14 +127,10 @@ 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 { VideoPlayerComponent } from './components/video-player/video-player.component'; import { ListOfSpeakersContentComponent } from './components/list-of-speakers-content/list-of-speakers-content.component'; -import { ApplauseBarDisplayComponent } from './components/applause-bar-display/applause-bar-display.component'; import { ProgressComponent } from './components/progress/progress.component'; -import { NgParticlesModule } from 'ng-particles'; -import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component'; import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/point-of-order-dialog.component'; +import { VideoPlayerComponent } from './components/video-player/video-player.component'; /** * Share Module for all "dumb" components and pipes. @@ -199,8 +195,7 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/ PblNgridTargetEventsModule, PdfViewerModule, NgxMaterialTimepickerModule, - ChartsModule, - NgParticlesModule + ChartsModule ], exports: [ FormsModule, @@ -304,10 +299,11 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/ VotingPrivacyWarningComponent, MotionPollDetailContentComponent, AssignmentPollDetailContentComponent, - JitsiComponent, - VideoPlayerComponent, ListOfSpeakersContentComponent, - PointOfOrderDialogComponent + PointOfOrderDialogComponent, + ListOfSpeakersContentComponent, + ProgressComponent, + VideoPlayerComponent ], declarations: [ PermsDirective, @@ -369,13 +365,10 @@ import { PointOfOrderDialogComponent } from './components/point-of-order-dialog/ VotingPrivacyWarningComponent, MotionPollDetailContentComponent, AssignmentPollDetailContentComponent, - JitsiComponent, - VideoPlayerComponent, ListOfSpeakersContentComponent, - ApplauseBarDisplayComponent, ProgressComponent, - ApplauseParticleDisplayComponent, - PointOfOrderDialogComponent + PointOfOrderDialogComponent, + VideoPlayerComponent ], providers: [ { diff --git a/client/src/app/site/interaction/components/action-bar/action-bar.component.html b/client/src/app/site/interaction/components/action-bar/action-bar.component.html new file mode 100644 index 000000000..87a51454a --- /dev/null +++ b/client/src/app/site/interaction/components/action-bar/action-bar.component.html @@ -0,0 +1,64 @@ +
+ + + + + + + + + + + + + + + + + +
diff --git a/client/src/app/site/interaction/components/action-bar/action-bar.component.scss b/client/src/app/site/interaction/components/action-bar/action-bar.component.scss new file mode 100644 index 000000000..dd861735c --- /dev/null +++ b/client/src/app/site/interaction/components/action-bar/action-bar.component.scss @@ -0,0 +1,17 @@ +:host { + margin: auto 0.5em 0 0; +} + +.interaction-bar-wrapper { + width: auto; + transition: all 2s linear; +} + +.action-bar-shadow { + box-shadow: 0px 0px 4px 1px rgba(0, 0, 0, 0.5) !important; +} + +.mat-button-base { + margin-left: 0.5em; + margin-bottom: 0.5em; +} diff --git a/client/src/app/site/interaction/components/action-bar/action-bar.component.spec.ts b/client/src/app/site/interaction/components/action-bar/action-bar.component.spec.ts new file mode 100644 index 000000000..21813ffad --- /dev/null +++ b/client/src/app/site/interaction/components/action-bar/action-bar.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { ActionBarComponent } from './action-bar.component'; + +describe('ActionBarComponent', () => { + let component: ActionBarComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ActionBarComponent], + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ActionBarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/components/action-bar/action-bar.component.ts b/client/src/app/site/interaction/components/action-bar/action-bar.component.ts new file mode 100644 index 000000000..7faf81c70 --- /dev/null +++ b/client/src/app/site/interaction/components/action-bar/action-bar.component.ts @@ -0,0 +1,124 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; + +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { fadeAnimation, fadeInOut } from 'app/shared/animations'; +import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService } from '../../services/applause.service'; +import { CallRestrictionService } from '../../services/call-restriction.service'; +import { InteractionService } from '../../services/interaction.service'; +import { RtcService } from '../../services/rtc.service'; + +const canEnterTooltip = _('Enter conference room'); +const cannotEnterTooltip = _('Add yourself to the current list of speakers to join the conference'); +@Component({ + selector: 'os-action-bar', + templateUrl: './action-bar.component.html', + styleUrls: ['./action-bar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [fadeInOut, fadeAnimation] +}) +export class ActionBarComponent extends BaseViewComponentDirective { + public applauseLevel = 0; + public applauseDisabled = false; + + public showApplause: Observable = this.applauseService.showApplause; + public applauseLevelObservable: Observable = this.applauseService.applauseLevelObservable; + public applauseTimeout = this.applauseService.applauseTimeout; + public isJoined: Observable = this.rtcService.isJoinedObservable; + public showCallDialog: Observable = this.rtcService.showCallDialogObservable; + public showLiveConf: Observable = this.interactionService.showLiveConfObservable; + + public isSupportEnabled: Observable = this.rtcService.isSupportEnabled; + + private canEnterCallObservable: Observable = this.callRestrictionService.canEnterCallObservable; + public canEnterCall = false; + + /** + * for the pulse animation + */ + public enterCallAnimHelper = true; + public meetingActiveAnimHelper = true; + + public get isConfStateStream(): Observable { + return this.interactionService.isConfStateStream; + } + + public get isConfStateJitsi(): Observable { + return this.interactionService.isConfStateJitsi; + } + + public get isConfStateNone(): Observable { + return this.interactionService.isConfStateNone; + } + + public get enterRoomTooltip(): string { + if (this.canEnterCall) { + return _(canEnterTooltip); + } else { + return _(cannotEnterTooltip); + } + } + + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private router: Router, + private callRestrictionService: CallRestrictionService, + private interactionService: InteractionService, + private rtcService: RtcService, + private applauseService: ApplauseService, + private cd: ChangeDetectorRef + ) { + super(titleService, translate, matSnackBar); + this.subscriptions.push( + this.canEnterCallObservable.subscribe(canEnter => { + this.canEnterCall = canEnter; + this.cd.markForCheck(); + }) + ); + } + + public async enterConferenceRoom(canEnter: boolean): Promise { + if (canEnter) { + this.interactionService + .enterCall() + .then(() => this.rtcService.enterConferenceRoom()) + .catch(this.raiseError); + } else { + const navUrl = '/autopilot'; + this.router.navigate([navUrl]); + } + } + + public enterSupportRoom(): void { + this.interactionService + .enterCall() + .then(() => this.rtcService.enterSupportRoom()) + .catch(this.raiseError); + } + + public maximizeCallDialog(): void { + this.rtcService.showCallDialog = true; + } + + public sendApplause(): void { + this.applauseDisabled = true; + this.applauseService.sendApplause(); + this.cd.markForCheck(); + setTimeout(() => { + this.applauseDisabled = false; + this.cd.markForCheck(); + }, this.applauseTimeout); + } + + public triggerCallHiddenAnimation(): void { + this.meetingActiveAnimHelper = !this.meetingActiveAnimHelper; + } +} diff --git a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.html b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.html similarity index 83% rename from client/src/app/shared/components/applause-bar-display/applause-bar-display.component.html rename to client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.html index a632db452..44077f7b7 100644 --- a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.html +++ b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.scss b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.scss similarity index 76% rename from client/src/app/shared/components/applause-bar-display/applause-bar-display.component.scss rename to client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.scss index e5caf4ee5..ae0795216 100644 --- a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.scss +++ b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.scss @@ -11,16 +11,10 @@ } .level-indicator { - height: 50px; display: block; text-align: center; .level { display: inline-block; - margin-top: 25px; } } - -.particle-display { - // height: 100%; -} diff --git a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.spec.ts b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.spec.ts similarity index 100% rename from client/src/app/shared/components/applause-bar-display/applause-bar-display.component.spec.ts rename to client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.spec.ts diff --git a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.ts b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.ts similarity index 89% rename from client/src/app/shared/components/applause-bar-display/applause-bar-display.component.ts rename to client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.ts index 5e59e66de..08ea847e6 100644 --- a/client/src/app/shared/components/applause-bar-display/applause-bar-display.component.ts +++ b/client/src/app/site/interaction/components/applause-bar-display/applause-bar-display.component.ts @@ -4,10 +4,10 @@ import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { Applause, ApplauseService, ApplauseType } from 'app/core/ui-services/applause.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { fadeAnimation } from 'app/shared/animations'; import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService, ApplauseType } from 'app/site/interaction/services/applause.service'; @Component({ selector: 'os-applause-bar-display', @@ -26,10 +26,6 @@ export class ApplauseBarDisplayComponent extends BaseViewComponentDirective { return !!this.level; } - public get isApplauseTypeBar(): boolean { - return this.applauseService.applauseType === ApplauseType.bar; - } - public constructor( title: Title, translate: TranslateService, diff --git a/client/src/app/shared/components/applause-particle-display/applause-particle-display.component.html b/client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.html similarity index 100% rename from client/src/app/shared/components/applause-particle-display/applause-particle-display.component.html rename to client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.html diff --git a/client/src/app/shared/components/applause-particle-display/applause-particle-display.component.scss b/client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.scss similarity index 100% rename from client/src/app/shared/components/applause-particle-display/applause-particle-display.component.scss rename to client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.scss diff --git a/client/src/app/shared/components/applause-particle-display/applause-particle-display.component.spec.ts b/client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.spec.ts similarity index 100% rename from client/src/app/shared/components/applause-particle-display/applause-particle-display.component.spec.ts rename to client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.spec.ts diff --git a/client/src/app/shared/components/applause-particle-display/applause-particle-display.component.ts b/client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.ts similarity index 96% rename from client/src/app/shared/components/applause-particle-display/applause-particle-display.component.ts rename to client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.ts index 77be271e2..14c19dab4 100644 --- a/client/src/app/shared/components/applause-particle-display/applause-particle-display.component.ts +++ b/client/src/app/site/interaction/components/applause-particle-display/applause-particle-display.component.ts @@ -7,10 +7,10 @@ import { Subject } from 'rxjs'; import { auditTime } from 'rxjs/operators'; import { Container } from 'tsparticles'; -import { ApplauseService } from 'app/core/ui-services/applause.service'; import { ConfigService } from 'app/core/ui-services/config.service'; import { ElementSize } from 'app/shared/directives/resized.directive'; import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService } from 'app/site/interaction/services/applause.service'; import { particleConfig, particleOptions } from './particle-options'; @Component({ @@ -79,7 +79,6 @@ export class ApplauseParticleDisplayComponent extends BaseViewComponentDirective private setParticleLevel(level: number): void { if (this.particleContainer) { const emitters = this.particleContainer.plugins.get('emitters') as any; - // TODO: Use `Emitters` instead of any. if (emitters) { emitters.array[0].emitterOptions.rate.quantity = level; } diff --git a/client/src/app/shared/components/applause-particle-display/particle-options.ts b/client/src/app/site/interaction/components/applause-particle-display/particle-options.ts similarity index 100% rename from client/src/app/shared/components/applause-particle-display/particle-options.ts rename to client/src/app/site/interaction/components/applause-particle-display/particle-options.ts diff --git a/client/src/app/site/interaction/components/call-dialog/call-dialog.component.html b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.html new file mode 100644 index 000000000..1cae073ac --- /dev/null +++ b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +
+ + + open_in_new + + + + + + + + + + +
+
+
+
diff --git a/client/src/app/site/interaction/components/call-dialog/call-dialog.component.scss b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.scss new file mode 100644 index 000000000..7ac3c2778 --- /dev/null +++ b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.scss @@ -0,0 +1,32 @@ +.dialog-mobile { + left: 15px !important; +} + +.jitsi-fake-dialog-wrapper { + z-index: -1; + position: fixed; + pointer-events: none; + left: 270px; + top: 20px; + right: 30px; + bottom: 0; + + .jitsi-fake-dialog { + padding: 0 5px 5px 5px; + pointer-events: all; + display: flex; + flex-direction: column; + width: 100%; + height: 90%; + + .jitsi-iframe-wrapper { + flex: 1; + } + + .jitsi-dialog-actions { + .right { + float: right; + } + } + } +} diff --git a/client/src/app/site/interaction/components/call-dialog/call-dialog.component.spec.ts b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.spec.ts new file mode 100644 index 000000000..282e7a0e8 --- /dev/null +++ b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { CallDialogComponent } from './call-dialog.component'; + +describe('CallDialogComponent', () => { + let component: CallDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [CallDialogComponent], + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CallDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/components/call-dialog/call-dialog.component.ts b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.ts new file mode 100644 index 000000000..ba2c3ab22 --- /dev/null +++ b/client/src/app/site/interaction/components/call-dialog/call-dialog.component.ts @@ -0,0 +1,51 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Output, + ViewChild +} from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ViewportService } from 'app/core/ui-services/viewport.service'; +import { RtcService } from '../../services/rtc.service'; + +@Component({ + selector: 'os-call-dialog', + templateUrl: './call-dialog.component.html', + styleUrls: ['./call-dialog.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CallDialogComponent implements AfterViewInit { + @ViewChild('jitsi') + private jitsiNode: ElementRef; + + public jitsiMeetUrl: Observable = this.rtcService.jitsiMeetUrl; + + public isMobile: Observable = this.vp.isMobileSubject; + + public constructor(private cd: ChangeDetectorRef, private rtcService: RtcService, private vp: ViewportService) {} + + public ngAfterViewInit(): void { + this.rtcService.setJitsiNode(this.jitsiNode); + this.cd.markForCheck(); + } + + public fullScreen(): void { + this.rtcService.enterFullScreen(); + this.cd.markForCheck(); + } + + public hangUp(): void { + this.rtcService.stopJitsi(); + this.cd.markForCheck(); + } + + public hideDialog(): void { + this.rtcService.showCallDialog = false; + } +} diff --git a/client/src/app/site/interaction/components/call/call.component.html b/client/src/app/site/interaction/components/call/call.component.html new file mode 100644 index 000000000..5233fd94c --- /dev/null +++ b/client/src/app/site/interaction/components/call/call.component.html @@ -0,0 +1,87 @@ + + +
+ +
+ +
+ {{ 'A conference is already running in your OpenSlides session.' | translate }} + +
+ +
+ cloud_off +
+ +
+ +
+ + +
+ +
+
    +
  1. +
    + {{ members[memberId].name }} +
    +
  2. +
+
+
+
+ + +
+ +
+
+ + + + + +
+
+ + +
+
+
+
diff --git a/client/src/app/site/interaction/components/call/call.component.scss b/client/src/app/site/interaction/components/call/call.component.scss new file mode 100644 index 000000000..222d3c955 --- /dev/null +++ b/client/src/app/site/interaction/components/call/call.component.scss @@ -0,0 +1,64 @@ +$wrapper-padding: 5px; + +.jitsi-list { + display: flex; + flex-direction: column; + height: 100%; + + .content { + flex: 1 0 auto; + + .disconnected { + display: flex; + height: 100%; + + * { + margin: auto; + } + } + + .room-members { + height: 100%; + position: relative; + + .room-list-applause-particles { + position: absolute; + height: 100%; + width: 70px; + right: 0; + } + + .member-list { + max-height: 100%; + overflow-y: auto; + + .member-list-entry { + margin: 5px; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .focused { + font-weight: bold; + } + } + } + + .control-grid { + padding: $wrapper-padding; + display: grid; + grid-template-areas: 'empty buttons exit'; + grid-template-columns: 40px auto 40px; + + .exit { + grid-area: exit; + } + + .control-buttons { + grid-area: buttons; + margin: auto; + } + } +} diff --git a/client/src/app/shared/components/jitsi/jitsi.component.spec.ts b/client/src/app/site/interaction/components/call/call.component.spec.ts similarity index 58% rename from client/src/app/shared/components/jitsi/jitsi.component.spec.ts rename to client/src/app/site/interaction/components/call/call.component.spec.ts index ec145d053..5597e3fd5 100644 --- a/client/src/app/shared/components/jitsi/jitsi.component.spec.ts +++ b/client/src/app/site/interaction/components/call/call.component.spec.ts @@ -2,21 +2,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { E2EImportsModule } from 'e2e-imports.module'; -import { JitsiComponent } from './jitsi.component'; +import { CallComponent } from './call.component'; -describe('JitsiComponent', () => { - let component: JitsiComponent; - let fixture: ComponentFixture; +describe('CallComponent', () => { + let component: CallComponent; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [E2EImportsModule], - declarations: [JitsiComponent] + declarations: [CallComponent], + imports: [E2EImportsModule] }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(JitsiComponent); + fixture = TestBed.createComponent(CallComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/client/src/app/site/interaction/components/call/call.component.ts b/client/src/app/site/interaction/components/call/call.component.ts new file mode 100644 index 000000000..00852e182 --- /dev/null +++ b/client/src/app/site/interaction/components/call/call.component.ts @@ -0,0 +1,189 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + OnDestroy, + OnInit, + Output +} from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService } from '../../services/applause.service'; +import { CallRestrictionService } from '../../services/call-restriction.service'; +import { InteractionService } from '../../services/interaction.service'; +import { RtcService } from '../../services/rtc.service'; +import { StreamService } from '../../services/stream.service'; + +const helpDeskTitle = _('Help desk'); +const liveConferenceTitle = _('Conference room'); +const disconnectedTitle = _('disconnected'); +const connectingTitle = _('connecting ...'); + +@Component({ + selector: 'os-call', + templateUrl: './call.component.html', + styleUrls: ['./call.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CallComponent extends BaseViewComponentDirective implements OnInit, AfterViewInit, OnDestroy { + public isJitsiActiveInAnotherTab: Observable = this.rtcService.inOtherTab; + public canEnterCall: Observable = this.callRestrictionService.canEnterCallObservable; + public isJitsiDialogOpen: Observable = this.rtcService.showCallDialogObservable; + + public isJitsiActive: boolean; + public isJoined: boolean; + + public get showHangUp(): boolean { + return this.isJitsiActive && this.isJoined; + } + + private dominantSpeaker: string; + private members = {}; + public get memberList(): string[] { + return Object.keys(this.members); + } + + public get isDisconnected(): boolean { + return !this.isJitsiActive && !this.isJoined; + } + + public get isConnecting(): boolean { + return this.isJitsiActive && !this.isJoined; + } + + public get isConnected(): boolean { + return this.isJitsiActive && this.isJoined; + } + + public get showParticles(): Observable { + return this.applauseService.showParticles; + } + + public get canSeeLiveStream(): Observable { + return this.streamService.canSeeLiveStreamObservable; + } + + public get liveStreamUrl(): Observable { + return this.streamService.liveStreamUrlObservable; + } + + private autoConnect: boolean; + + @Output() + public conferenceTitle: EventEmitter = new EventEmitter(); + + @Output() + public conferenceSubtitle: EventEmitter = new EventEmitter(); + + public constructor( + titleService: Title, + translate: TranslateService, + snackBar: MatSnackBar, + private callRestrictionService: CallRestrictionService, + private rtcService: RtcService, + private applauseService: ApplauseService, + private interactionService: InteractionService, + private streamService: StreamService, + private cd: ChangeDetectorRef + ) { + super(titleService, translate, snackBar); + + this.subscriptions.push( + this.rtcService.isJitsiActiveObservable.subscribe(active => { + this.isJitsiActive = active; + this.updateSubtitle(); + this.cd.markForCheck(); + }), + + this.rtcService.isJoinedObservable.subscribe(isJoined => { + this.isJoined = isJoined; + this.updateSubtitle(); + this.cd.markForCheck(); + }), + + this.rtcService.memberObservableObservable.subscribe(members => { + this.members = members; + this.cd.markForCheck(); + }), + + this.rtcService.dominantSpeakerObservable.subscribe(domSpeaker => { + this.dominantSpeaker = domSpeaker?.displayName; + this.updateSubtitle(); + this.cd.markForCheck(); + }), + + this.rtcService.autoConnect.subscribe(auto => { + this.autoConnect = auto; + }), + + this.rtcService.connectedToHelpDesk.subscribe(onHelpDesk => { + if (onHelpDesk) { + this.conferenceTitle.next(helpDeskTitle); + } else { + this.conferenceTitle.next(liveConferenceTitle); + } + this.cd.markForCheck(); + }) + ); + } + + public ngOnInit(): void { + this.updateSubtitle(); + } + + public ngAfterViewInit(): void { + if (this.autoConnect) { + this.callRoom(); + } + } + + // closing the tab should also try to stop jitsi. + // this will usually not be caught by ngOnDestroy + @HostListener('window:beforeunload', ['$event']) + public beforeunload($event: any): void { + this.rtcService.stopJitsi(); + } + + public ngOnDestroy(): void { + super.ngOnDestroy(); + this.rtcService.stopJitsi(); + } + + private updateSubtitle(): void { + if (this.isJitsiActive && this.isJoined) { + this.conferenceSubtitle.next(this.dominantSpeaker || ''); + } else if (this.isJitsiActive && !this.isJoined) { + this.conferenceSubtitle.next(connectingTitle); + } else { + this.conferenceSubtitle.next(disconnectedTitle); + } + } + + public async callRoom(): Promise { + await this.rtcService.enterConferenceRoom().catch(this.raiseError); + this.cd.markForCheck(); + } + + public forceStart(): void { + this.rtcService.enterConferenceRoom(true).catch(this.raiseError); + this.cd.markForCheck(); + } + + public hangUp(): void { + this.rtcService.stopJitsi(); + this.cd.markForCheck(); + } + + public viewStream(): void { + this.interactionService.viewStream(); + } +} diff --git a/client/src/app/site/interaction/components/interaction-container/interaction-container.component.html b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.html new file mode 100644 index 000000000..21ae32a0d --- /dev/null +++ b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.html @@ -0,0 +1,36 @@ +
+
+
+
+ {{ containerHeadTitle | translate }} +
+
+ {{ containerHeadSubtitle | translate }} +
+
+
+ +
+ + + + + + + + +
+
diff --git a/client/src/app/site/interaction/components/interaction-container/interaction-container.component.scss b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.scss new file mode 100644 index 000000000..8af177298 --- /dev/null +++ b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.scss @@ -0,0 +1,59 @@ +$radius: 5px; + +:host { + margin-top: auto; +} + +.interaction-container-wrapper { + box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2); + border-top-left-radius: $radius; + border-top-right-radius: $radius; + min-width: 250px; + max-width: 70vw; +} + +.container-head { + height: 50px; + display: flex; + border-top-left-radius: $radius; + border-top-right-radius: $radius; + cursor: pointer; + + .container-head-wrapper { + margin-left: 1em; + margin-top: auto; + margin-bottom: auto; + } + + .container-head-title { + font-weight: bold; + } +} + +.container-body { + display: flex; + max-width: 500px; + max-height: 280px; + transition: all 350ms ease-out; +} + +.container-body-with-applause-bar { + max-width: 530px !important; +} + +.video-player { + display: block; + width: 500px; + height: 280px; +} + +.call-body { + display: block; + width: 250px; + height: 280px; +} + +.container-body-hide { + max-width: 250px; + max-height: 0px; +} diff --git a/client/src/app/site/interaction/components/interaction-container/interaction-container.component.spec.ts b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.spec.ts new file mode 100644 index 000000000..84f19185f --- /dev/null +++ b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { InteractionContainerComponent } from './interaction-container.component'; + +describe('InteractionContainerComponent', () => { + let component: InteractionContainerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [InteractionContainerComponent], + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(InteractionContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/components/interaction-container/interaction-container.component.ts b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.ts new file mode 100644 index 000000000..6a8a50e44 --- /dev/null +++ b/client/src/app/site/interaction/components/interaction-container/interaction-container.component.ts @@ -0,0 +1,117 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest, forkJoin, merge, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, mergeAll, mergeMap, withLatestFrom } from 'rxjs/operators'; + +import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService } from '../../services/applause.service'; +import { InteractionService } from '../../services/interaction.service'; +import { RtcService } from '../../services/rtc.service'; +import { StreamService } from '../../services/stream.service'; + +@Component({ + selector: 'os-interaction-container', + templateUrl: './interaction-container.component.html', + styleUrls: ['./interaction-container.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class InteractionContainerComponent extends BaseViewComponentDirective { + public showBody = false; + + private streamRunning = false; + private streamLoadedOnce = false; + + public containerHeadTitle = ''; + public containerHeadSubtitle = ''; + + public get isApplausEnabled(): Observable { + return this.applauseService.showApplause; + } + + public get showApplauseBar(): Observable { + return this.applauseService.showBar; + } + + public get isConfStateStream(): Observable { + return this.interactionService.isConfStateStream; + } + + public get isConfStateJitsi(): Observable { + return this.interactionService.isConfStateJitsi; + } + + public get isConfStateNone(): Observable { + return this.interactionService.isConfStateNone; + } + + public get isStreamInOtherTab(): boolean { + return !this.streamRunning && this.streamLoadedOnce; + } + + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + rtcService: RtcService, + streamService: StreamService, + private interactionService: InteractionService, + private applauseService: ApplauseService, + private cd: ChangeDetectorRef + ) { + super(titleService, translate, matSnackBar); + this.subscriptions.push( + interactionService.conferenceStateObservable.pipe(distinctUntilChanged()).subscribe(state => { + if (state) { + this.clearTitles(); + } + }), + rtcService.showCallDialogObservable.subscribe(show => { + if (show) { + this.showBody = false; + } + this.cd.markForCheck(); + }), + streamService.streamLoadedOnceObservable.subscribe(loadedOnce => { + this.streamLoadedOnce = loadedOnce; + if (!this.isStreamInOtherTab) { + this.showBody = true; + } + this.cd.markForCheck(); + }), + streamService.isStreamRunningObservable.subscribe(running => { + this.streamRunning = running || false; + if (!this.isStreamInOtherTab) { + this.showBody = true; + } + this.cd.markForCheck(); + }) + ); + } + + private clearTitles(): void { + this.containerHeadTitle = ''; + this.containerHeadSubtitle = ''; + this.cd.detectChanges(); + } + + public showHideBody(): void { + this.showBody = !this.showBody; + } + + public updateTitle(title: string): void { + if (title !== this.containerHeadTitle) { + this.containerHeadTitle = title ?? ''; + this.cd.detectChanges(); + } + } + + public updateSubtitle(title: string): void { + if (title !== this.containerHeadSubtitle) { + this.containerHeadSubtitle = title ?? ''; + this.cd.detectChanges(); + } + } +} diff --git a/client/src/app/site/interaction/components/stream/stream.component.html b/client/src/app/site/interaction/components/stream/stream.component.html new file mode 100644 index 000000000..9fd678744 --- /dev/null +++ b/client/src/app/site/interaction/components/stream/stream.component.html @@ -0,0 +1,15 @@ +
+ + +
+
+ {{ 'You are not allowed to see the live stream' | translate }} +
+
+ +
diff --git a/client/src/app/site/interaction/components/stream/stream.component.scss b/client/src/app/site/interaction/components/stream/stream.component.scss new file mode 100644 index 000000000..296629d10 --- /dev/null +++ b/client/src/app/site/interaction/components/stream/stream.component.scss @@ -0,0 +1,8 @@ +.applause-particles { + position: absolute; + display: block; + pointer-events: none !important; + width: 100px; + height: 100%; + z-index: 1; +} diff --git a/client/src/app/site/interaction/components/stream/stream.component.spec.ts b/client/src/app/site/interaction/components/stream/stream.component.spec.ts new file mode 100644 index 000000000..ba60ad784 --- /dev/null +++ b/client/src/app/site/interaction/components/stream/stream.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { StreamComponent } from './stream.component'; + +describe('StreamComponent', () => { + let component: StreamComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [StreamComponent], + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StreamComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/components/stream/stream.component.ts b/client/src/app/site/interaction/components/stream/stream.component.ts new file mode 100644 index 000000000..3bc2fc784 --- /dev/null +++ b/client/src/app/site/interaction/components/stream/stream.component.ts @@ -0,0 +1,115 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + OnDestroy, + OnInit, + Output +} from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; + +import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ApplauseService } from '../../services/applause.service'; +import { StreamService } from '../../services/stream.service'; + +@Component({ + selector: 'os-stream', + templateUrl: './stream.component.html', + styleUrls: ['./stream.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StreamComponent extends BaseViewComponentDirective implements AfterViewInit, OnDestroy { + private streamRunning = false; + + public liveStreamUrl: string; + private streamLoadedOnce: boolean; + + public get showParticles(): Observable { + return this.applauseService.showParticles; + } + + public get showVideoPlayer(): boolean { + return this.streamRunning || this.streamLoadedOnce === false; + } + + public get isStreamInOtherTab(): boolean { + return !this.streamRunning && this.streamLoadedOnce; + } + + @Output() + public streamTitle: EventEmitter = new EventEmitter(); + + @Output() + public streamSubtitle: EventEmitter = new EventEmitter(); + + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private streamService: StreamService, + private applauseService: ApplauseService, + private cd: ChangeDetectorRef + ) { + super(titleService, translate, matSnackBar); + + this.subscriptions.push( + this.streamService.liveStreamUrlObservable.subscribe(url => { + this.liveStreamUrl = url?.trim(); + this.cd.markForCheck(); + }), + this.streamService.streamLoadedOnceObservable.subscribe(loadedOnce => { + this.streamLoadedOnce = loadedOnce || false; + this.cd.markForCheck(); + }), + this.streamService.isStreamRunningObservable.subscribe(running => { + this.streamRunning = running || false; + this.cd.markForCheck(); + }) + ); + } + + public ngAfterViewInit(): void { + this.streamTitle.next('Livestream'); + this.streamSubtitle.next(''); + this.cd.detectChanges(); + } + + // closing the tab should also try to stop jitsi. + // this will usually not be caught by ngOnDestroy + @HostListener('window:beforeunload', ['$event']) + public async beforeunload($event: any): Promise { + this.beforeViewCloses(); + } + + public ngOnDestroy(): void { + super.ngOnDestroy(); + this.beforeViewCloses(); + } + + private beforeViewCloses(): void { + if (this.streamLoadedOnce && this.streamRunning) { + this.streamService.deleteStreamingLock(); + } + } + + public forceLoadStream(): void { + this.streamService.deleteStreamingLock(); + } + + public onSteamLoaded(): void { + /** + * explicit false check, undefined would mean that this was not checked yet + */ + if (this.streamLoadedOnce === false) { + this.streamService.setStreamingLock(); + this.streamService.setStreamRunning(true); + } + } +} diff --git a/client/src/app/site/interaction/interaction.module.ts b/client/src/app/site/interaction/interaction.module.ts new file mode 100644 index 000000000..39043278f --- /dev/null +++ b/client/src/app/site/interaction/interaction.module.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { NgParticlesModule } from 'ng-particles'; + +import { ActionBarComponent } from './components/action-bar/action-bar.component'; +import { ApplauseBarDisplayComponent } from './components/applause-bar-display/applause-bar-display.component'; +import { ApplauseParticleDisplayComponent } from './components/applause-particle-display/applause-particle-display.component'; +import { CallDialogComponent } from './components/call-dialog/call-dialog.component'; +import { CallComponent } from './components/call/call.component'; +import { InteractionContainerComponent } from './components/interaction-container/interaction-container.component'; +import { SharedModule } from '../../shared/shared.module'; +import { StreamComponent } from './components/stream/stream.component'; + +@NgModule({ + declarations: [ + ApplauseBarDisplayComponent, + ApplauseParticleDisplayComponent, + ActionBarComponent, + InteractionContainerComponent, + StreamComponent, + CallComponent, + CallDialogComponent + ], + imports: [CommonModule, SharedModule, NgParticlesModule], + exports: [ActionBarComponent, InteractionContainerComponent] +}) +export class InteractionModule {} diff --git a/client/src/app/core/ui-services/applause.service.spec.ts b/client/src/app/site/interaction/services/applause.service.spec.ts similarity index 100% rename from client/src/app/core/ui-services/applause.service.spec.ts rename to client/src/app/site/interaction/services/applause.service.spec.ts diff --git a/client/src/app/core/ui-services/applause.service.ts b/client/src/app/site/interaction/services/applause.service.ts similarity index 63% rename from client/src/app/core/ui-services/applause.service.ts rename to client/src/app/site/interaction/services/applause.service.ts index ebf3a0b03..0d94616c8 100644 --- a/client/src/app/core/ui-services/applause.service.ts +++ b/client/src/app/site/interaction/services/applause.service.ts @@ -3,9 +3,9 @@ import { Injectable } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { distinctUntilChanged, filter, map } from 'rxjs/operators'; -import { ConfigService } from './config.service'; -import { HttpService } from '../core-services/http.service'; -import { NotifyService } from '../core-services/notify.service'; +import { ConfigService } from '../../../core/ui-services/config.service'; +import { HttpService } from '../../../core/core-services/http.service'; +import { NotifyService } from '../../../core/core-services/notify.service'; export interface Applause { level: number; @@ -21,35 +21,54 @@ const applausePath = '/system/applause'; const applauseNotifyMessageName = 'applause'; @Injectable({ + // providedIn: InteractionModule providedIn: 'root' + // provided: InteractionModule }) export class ApplauseService { private minApplauseLevel: number; private maxApplauseLevel: number; private presentApplauseUsers: number; + private applauseTypeObservable: Observable; - public applauseType: ApplauseType; + public showApplause: Observable; + public showApplauseLevel: boolean; + public applauseTimeout: number; private applauseLevelSubject: Subject = new Subject(); - public applauseLevelObservable = this.applauseLevelSubject.asObservable(); + public applauseLevelObservable: Observable = this.applauseLevelSubject.asObservable(); private get maxApplause(): number { return this.maxApplauseLevel || this.presentApplauseUsers || 0; } + public get showParticles(): Observable { + return this.applauseTypeObservable.pipe(map(type => type === ApplauseType.particles)); + } + + public get showBar(): Observable { + return this.applauseTypeObservable.pipe(map(type => type === ApplauseType.bar)); + } + public constructor( configService: ConfigService, private httpService: HttpService, private notifyService: NotifyService ) { + this.showApplause = configService.get('general_system_applause_enable'); + this.applauseTypeObservable = configService.get('general_system_applause_type'); + configService.get('general_system_applause_min_amount').subscribe(minLevel => { this.minApplauseLevel = minLevel; }); configService.get('general_system_applause_max_amount').subscribe(maxLevel => { this.maxApplauseLevel = maxLevel; }); - configService.get('general_system_applause_type').subscribe((type: ApplauseType) => { - this.applauseType = type; + configService.get('general_system_applause_show_level').subscribe(show => { + this.showApplauseLevel = show; + }); + configService.get('general_system_stream_applause_timeout').subscribe(timeout => { + this.applauseTimeout = (timeout || 1) * 1000; }); this.notifyService .getMessageObservable(applauseNotifyMessageName) diff --git a/client/src/app/site/interaction/services/call-restriction.service.spec.ts b/client/src/app/site/interaction/services/call-restriction.service.spec.ts new file mode 100644 index 000000000..dd33f5c03 --- /dev/null +++ b/client/src/app/site/interaction/services/call-restriction.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { CallRestrictionService } from './call-restriction.service'; + +describe('CallRestrictionService', () => { + let service: CallRestrictionService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [E2EImportsModule] }); + service = TestBed.inject(CallRestrictionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/services/call-restriction.service.ts b/client/src/app/site/interaction/services/call-restriction.service.ts new file mode 100644 index 000000000..7939ee764 --- /dev/null +++ b/client/src/app/site/interaction/services/call-restriction.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +import { OperatorService, Permission } from 'app/core/core-services/operator.service'; +import { ConfigService } from 'app/core/ui-services/config.service'; +import { UserListIndexType } from 'app/site/agenda/models/view-list-of-speakers'; +import { CurrentListOfSpeakersService } from 'app/site/projector/services/current-list-of-speakers.service'; + +@Injectable({ + providedIn: 'root' +}) +export class CallRestrictionService { + private canManageSpeaker: boolean; + private restricted: boolean; + private isOnCurrentLos: boolean; + private nextSpeakerAmount: number; + + public isAccessRestricted: Observable; + + private canEnterCallSubject = new BehaviorSubject(false); + public canEnterCallObservable = this.canEnterCallSubject.asObservable(); + + private hasToEnterCallSubject = new Subject(); + public hasToEnterCallObservable = this.hasToEnterCallSubject.asObservable(); + + private hasToLeaveCallSubject = new Subject(); + public hasToLeaveCallObservable = this.hasToLeaveCallSubject.asObservable(); + + public constructor( + configService: ConfigService, + operator: OperatorService, + closService: CurrentListOfSpeakersService + ) { + /** + * general access perm + */ + operator.getUserObservable().subscribe(() => { + this.canManageSpeaker = operator.hasPerms(Permission.agendaCanManageListOfSpeakers); + this.updateCanEnterCall(); + }); + + /** + * LosRestriction + */ + this.isAccessRestricted = configService.get('general_system_conference_los_restriction'); + this.isAccessRestricted.subscribe(restricted => { + this.restricted = restricted; + this.updateCanEnterCall(); + }); + + /** + * Is User In Clos + */ + closService.currentListOfSpeakersObservable + .pipe( + map(los => los?.findUserIndexOnList(operator.user.id) ?? -1), + distinctUntilChanged() + ) + .subscribe(userLosIndex => { + this.isOnCurrentLos = userLosIndex !== UserListIndexType.NotOnList; + this.updateCanEnterCall(); + this.updateAutoJoinJitsiByLosIndex(userLosIndex); + }); + + /** + * Amount of next speakers + */ + configService + .get('general_system_conference_auto_connect_next_speakers') + .subscribe(nextSpeakerAmount => { + this.nextSpeakerAmount = nextSpeakerAmount; + }); + } + + private updateCanEnterCall(): void { + this.canEnterCallSubject.next(!this.restricted || this.canManageSpeaker || this.isOnCurrentLos); + } + + private updateAutoJoinJitsiByLosIndex(operatorClosIndex: number): void { + if (operatorClosIndex !== UserListIndexType.NotOnList) { + if ( + this.nextSpeakerAmount && + this.nextSpeakerAmount > 0 && + operatorClosIndex > UserListIndexType.Active && + operatorClosIndex <= this.nextSpeakerAmount + ) { + this.hasToEnterCallSubject.next(); + } + } else if (operatorClosIndex === UserListIndexType.NotOnList && this.restricted) { + this.hasToLeaveCallSubject.next(); + } + } +} diff --git a/client/src/app/site/interaction/services/interaction.service.spec.ts b/client/src/app/site/interaction/services/interaction.service.spec.ts new file mode 100644 index 000000000..06df66e3c --- /dev/null +++ b/client/src/app/site/interaction/services/interaction.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { InteractionService } from './interaction.service'; + +describe('InteractionService', () => { + let service: InteractionService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [E2EImportsModule] }); + service = TestBed.inject(InteractionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/services/interaction.service.ts b/client/src/app/site/interaction/services/interaction.service.ts new file mode 100644 index 000000000..98d0abe33 --- /dev/null +++ b/client/src/app/site/interaction/services/interaction.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ConfigService } from 'app/core/ui-services/config.service'; +import { CallRestrictionService } from './call-restriction.service'; +import { RtcService } from './rtc.service'; +import { StreamService } from './stream.service'; + +export enum ConferenceState { + none, + stream, + jitsi +} + +@Injectable({ + providedIn: 'root' +}) +export class InteractionService { + private conferenceStateSubject = new BehaviorSubject(ConferenceState.none); + public conferenceStateObservable = this.conferenceStateSubject.asObservable(); + public showLiveConfObservable: Observable; + private get conferenceState(): ConferenceState { + return this.conferenceStateSubject.value; + } + + private isJitsiEnabled: boolean; + private isInCall: boolean; + private isJitsiActive: boolean; + private hasLiveStreamUrl: boolean; + private canSeeLiveStream: boolean; + private showLiveConf: boolean; + + public get isConfStateStream(): Observable { + return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.stream)); + } + + public get isConfStateJitsi(): Observable { + return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.jitsi)); + } + + public get isConfStateNone(): Observable { + return this.conferenceStateObservable.pipe(map(state => state === ConferenceState.none)); + } + + public constructor( + private configService: ConfigService, + private streamService: StreamService, + private rtcService: RtcService, + private callRestrictionService: CallRestrictionService + ) { + this.showLiveConfObservable = this.configService.get('general_system_conference_show'); + + /** + * If you want to somehow simplify this using rxjs merge-map magic or something + * be my guest. + */ + this.streamService.liveStreamUrlObservable.subscribe(url => { + this.hasLiveStreamUrl = !!url?.trim() ?? false; + this.detectDeadState(); + }); + + this.streamService.canSeeLiveStreamObservable.subscribe(canSee => { + this.canSeeLiveStream = canSee; + this.detectDeadState(); + }); + + this.rtcService.isJitsiEnabledObservable.subscribe(enabled => { + this.isJitsiEnabled = enabled; + this.detectDeadState(); + }); + + this.rtcService.isJoinedObservable.subscribe(joined => { + this.isInCall = joined; + this.detectDeadState(); + }); + + this.rtcService.isJitsiActiveObservable.subscribe(isActive => { + this.isJitsiActive = isActive; + this.detectDeadState(); + }); + + this.callRestrictionService.hasToEnterCallObservable.subscribe(() => { + if (!this.isInCall) { + this.enterCall(); + this.rtcService.enterConferenceRoom(); + } + }); + + this.callRestrictionService.hasToLeaveCallObservable.subscribe(() => { + this.viewStream(); + }); + + this.showLiveConfObservable.subscribe(showConf => { + this.showLiveConf = showConf; + this.detectDeadState(); + }); + + this.detectDeadState(); + } + + public async enterCall(): Promise { + if (this.conferenceState !== ConferenceState.jitsi) { + this.setConferenceState(ConferenceState.jitsi); + } + } + + public viewStream(): void { + if (this.conferenceState !== ConferenceState.stream) { + this.setConferenceState(ConferenceState.stream); + } + } + + private setConferenceState(newState: ConferenceState): void { + if (newState !== this.conferenceState) { + this.conferenceStateSubject.next(newState); + } + } + + /** + * this is the "dead" state; you would see the jitsi state; but are not connected + * or the connection is prohibited. If this occurs and a live stream + * becomes available, switch to the stream state + */ + private detectDeadState(): void { + if ( + this.isInCall === undefined || + this.isJitsiActive === undefined || + this.hasLiveStreamUrl === undefined || + this.conferenceState === undefined || + this.canSeeLiveStream === undefined || + this.isJitsiEnabled === undefined + ) { + return; + } + + /** + * most importantly, if there is a call, to not change the state! + */ + if (this.isInCall || this.isJitsiActive) { + return; + } + + if (this.hasLiveStreamUrl && this.canSeeLiveStream) { + this.viewStream(); + } else if (this.showLiveConf && (!this.hasLiveStreamUrl || !this.canSeeLiveStream) && this.isJitsiEnabled) { + this.enterCall(); + } else { + this.setConferenceState(ConferenceState.none); + } + } +} diff --git a/client/src/app/site/interaction/services/rtc.service.spec.ts b/client/src/app/site/interaction/services/rtc.service.spec.ts new file mode 100644 index 000000000..63b0f8b4b --- /dev/null +++ b/client/src/app/site/interaction/services/rtc.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { RtcService } from './rtc.service'; + +describe('RtcService', () => { + let service: RtcService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [E2EImportsModule] }); + service = TestBed.inject(RtcService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/services/rtc.service.ts b/client/src/app/site/interaction/services/rtc.service.ts new file mode 100644 index 000000000..cf7994447 --- /dev/null +++ b/client/src/app/site/interaction/services/rtc.service.ts @@ -0,0 +1,397 @@ +import { ElementRef, Injectable } from '@angular/core'; + +import { StorageMap } from '@ngx-pwa/local-storage'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; + +import { ConstantsService } from 'app/core/core-services/constants.service'; +import { OperatorService } from 'app/core/core-services/operator.service'; +import { ConfigService } from 'app/core/ui-services/config.service'; +import { CallRestrictionService } from './call-restriction.service'; +import { UserMediaPermService } from './user-media-perm.service'; + +export const RTC_LOGGED_STORAGE_KEY = 'rtcIsLoggedIn'; + +interface JitsiMember { + id: string; + displayName: string; +} + +interface ConferenceJoinedResult { + roomName: string; + id: string; + displayName: string; + formattedDisplayName: string; +} + +interface ConferenceMember { + name: string; + focus: boolean; +} + +interface DisplayNameChangeResult { + // Yes, in this case "displayname" really does not have a capital n. Thank you jitsi. + displayname: string; + formattedDisplayName: string; + id: string; +} + +interface MemberKicked { + kicked: { + id: string; + local: boolean; + }; + kicker: { + id: string; + }; +} + +/** + * Jitsi + */ +declare var JitsiMeetExternalAPI: any; + +const configOverwrite = { + startAudioOnly: false, + // allows jitsi on mobile devices + disableDeepLinking: true, + startWithAudioMuted: true, + startWithVideoMuted: true, + useNicks: true, + enableWelcomePage: false, + enableUserRolesBasedOnToken: false, + enableFeaturesBasedOnToken: false, + disableThirdPartyRequests: true, + enableNoAudioDetection: false, + enableNoisyMicDetection: false +}; + +const interfaceConfigOverwrite = { + DISABLE_VIDEO_BACKGROUND: true, + INVITATION_POWERED_BY: false, + DISABLE_JOIN_LEAVE_NOTIFICATIONS: true, + DISABLE_PRESENCE_STATUS: true, + TOOLBAR_ALWAYS_VISIBLE: true, + TOOLBAR_TIMEOUT: 10000000, + TOOLBAR_BUTTONS: [ + 'microphone', + 'camera', + 'desktop', + 'fullscreen', + 'fodeviceselection', + 'profile', + 'chat', + 'recording', + 'livestreaming', + 'etherpad', + 'sharedvideo', + 'settings', + 'videoquality', + 'filmstrip', + 'feedback', + 'stats', + 'shortcuts', + 'tileview', + 'download', + 'help', + 'mute-everyone', + 'hangup' + ] +}; + +export interface JitsiConfig { + JITSI_DOMAIN: string; + JITSI_ROOM_NAME: string; + JITSI_ROOM_PASSWORD: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class RtcService { + private jitsiConfig: JitsiConfig; + private isJitsiEnabledSubject = new BehaviorSubject(false); + public isJitsiEnabledObservable = this.isJitsiEnabledSubject.asObservable(); + + public autoConnect: Observable; + + // JitsiMeet api object + private api: any | null; + private get isJitsiActive(): boolean { + return !!this.api; + } + + private options: Object; + private jitsiNode: ElementRef; + + private actualRoomName: string; + + public isSupportEnabled: Observable; + private connectedToHelpDeskSubject = new BehaviorSubject(false); + public connectedToHelpDesk = this.connectedToHelpDeskSubject.asObservable(); + + public isJitsiActiveInAnotherTab = false; + private isJoinedSubject = new BehaviorSubject(false); + public isJoinedObservable = this.isJoinedSubject.asObservable(); + + private isPasswordSet = false; + + private isJitsiActiveSubject = new BehaviorSubject(false); + public isJitsiActiveObservable = this.isJitsiActiveSubject.asObservable(); + + private get defaultRoomName(): string { + return this.jitsiConfig?.JITSI_ROOM_NAME; + } + + private jitsiMeetUrlSubject = new Subject(); + public jitsiMeetUrl = this.jitsiMeetUrlSubject.asObservable(); + + private members = {}; + private memberSubject = new BehaviorSubject(this.members); + public memberObservableObservable = this.memberSubject.asObservable(); + + private dominantSpeaker: JitsiMember; + private dominantSpeakerSubject = new Subject(); + public dominantSpeakerObservable = this.dominantSpeakerSubject.asObservable(); + + private isMutedSubject = new BehaviorSubject(false); + public isMuted = this.isMutedSubject.asObservable(); + + private canEnterCall: boolean; + public inOtherTab: Observable; + + private showCallDialogSubject = new BehaviorSubject(false); + public showCallDialogObservable = this.showCallDialogSubject.asObservable(); + public set showCallDialog(show: boolean) { + this.showCallDialogSubject.next(show); + } + + public constructor( + constantsService: ConstantsService, + configService: ConfigService, + callRestrictionService: CallRestrictionService, + private userMediaPermService: UserMediaPermService, + private storageMap: StorageMap, + private operator: OperatorService + ) { + this.isSupportEnabled = configService.get('general_system_conference_enable_helpdesk'); + this.autoConnect = configService.get('general_system_conference_auto_connect'); + + constantsService.get('Settings').subscribe(settings => { + this.jitsiConfig = settings; + this.isJitsiEnabledSubject.next(!!settings.JITSI_DOMAIN && !!settings.JITSI_ROOM_NAME); + }); + configService.get('general_system_conference_open_microphone').subscribe(open => { + configOverwrite.startWithAudioMuted = !open; + }); + configService.get('general_system_conference_open_video').subscribe(open => { + configOverwrite.startWithVideoMuted = !open; + }); + callRestrictionService.canEnterCallObservable.subscribe(canEnter => { + this.canEnterCall = canEnter; + }); + + this.inOtherTab = this.storageMap + .watch(RTC_LOGGED_STORAGE_KEY, { type: 'boolean' }) + .pipe(distinctUntilChanged()); + } + + public setJitsiNode(jitsiNode: ElementRef): void { + this.jitsiNode = jitsiNode; + } + + public toggleMute(): void { + if (this.isJitsiActive) { + this.api.executeCommand('toggleAudio'); + } + } + + public async enterSupportRoom(): Promise { + this.connectedToHelpDeskSubject.next(true); + this.actualRoomName = `${this.defaultRoomName}-SUPPORT`; + await this.enterConversation(); + } + + public async enterConferenceRoom(force?: boolean): Promise { + if (!this.canEnterCall) { + return; + } + if (force) { + this.disconnect(); + } + this.connectedToHelpDeskSubject.next(false); + this.actualRoomName = this.defaultRoomName; + await this.enterConversation(); + } + + private setRoomPassword(): void { + if (this.jitsiConfig?.JITSI_ROOM_PASSWORD && !this.isPasswordSet) { + // You can only set the password after the server has recognized that you are + // the moderator. There is no event listener for that. + setTimeout(() => { + this.api.executeCommand('password', this.jitsiConfig?.JITSI_ROOM_PASSWORD); + this.isPasswordSet = true; + }, 1000); + } + } + + private async enterConversation(): Promise { + await this.operator.loaded; + await this.userMediaPermService.requestMediaAccess(); + this.storageMap.set(RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {}); + this.setOptions(); + if (this.api) { + this.api.dispose(); + this.api = undefined; + } + this.api = new JitsiMeetExternalAPI(this.jitsiConfig?.JITSI_DOMAIN, this.options); + this.isJitsiActiveSubject.next(true); + const jitsiName = this.operator.viewUser.getShortName(); + this.api.executeCommand('displayName', jitsiName); + this.loadApiCallbacks(); + } + + private loadApiCallbacks(): void { + this.isMutedSubject.next(configOverwrite.startWithAudioMuted); + + this.api.on('videoConferenceJoined', (info: ConferenceJoinedResult) => { + this.onEnterConference(info); + }); + + this.api.on('participantJoined', (newMember: JitsiMember) => { + this.addMember(newMember); + }); + + this.api.on('participantLeft', (oldMember: { id: string }) => { + this.removeMember(oldMember); + }); + + this.api.on('displayNameChange', (member: DisplayNameChangeResult) => { + this.renameMember(member); + }); + + this.api.on('audioMuteStatusChanged', (isMuted: { muted: boolean }) => { + this.isMutedSubject.next(isMuted.muted); + }); + + this.api.on('readyToClose', () => { + this.stopJitsi(); + }); + + this.api.on('dominantSpeakerChanged', (newSpeaker: { id: string }) => { + this.newDominantSpeaker(newSpeaker.id); + }); + + this.api.on('passwordRequired', () => { + this.setRoomPassword(); + }); + + this.api.on('participantKickedOut', (kicked: MemberKicked) => { + this.onMemberKicked(kicked); + }); + } + + private onEnterConference(info: ConferenceJoinedResult): void { + this.isJoinedSubject.next(true); + this.showCallDialogSubject.next(true); + this.addMember({ displayName: info.displayName, id: info.id }); + this.setRoomPassword(); + } + + private addMember(newMember: JitsiMember): void { + this.members[newMember.id] = { + name: newMember.displayName, + focus: false + } as ConferenceMember; + this.updateMemberSubject(); + } + + private onMemberKicked(kick: MemberKicked): void { + if (kick.kicked.local) { + this.stopJitsi(); + } + } + + private removeMember(oldMember: { id: string }): void { + if (this.members[oldMember.id]) { + delete this.members[oldMember.id]; + this.updateMemberSubject(); + } + } + + private renameMember(member: DisplayNameChangeResult): void { + if (this.members[member.id]) { + this.members[member.id].name = member.displayname; + this.updateMemberSubject(); + } + if (this.dominantSpeaker?.id === member.id) { + this.newDominantSpeaker(member.id); + } + } + + private newDominantSpeaker(newSpeakerId: string): void { + if (this.dominantSpeaker && this.members[this.dominantSpeaker.id]) { + this.members[this.dominantSpeaker.id].focus = false; + } + this.members[newSpeakerId].focus = true; + this.updateMemberSubject(); + this.dominantSpeaker = { + id: newSpeakerId, + displayName: this.members[newSpeakerId].name + }; + this.dominantSpeakerSubject.next(this.dominantSpeaker); + } + + private clearMembers(): void { + this.members = {}; + this.updateMemberSubject(); + } + + private updateMemberSubject(): void { + this.memberSubject.next(this.members); + } + + /** + * https://github.com/jitsi/jitsi-meet/issues/8244 + */ + public enterFullScreen(): void {} + + private disconnect(): void { + if (this.isJitsiActive) { + this.api?.executeCommand('hangup'); + this.api?.dispose(); + this.api = undefined; + } + this.clearMembers(); + this.deleteJitsiLock(); + this.dominantSpeaker = null; + this.dominantSpeakerSubject.next(this.dominantSpeaker); + this.isJoinedSubject.next(false); + this.showCallDialogSubject.next(false); + this.isPasswordSet = false; + } + + public stopJitsi(): void { + this.disconnect(); + this.connectedToHelpDeskSubject.next(false); + this.isJitsiActiveSubject.next(false); + } + + private setOptions(): void { + this.setJitsiMeetUrl(); + this.options = { + roomName: this.actualRoomName, + parentNode: this.jitsiNode.nativeElement, + configOverwrite: configOverwrite, + interfaceConfigOverwrite: interfaceConfigOverwrite + }; + } + + private setJitsiMeetUrl(): void { + this.jitsiMeetUrlSubject.next(`https://${this.jitsiConfig.JITSI_DOMAIN}/${this.actualRoomName}`); + } + + private deleteJitsiLock(): void { + this.storageMap.delete(RTC_LOGGED_STORAGE_KEY).subscribe(); + } +} diff --git a/client/src/app/site/interaction/services/stream.service.spec.ts b/client/src/app/site/interaction/services/stream.service.spec.ts new file mode 100644 index 000000000..c6366546d --- /dev/null +++ b/client/src/app/site/interaction/services/stream.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { StreamService } from './stream.service'; + +describe('StreamService', () => { + let service: StreamService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [E2EImportsModule] }); + service = TestBed.inject(StreamService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/interaction/services/stream.service.ts b/client/src/app/site/interaction/services/stream.service.ts new file mode 100644 index 000000000..61acf6d10 --- /dev/null +++ b/client/src/app/site/interaction/services/stream.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; + +import { StorageMap } from '@ngx-pwa/local-storage'; +import { Observable, Subject } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; + +import { OperatorService, Permission } from 'app/core/core-services/operator.service'; +import { ConfigService } from 'app/core/ui-services/config.service'; + +const STREAM_RUNNING_STORAGE_KEY = 'streamIsRunning'; + +@Injectable({ + providedIn: 'root' +}) +export class StreamService { + public liveStreamUrlObservable: Observable; + + /** + * undefined is controlled behavior, meaning, this property was not + * checked yet. + * Thus, false-checks have to be explicit + */ + public streamLoadedOnceObservable: Observable; + + private isStreamRunningSubject = new Subject(); + public isStreamRunningObservable = this.isStreamRunningSubject.asObservable(); + + private canSeeLiveStreamSubject = new Subject(); + public canSeeLiveStreamObservable = this.canSeeLiveStreamSubject.asObservable(); + + public constructor(private storageMap: StorageMap, operator: OperatorService, configService: ConfigService) { + this.liveStreamUrlObservable = configService.get('general_system_stream_url'); + + this.streamLoadedOnceObservable = this.storageMap + .watch(STREAM_RUNNING_STORAGE_KEY, { type: 'boolean' }) + .pipe(distinctUntilChanged()); + + operator.getUserObservable().subscribe(() => { + this.canSeeLiveStreamSubject.next(operator.hasPerms(Permission.coreCanSeeLiveStream)); + }); + } + + public setStreamingLock(): void { + this.storageMap.set(STREAM_RUNNING_STORAGE_KEY, true).subscribe(() => {}); + } + + public setStreamRunning(running: boolean): void { + this.isStreamRunningSubject.next(running); + } + + public deleteStreamingLock(): void { + /** + * subscriptions are faster than promises. This will fire more reliable + * than converting it to promise first + */ + this.storageMap.delete(STREAM_RUNNING_STORAGE_KEY).subscribe(); + } +} diff --git a/client/src/app/core/ui-services/user-media-perm.service.spec.ts b/client/src/app/site/interaction/services/user-media-perm.service.spec.ts similarity index 100% rename from client/src/app/core/ui-services/user-media-perm.service.spec.ts rename to client/src/app/site/interaction/services/user-media-perm.service.spec.ts diff --git a/client/src/app/core/ui-services/user-media-perm.service.ts b/client/src/app/site/interaction/services/user-media-perm.service.ts similarity index 98% rename from client/src/app/core/ui-services/user-media-perm.service.ts rename to client/src/app/site/interaction/services/user-media-perm.service.ts index 6ccf6ca22..83590d984 100644 --- a/client/src/app/core/ui-services/user-media-perm.service.ts +++ b/client/src/app/site/interaction/services/user-media-perm.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { TranslateService } from '@ngx-translate/core'; -import { OverlayService } from './overlay.service'; +import { OverlayService } from '../../../core/ui-services/overlay.service'; const givePermsMessage = _('Please allow OpenSlides to access your microphone and/or camera'); const accessDeniedMessage = _('Media access is denied'); diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index d3d03bc27..01dfdbeae 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -114,7 +114,8 @@
- + +
diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss index 6aee28f9a..15306f4ac 100644 --- a/client/src/app/site/site.component.scss +++ b/client/src/app/site/site.component.scss @@ -136,9 +136,16 @@ mat-sidenav-container { .toolbars { display: flex; - + position: fixed; + bottom: 0; + /** + * right 0 would overlap the browser scrollbar + */ + right: 20px; + z-index: 99; + pointer-events: none; * { - margin-top: auto; + pointer-events: initial; } } diff --git a/client/src/app/site/site.module.ts b/client/src/app/site/site.module.ts index 289add0f5..166b961d0 100644 --- a/client/src/app/site/site.module.ts +++ b/client/src/app/site/site.module.ts @@ -2,11 +2,12 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { SharedModule } from 'app/shared/shared.module'; +import { InteractionModule } from './interaction/interaction.module'; import { SiteRoutingModule } from './site-routing.module'; import { SiteComponent } from './site.component'; @NgModule({ - imports: [CommonModule, SharedModule, SiteRoutingModule], + imports: [CommonModule, SharedModule, SiteRoutingModule, InteractionModule], declarations: [SiteComponent] }) export class SiteModule {} diff --git a/client/src/assets/styles/component-themes.scss b/client/src/assets/styles/component-themes.scss index 1812af7ae..35293fbae 100644 --- a/client/src/assets/styles/component-themes.scss +++ b/client/src/assets/styles/component-themes.scss @@ -42,7 +42,6 @@ $narrow-spacing: ( @import '~app/site/motions/modules/motion-poll/motion-poll-detail/motion-poll-detail.component.scss-theme.scss'; @import '~app/site/assignments/modules/assignment-poll/components/assignment-poll-detail/assignment-poll-detail-component.scss-theme.scss'; @import '~app/shared/components/progress-snack-bar/progress-snack-bar.component.scss-theme.scss'; -@import '~app/shared/components/jitsi/jitsi.component.scss-theme.scss'; @import '~app/shared/components/list-view-table/list-view-table.component.scss-theme.scss'; @import '~app/site/common/components/user-statistics/user-statistics.component.scss-theme.scss'; @import '~app/site/login/components/login-wrapper/login-wrapper.component.scss-theme.scss'; @@ -69,7 +68,6 @@ $narrow-spacing: ( @include os-motion-poll-detail-style($theme); @include os-assignment-poll-detail-style($theme); @include os-progress-snack-bar-style($theme); - @include os-jitsi-theme($theme); @include os-list-view-table-theme($theme); @include os-user-statistics-style($theme); @include os-login-wrapper-theme($theme); diff --git a/client/src/assets/styles/global-components-style.scss b/client/src/assets/styles/global-components-style.scss index e2a0904b6..0ff7a9a60 100644 --- a/client/src/assets/styles/global-components-style.scss +++ b/client/src/assets/styles/global-components-style.scss @@ -35,10 +35,32 @@ color: mat-color(if($is-dark-theme, $accent, $primary)); } - //custom table header for search button, filtering and more. Used in ListViews + /** + * normal current mat bg color with primary text color. + * important is required to overwrite materials default + * button color + */ .custom-table-header, - .background--default { - background: mat-color($background, background); + .background-default { + background: mat-color($background, background) !important; + } + + .fake-disabled { + background: mat-color($background, unselected-chip) !important; + opacity: 1 !important; + + .mat-button-wrapper { + .mat-icon { + color: mat-color($foreground, disabled-button) !important; + svg path { + fill: mat-color($foreground, disabled-button) !important; + } + } + } + } + + .background-default[disabled] { + @extend .fake-disabled; } .underline { @@ -192,6 +214,11 @@ color: mat-color($primary, default-contrast) !important; } + .background-card { + background: mat-color($background, card); + color: mat-color($foreground, text); + } + .primary-foreground { color: mat-color($primary); } @@ -199,4 +226,10 @@ .accent-foreground { color: mat-color($accent); } + + .svg-primary { + svg path { + fill: mat-color($primary) !important; + } + } } diff --git a/server/openslides/core/config_variables.py b/server/openslides/core/config_variables.py index 58bad5d61..08f7a5a62 100644 --- a/server/openslides/core/config_variables.py +++ b/server/openslides/core/config_variables.py @@ -164,7 +164,7 @@ def get_config_variables(): name="general_system_stream_poster", default_value="", label="Livestream poster image url", - help_text="Shows if livestream is not started. Recommended image format: 500x200px, PNG or JPG", + help_text="Shows if livestream is not started. Recommended image format: 500x280px, PNG or JPG", weight=147, subgroup="Live conference", )