From 26ac618ddf78a0c7d43201ed41699a5602d8ad91 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 10 Nov 2020 15:25:52 +0100 Subject: [PATCH] Show "please grant mic access" info If the browser does not have the permission to use the microphone, OpenSlides will show a spinner to inform the user to grant mic access --- .../user-media-perm.service.spec.ts | 18 +++ .../ui-services/user-media-perm.service.ts | 110 ++++++++++++++++++ .../components/jitsi/jitsi.component.ts | 37 ++++-- server/openslides/core/config_variables.py | 20 +++- 4 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 client/src/app/core/ui-services/user-media-perm.service.spec.ts create mode 100644 client/src/app/core/ui-services/user-media-perm.service.ts diff --git a/client/src/app/core/ui-services/user-media-perm.service.spec.ts b/client/src/app/core/ui-services/user-media-perm.service.spec.ts new file mode 100644 index 000000000..53e477d23 --- /dev/null +++ b/client/src/app/core/ui-services/user-media-perm.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { UserMediaPermService } from './user-media-perm.service'; + +describe('UserMediaPermService', () => { + let service: UserMediaPermService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [E2EImportsModule] }); + service = TestBed.inject(UserMediaPermService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/ui-services/user-media-perm.service.ts b/client/src/app/core/ui-services/user-media-perm.service.ts new file mode 100644 index 000000000..7c049e7a7 --- /dev/null +++ b/client/src/app/core/ui-services/user-media-perm.service.ts @@ -0,0 +1,110 @@ +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; + +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { OverlayService } from './overlay.service'; + +const givePermsMessage = _('Please allow OpenSlides to access your microphone and/or camera'); +const accessDeniedMessage = _('Media access is denied'); +const noMicMessage = _('Your device has no Microphone'); +@Injectable({ + providedIn: 'root' +}) +export class UserMediaPermService { + private hasAudioDevice: boolean; + private hasVideoDevice: boolean; + + public constructor(private translate: TranslateService, private overlayService: OverlayService) {} + + public async requestMediaAccess(): Promise { + await this.detectAvailableDevices(); + + if (!this.hasAudioDevice) { + throw new Error(noMicMessage); + } + const hasMediaAccess: PermissionState | null = await this.detectPermStatus(); + if (!hasMediaAccess || hasMediaAccess === 'prompt') { + await this.tryMediaAccess(); + } else if (hasMediaAccess === 'denied') { + this.throwPermError(); + } + } + + /** + * `navigator.permissions.query` does only work in chrome + * This function detects if this method works at all. + * If it does not work, we try to access the media anyways without + * detecting the set permission beforehand. + * The negative result would be, that the user sees the + * overlay for a very short time. + * This cannot be avoided, but rather solves itself if more browsers + * start to support the given media query + */ + private async detectPermStatus(): Promise { + try { + const micPermStatus = await navigator.permissions.query({ name: 'microphone' }); + const camPermStatus = await navigator.permissions.query({ name: 'camera' }); + + if (!this.hasVideoDevice || micPermStatus.state === camPermStatus.state) { + return micPermStatus.state; + } else if (micPermStatus.state === 'denied' || camPermStatus.state === 'denied') { + return 'denied'; + } else { + return null; + } + } catch (e) { + return null; + } + } + + private async detectAvailableDevices(): Promise { + const mediaDevices = await navigator.mediaDevices.enumerateDevices(); + this.hasAudioDevice = !!mediaDevices.find(device => device.kind === 'audioinput'); + this.hasVideoDevice = !!mediaDevices.find(device => device.kind === 'videoinput'); + } + + private async tryMediaAccess(): Promise { + this.showAwaitPermInfo(); + try { + const stream: MediaStream = await navigator.mediaDevices.getUserMedia({ + audio: this.hasAudioDevice, + video: this.hasVideoDevice + }); + this.hideAwaitPermInfo(); + + if (stream) { + stream.getTracks().forEach(track => { + track.stop(); + }); + } + } catch (e) { + if (e instanceof DOMException) { + if (e.message === 'Permission denied') { + this.throwPermError(); + } + } else { + this.throwPermError(e); + } + } + } + + private throwPermError(error: Error = new Error(accessDeniedMessage)): void { + this.hideAwaitPermInfo(); + console.error(error); + throw new Error(this.translate.instant(error.message)); + } + + /** + * Show: + * Please allow OpenSlides to use your microphone + */ + private showAwaitPermInfo(): void { + this.overlayService.showSpinner(this.translate.instant(givePermsMessage), true); + } + + private hideAwaitPermInfo(): void { + this.overlayService.hideSpinner(); + } +} diff --git a/client/src/app/shared/components/jitsi/jitsi.component.ts b/client/src/app/shared/components/jitsi/jitsi.component.ts index 5a361aae8..5265fb9ba 100644 --- a/client/src/app/shared/components/jitsi/jitsi.component.ts +++ b/client/src/app/shared/components/jitsi/jitsi.component.ts @@ -12,6 +12,7 @@ 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 { 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'; @@ -80,6 +81,8 @@ export class JitsiComponent extends BaseViewComponentDirective implements OnInit public enableJitsi: boolean; private autoconnect: boolean; + private startWithMicMuted: boolean; + private startWithVideoMuted: boolean; private roomName: string; private roomPassword: string; private jitsiDomain: string; @@ -184,8 +187,8 @@ export class JitsiComponent extends BaseViewComponentDirective implements OnInit startAudioOnly: false, // allows jitsi on mobile devices disableDeepLinking: true, - startWithAudioMuted: true, - startWithVideoMuted: true, + startWithAudioMuted: this.startWithMicMuted, + startWithVideoMuted: this.startWithVideoMuted, useNicks: true, enableWelcomePage: false, enableUserRolesBasedOnToken: false, @@ -236,7 +239,8 @@ export class JitsiComponent extends BaseViewComponentDirective implements OnInit private userRepo: UserRepositoryService, private constantsService: ConstantsService, private configService: ConfigService, - private closService: CurrentListOfSpeakersService + private closService: CurrentListOfSpeakersService, + private userMediaPermService: UserMediaPermService ) { super(titleService, translate, snackBar); } @@ -340,6 +344,14 @@ export class JitsiComponent extends BaseViewComponentDirective implements OnInit this.configService.get('general_system_stream_url').subscribe(url => { this.videoStreamUrl = url; this.configsLoaded.resolve(); + }), + this.configService.get('general_system_conference_open_microphon').subscribe(open => { + this.startWithMicMuted = !open; + console.log('this.startWithMicMuted ', this.startWithMicMuted); + }), + this.configService.get('general_system_conference_open_video').subscribe(open => { + this.startWithVideoMuted = !open; + console.log('this.startWithVideoMuted ', this.startWithVideoMuted); }) ); @@ -393,14 +405,19 @@ export class JitsiComponent extends BaseViewComponentDirective implements OnInit public async enterConversation(): Promise { await this.operator.loaded; - this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {}); - this.setConferenceState(ConferenceState.jitsi); - this.setOptions(); - this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options); + try { + await this.userMediaPermService.requestMediaAccess(); + this.storageMap.set(this.RTC_LOGGED_STORAGE_KEY, true).subscribe(() => {}); + this.setConferenceState(ConferenceState.jitsi); + this.setOptions(); + this.api = new JitsiMeetExternalAPI(this.jitsiDomain, this.options); - const jitsiname = this.userRepo.getShortName(this.operator.user); - this.api.executeCommand('displayName', jitsiname); - this.loadApiCallbacks(); + const jitsiname = this.userRepo.getShortName(this.operator.user); + this.api.executeCommand('displayName', jitsiname); + this.loadApiCallbacks(); + } catch (e) { + this.raiseError(e); + } } private loadApiCallbacks(): void { diff --git a/server/openslides/core/config_variables.py b/server/openslides/core/config_variables.py index abfc262ab..eabb51306 100644 --- a/server/openslides/core/config_variables.py +++ b/server/openslides/core/config_variables.py @@ -121,13 +121,31 @@ def get_config_variables(): subgroup="Live conference", ) + yield ConfigVariable( + name="general_system_conference_open_microphone", + default_value=False, + input_type="boolean", + label="Automatically open the microphone for new conference speakers", + weight=143, + subgroup="Live conference", + ) + + yield ConfigVariable( + name="general_system_conference_open_video", + default_value=False, + input_type="boolean", + label="Automatically open the web cam for new conference speakers", + weight=144, + subgroup="Live conference", + ) + yield ConfigVariable( name="general_system_conference_auto_connect_next_speakers", default_value=0, input_type="integer", label="Number of next speakers automatically connecting to the live conference", help_text="Live conference has to be active. Choose 0 to disable auto connect.", - weight=143, + weight=145, subgroup="Live conference", )