Skip to content

Commit

Permalink
VIH-10689 Pause Resume audio recording (#2271)
Browse files Browse the repository at this point in the history
* Created a new audio-recording.service.ts and shifted judge waiting room wowza logic to there. Added pause /resume button to private consultation room controls component

* New event hub action created

* Added new event hup action

* lint

* test

* lint

* Sonarcloud complaints

* lint

* typo

* linting ...again

* AudioRecordingPaused event hub changes

* AudioRecordingPaused event hub changes

* Naming convention updated

* updated params

* Typo

* Addec condownComplete and wowzaParticipants to the conference state store

* added deletion of wowza participant to store

* Updated audioRecording event to take bool state

* Added conference store to audio-recording.service.ts

* VIH-10689 only display wowza pause button when wowza is connected

* Updated tests

* missing property

* code coverage

* Additional test coverage

* additional conference store update

* test name

* restartactioned update added back in

* added distinctUntilChanged

---------

Co-authored-by: Shaed Parkar <[email protected]>
  • Loading branch information
will-craig and Shaed Parkar authored Oct 28, 2024
1 parent a3453ec commit 28e1015
Show file tree
Hide file tree
Showing 22 changed files with 661 additions and 333 deletions.
9 changes: 6 additions & 3 deletions VideoWeb/VideoWeb.Common/RequestTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ public void Initialize(ITelemetry telemetry)

private bool IsReadableBadRequest(Microsoft.ApplicationInsights.DataContracts.RequestTelemetry telemetry)
{
return _httpContextAccessor.HttpContext.Request.Body.CanRead
&& telemetry.ResponseCode == "400";
if (_httpContextAccessor?.HttpContext == null)
{
return false;
}
return _httpContextAccessor.HttpContext.Request.Body.CanRead && telemetry.ResponseCode == "400";
}
}
}
}
3 changes: 1 addition & 2 deletions VideoWeb/VideoWeb.EventHub/Hub/EventHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,7 @@ public async Task SendAudioRestartAction(Guid conferenceId, Guid participantId)
}
catch (Exception ex)
{
logger.LogError(ex, "Error occured when updating other hosts in conference {ConferenceId}",
conferenceId);
logger.LogError(ex, "Error occured when updating other hosts in conference {ConferenceId}", conferenceId);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { AudioRecordingService } from './audio-recording.service';
import { VideoCallService } from '../waiting-space/services/video-call.service';
import { EventsService } from './events.service';
import { Subject } from 'rxjs';
import { AudioRecordingPauseStateMessage } from '../shared/models/audio-recording-pause-state-message';
import { VHPexipParticipant } from '../waiting-space/store/models/vh-conference';
import * as ConferenceSelectors from '../waiting-space/store/selectors/conference.selectors';
import {
globalConference,
initAllWRDependencies,
mockConferenceStore
} from '../waiting-space/waiting-room-shared/tests/waiting-room-base-setup';

describe('AudioRecordingService', () => {
let service: AudioRecordingService;
let videoCallServiceSpy: jasmine.SpyObj<VideoCallService>;
let eventServiceSpy: jasmine.SpyObj<EventsService>;
const audioStoppedMock$ = new Subject<AudioRecordingPauseStateMessage>();
const pexipParticipant: VHPexipParticipant = {
isRemoteMuted: false,
isSpotlighted: false,
handRaised: false,
pexipDisplayName: 'vh-wowza',
uuid: 'unique-identifier',
callTag: 'call-tag',
isAudioOnlyCall: true,
isVideoCall: false,
protocol: 'protocol-type',
receivingAudioMix: 'audio-mix',
sentAudioMixes: []
};

beforeEach(() => {
mockConferenceStore.overrideSelector(ConferenceSelectors.getWowzaParticipant, pexipParticipant);
videoCallServiceSpy = jasmine.createSpyObj('VideoCallService', [
'disconnectWowzaAgent',
'connectWowzaAgent',
'getWowzaAgentConnectionState'
]);
eventServiceSpy = jasmine.createSpyObj('EventsService', ['sendAudioRecordingPaused', 'getAudioPaused']);
eventServiceSpy.getAudioPaused.and.returnValue(audioStoppedMock$);

const loggerMock = jasmine.createSpyObj('Logger', ['debug']);

service = new AudioRecordingService(loggerMock, videoCallServiceSpy, eventServiceSpy, mockConferenceStore);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should get audio pause state', done => {
service.getAudioRecordingPauseState().subscribe(isPaused => {
expect(isPaused).toBeTrue();
done();
});
audioStoppedMock$.next({ conferenceId: globalConference.id, pauseState: true });
});

it('should get Wowza agent connection state', done => {
const participant: VHPexipParticipant = { uuid: 'wowzaUUID', isAudioOnlyCall: false } as VHPexipParticipant;
mockConferenceStore.overrideSelector(ConferenceSelectors.getWowzaParticipant, participant);

service.getWowzaAgentConnectionState().subscribe(isConnected => {
expect(isConnected).toBeFalse();
done();
});

mockConferenceStore.refreshState();
});

describe('functions', () => {
it('should stop recording', async () => {
service.conference = { id: 'conferenceId' } as any;
service.wowzaAgent = { uuid: 'wowzaUUID' } as any;
await service.stopRecording();
expect(eventServiceSpy.sendAudioRecordingPaused).toHaveBeenCalledWith('conferenceId', true);
expect(videoCallServiceSpy.disconnectWowzaAgent).toHaveBeenCalledWith('wowzaUUID');
});

describe('reconnectToWowza', () => {
it('should reconnect to Wowza', async () => {
const failedToConnectCallback = jasmine.createSpy('failedToConnectCallback');
service.conference = { id: 'conferenceId', audioRecordingIngestUrl: 'ingestUrl' } as any;
videoCallServiceSpy.connectWowzaAgent.and.callFake((url, callback) => {
callback({ status: 'success', result: ['newUUID'] });
});

await service.reconnectToWowza(failedToConnectCallback);
expect(service.restartActioned).toBeTrue();
expect(videoCallServiceSpy.connectWowzaAgent).toHaveBeenCalledWith('ingestUrl', jasmine.any(Function));
expect(eventServiceSpy.sendAudioRecordingPaused).toHaveBeenCalledWith('conferenceId', false);
expect(failedToConnectCallback).not.toHaveBeenCalled();
});

it('should call failedToConnectCallback if reconnect to Wowza fails', async () => {
const failedToConnectCallback = jasmine.createSpy('failedToConnectCallback');
service.conference = { id: 'conferenceId', audioRecordingIngestUrl: 'ingestUrl' } as any;
videoCallServiceSpy.connectWowzaAgent.and.callFake((url, callback) => {
callback({ status: 'failure' });
});

await service.reconnectToWowza(failedToConnectCallback);
expect(failedToConnectCallback).toHaveBeenCalled();
});

it('should clean up dial out connections', () => {
service.dialOutUUID = ['uuid1', 'uuid2'];
service.cleanupDialOutConnections();
expect(videoCallServiceSpy.disconnectWowzaAgent).toHaveBeenCalledWith('uuid1');
expect(videoCallServiceSpy.disconnectWowzaAgent).toHaveBeenCalledWith('uuid2');
expect(service.dialOutUUID.length).toBe(0);
});
});

it('should clean up subscriptions on destroy', () => {
const onDestroySpy = spyOn(service['onDestroy$'], 'next');
const onDestroyCompleteSpy = spyOn(service['onDestroy$'], 'complete');
service.cleanupSubscriptions();
expect(onDestroySpy).toHaveBeenCalled();
expect(onDestroyCompleteSpy).toHaveBeenCalled();
});
});

afterAll(() => {
mockConferenceStore.resetSelectors();
});

beforeAll(() => {
initAllWRDependencies();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { VideoCallService } from '../waiting-space/services/video-call.service';
import { EventsService } from './events.service';
import { Logger } from './logging/logger-base';
import { AudioRecordingPauseStateMessage } from '../shared/models/audio-recording-pause-state-message';
import { Store } from '@ngrx/store';
import { ConferenceState } from '../waiting-space/store/reducers/conference.reducer';
import * as ConferenceSelectors from '../waiting-space/store/selectors/conference.selectors';
import { VHConference, VHPexipParticipant } from '../waiting-space/store/models/vh-conference';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';

@Injectable({
providedIn: 'root'
})
export class AudioRecordingService {
loggerPrefix = '[AudioRecordingService]';
dialOutUUID = [];
restartActioned: boolean;
conference: VHConference;
wowzaAgent: VHPexipParticipant;

private readonly audioStopped$: Subject<boolean> = new Subject<boolean>();
private readonly wowzaAgentConnection$ = new Subject<boolean>();
private readonly onDestroy$ = new Subject<void>();

constructor(
private readonly logger: Logger,
private readonly videoCallService: VideoCallService,
private readonly eventService: EventsService,
conferenceStore: Store<ConferenceState>
) {
conferenceStore
.select(ConferenceSelectors.getActiveConference)
.pipe(takeUntil(this.onDestroy$))
.subscribe(conference => {
this.conference = conference;
});

conferenceStore
.select(ConferenceSelectors.getWowzaParticipant)
.pipe(
takeUntil(this.onDestroy$),
distinctUntilChanged((prev, curr) => prev?.uuid === curr?.uuid)
)
.subscribe(participant => {
if (participant) {
this.dialOutUUID = [...new Set([...this.dialOutUUID, participant?.uuid])];
}
this.wowzaAgent = participant;
if (participant?.isAudioOnlyCall) {
this.wowzaAgentConnection$.next(true);
this.restartActioned = false;
} else {
this.wowzaAgentConnection$.next(false);
}
});

this.eventService.getAudioPaused().subscribe(async (message: AudioRecordingPauseStateMessage) => {
if (this.conference.id === message.conferenceId) {
this.audioStopped$.next(message.pauseState);
}
});
}

getWowzaAgentConnectionState(): Observable<boolean> {
return this.wowzaAgentConnection$.asObservable();
}

getAudioRecordingPauseState(): Observable<boolean> {
return this.audioStopped$.asObservable();
}

async stopRecording() {
await this.eventService.sendAudioRecordingPaused(this.conference.id, true);
this.videoCallService.disconnectWowzaAgent(this.wowzaAgent.uuid);
this.dialOutUUID = this.dialOutUUID.filter(uuid => uuid !== this.wowzaAgent.uuid);
}

async reconnectToWowza(failedToConnectCallback: Function) {
this.restartActioned = true;
this.cleanupDialOutConnections();
this.videoCallService.connectWowzaAgent(this.conference.audioRecordingIngestUrl, async dialOutToWowzaResponse => {
if (dialOutToWowzaResponse.status === 'success') {
await this.eventService.sendAudioRecordingPaused(this.conference.id, false);
} else {
failedToConnectCallback();
}
});
}

cleanupDialOutConnections() {
this.logger.debug(`${this.loggerPrefix} Cleaning up dial out connections, if any {dialOutUUID: ${this.dialOutUUID}}`);
this.dialOutUUID?.forEach(uuid => {
this.videoCallService.disconnectWowzaAgent(uuid);
});
this.dialOutUUID = [];
}

cleanupSubscriptions() {
this.onDestroy$.next();
this.onDestroy$.complete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export class EventsService {

CountdownFinished: (conferenceId: string) => {
this.logger.debug('[EventsService] - CountdownFinished received', conferenceId);
this.store.dispatch(ConferenceActions.countdownComplete({ conferenceId }));
this.hearingCountdownCompleteSubject.next(conferenceId);
},

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Subject } from 'rxjs';

import { AudioRecordingService } from '../../services/audio-recording.service';
import { VHConference, VHPexipParticipant } from '../../waiting-space/store/models/vh-conference';

const mockConference: VHConference = {
id: '',
audioRecordingIngestUrl: '',
caseName: '',
caseNumber: '',
duration: 0,
endpoints: undefined,
isVenueScottish: false,
participants: undefined,
scheduledDateTime: undefined,
status: undefined,
supplier: undefined
};
const mockWowzaAgent: VHPexipParticipant = {
handRaised: false,
isAudioOnlyCall: true,
isRemoteMuted: false,
isSpotlighted: false,
isVideoCall: false,
pexipDisplayName: 'vh-wowza',
protocol: '',
receivingAudioMix: '',
sentAudioMixes: undefined,
uuid: 'wowzaUUID',
callTag: 'callTag'
};

const getWowzaAgentConnectionState$ = new Subject<boolean>();
const getAudioRecordingPauseState$ = new Subject<boolean>();

export const audioRecordingServiceSpy = jasmine.createSpyObj<AudioRecordingService>(
'AudioRecordingService',
[
'getWowzaAgentConnectionState',
'getAudioRecordingPauseState',
'stopRecording',
'reconnectToWowza',
'cleanupDialOutConnections',
'cleanupSubscriptions'
],
{
conference: mockConference,
wowzaAgent: mockWowzaAgent,
dialOutUUID: [],
restartActioned: false,
loggerPrefix: '[AudioRecordingService]'
}
);

audioRecordingServiceSpy.getWowzaAgentConnectionState.and.returnValue(getWowzaAgentConnectionState$.asObservable());
audioRecordingServiceSpy.getAudioRecordingPauseState.and.returnValue(getAudioRecordingPauseState$.asObservable());
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { ConferenceState, initialState as initialConferenceState } from '../stor
import { createMockStore, MockStore } from '@ngrx/store/testing';
import { ConferenceActions } from '../store/actions/conference.actions';
import { take } from 'rxjs/operators';
import { NotificationToastrService } from '../services/notification-toastr.service';
import { audioRecordingServiceSpy } from '../../testing/mocks/mock-audio-recording.service';

describe('HearingControlsBaseComponent', () => {
const participantOneId = Guid.create().toString();
Expand Down Expand Up @@ -107,6 +109,7 @@ describe('HearingControlsBaseComponent', () => {
let clientSettingsResponse: ClientSettingsResponse;
let videoControlServiceSpy: jasmine.SpyObj<VideoControlService>;
let videoControlCacheSpy: jasmine.SpyObj<VideoControlCacheService>;
let notificationToastrServiceSpy: jasmine.SpyObj<NotificationToastrService>;

beforeEach(() => {
const initialState = initialConferenceState;
Expand Down Expand Up @@ -157,6 +160,7 @@ describe('HearingControlsBaseComponent', () => {

launchDarklyServiceSpy.getFlag.withArgs(FEATURE_FLAGS.wowzaKillButton, false).and.returnValue(of(true));
launchDarklyServiceSpy.getFlag.withArgs(FEATURE_FLAGS.vodafone, false).and.returnValue(of(false));
notificationToastrServiceSpy = jasmine.createSpyObj('NotificationToastrService', ['showError']);

component = new PrivateConsultationRoomControlsComponent(
videoCallService,
Expand All @@ -172,7 +176,9 @@ describe('HearingControlsBaseComponent', () => {
videoControlCacheSpy,
launchDarklyServiceSpy,
focusService,
mockStore
mockStore,
audioRecordingServiceSpy,
notificationToastrServiceSpy
);
conference = new ConferenceTestData().getConferenceNow();
component.participant = globalParticipant;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ <h1 #roomTitleLabel class="room-title-label">{{ getCaseNameAndNumber() }}</h1>
[isSupportedBrowserForNetworkHealth]="isSupportedBrowserForNetworkHealth"
[showConsultationControls]="showConsultationControls"
[unreadMessageCount]="unreadMessageCount"
[wowzaUUID]="wowzaAgent?.uuid"
[isChatVisible]="isChatVisible"
[areParticipantsVisible]="areParticipantsVisible"
(leaveConsultation)="leaveConsultation()"
Expand Down
Loading

0 comments on commit 28e1015

Please sign in to comment.