Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: separate API for enabled and muted states #72

Merged
merged 3 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions src/media/local-stream.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { WebrtcCoreError } from '../errors';
import { createMockedStream } from '../util/test-utils';
import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream';
import { StreamEventNames } from './stream';

/**
* A dummy LocalStream implementation, so we can instantiate it for testing.
Expand All @@ -16,27 +15,42 @@ describe('LocalStream', () => {
localStream = new TestLocalStream(mockStream);
});

describe('setMuted', () => {
describe('constructor', () => {
it('should add the correct event handlers on the track', () => {
expect.assertions(4);

const addEventListenerSpy = jest.spyOn(mockStream.getTracks()[0], 'addEventListener');

expect(addEventListenerSpy).toHaveBeenCalledTimes(3);
expect(addEventListenerSpy).toHaveBeenCalledWith('ended', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('mute', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('unmute', expect.anything());
});
});

describe('setUserMuted', () => {
let emitSpy: jest.SpyInstance;

beforeEach(() => {
localStream = new TestLocalStream(mockStream);
emitSpy = jest.spyOn(localStream[StreamEventNames.MuteStateChange], 'emit');
emitSpy = jest.spyOn(localStream[LocalStreamEventNames.UserMuteStateChange], 'emit');
});

it('should change the input track enabled state and fire an event', () => {
expect.assertions(6);
expect.assertions(8);

// Simulate the default state of the track's enabled state.
mockStream.getTracks()[0].enabled = true;

localStream.setMuted(true);
localStream.setUserMuted(true);
expect(mockStream.getTracks()[0].enabled).toBe(false);
expect(localStream.userMuted).toBe(true);
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenLastCalledWith(true);

localStream.setMuted(false);
localStream.setUserMuted(false);
expect(mockStream.getTracks()[0].enabled).toBe(true);
expect(localStream.userMuted).toBe(false);
expect(emitSpy).toHaveBeenCalledTimes(2);
expect(emitSpy).toHaveBeenLastCalledWith(false);
});
Expand All @@ -47,21 +61,7 @@ describe('LocalStream', () => {
// Simulate the default state of the track's enabled state.
mockStream.getTracks()[0].enabled = true;

localStream.setMuted(false);
expect(emitSpy).toHaveBeenCalledTimes(0);
});

it('should not fire an event if the track has been muted by the browser', () => {
expect.assertions(2);

// Simulate the default state of the track's enabled state.
mockStream.getTracks()[0].enabled = true;

// Simulate the track being muted by the browser.
Object.defineProperty(mockStream.getTracks()[0], 'muted', { value: true });

localStream.setMuted(true);
expect(mockStream.getTracks()[0].enabled).toBe(false);
localStream.setUserMuted(false);
expect(emitSpy).toHaveBeenCalledTimes(0);
});
});
Expand Down
100 changes: 78 additions & 22 deletions src/media/local-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import { Stream, StreamEventNames } from './stream';
export type TrackEffect = BaseEffect;

export enum LocalStreamEventNames {
UserMuteStateChange = 'user-mute-state-change',
SystemMuteStateChange = 'system-mute-state-change',
ConstraintsChange = 'constraints-change',
OutputTrackChange = 'output-track-change',
EffectAdded = 'effect-added',
}

interface LocalStreamEvents {
[LocalStreamEventNames.UserMuteStateChange]: TypedEvent<(muted: boolean) => void>;
[LocalStreamEventNames.SystemMuteStateChange]: TypedEvent<(muted: boolean) => void>;
[LocalStreamEventNames.ConstraintsChange]: TypedEvent<() => void>;
[LocalStreamEventNames.OutputTrackChange]: TypedEvent<(track: MediaStreamTrack) => void>;
[LocalStreamEventNames.EffectAdded]: TypedEvent<(effect: TrackEffect) => void>;
Expand All @@ -22,6 +26,10 @@ interface LocalStreamEvents {
* A stream which originates on the local device.
*/
abstract class _LocalStream extends Stream {
[LocalStreamEventNames.UserMuteStateChange] = new TypedEvent<(muted: boolean) => void>();

[LocalStreamEventNames.SystemMuteStateChange] = new TypedEvent<(muted: boolean) => void>();

[LocalStreamEventNames.ConstraintsChange] = new TypedEvent<() => void>();

[LocalStreamEventNames.OutputTrackChange] = new TypedEvent<(track: MediaStreamTrack) => void>();
Expand All @@ -45,6 +53,51 @@ abstract class _LocalStream extends Stream {
constructor(stream: MediaStream) {
super(stream);
this.inputStream = stream;
this.handleTrackMutedBySystem = this.handleTrackMutedBySystem.bind(this);
this.handleTrackUnmutedBySystem = this.handleTrackUnmutedBySystem.bind(this);
this.addTrackHandlersForLocalStreamEvents(this.inputTrack);
}

/**
* Handler which is called when a track's mute event fires.
*/
private handleTrackMutedBySystem(): void {
this[LocalStreamEventNames.SystemMuteStateChange].emit(true);
}

/**
* Handler which is called when a track's unmute event fires.
*/
private handleTrackUnmutedBySystem(): void {
this[LocalStreamEventNames.SystemMuteStateChange].emit(false);
}

/**
* Helper function to add event handlers to a MediaStreamTrack. See
* {@link Stream.addTrackHandlersForStreamEvents} for why this is useful.
*
* @param track - The MediaStreamTrack.
*/
private addTrackHandlersForLocalStreamEvents(track: MediaStreamTrack): void {
track.addEventListener('mute', this.handleTrackMutedBySystem);
track.addEventListener('unmute', this.handleTrackUnmutedBySystem);
}

/**
* @inheritdoc
*/
protected addTrackHandlers(track: MediaStreamTrack): void {
super.addTrackHandlers(track);
this.addTrackHandlersForLocalStreamEvents(track);
}

/**
* @inheritdoc
*/
protected removeTrackHandlers(track: MediaStreamTrack): void {
super.removeTrackHandlers(track);
track.removeEventListener('mute', this.handleTrackMutedBySystem);
track.removeEventListener('unmute', this.handleTrackUnmutedBySystem);
}

/**
Expand All @@ -58,45 +111,48 @@ abstract class _LocalStream extends Stream {
}

/**
* @inheritdoc
* Check whether or not this stream is muted. This considers both whether the stream has been
* muted by the user (see {@link userMuted}) and whether the stream has been muted by the system
* (see {@link systemMuted}).
*
* @returns True if the stream is muted, false otherwise.
*/
protected handleTrackMuted() {
if (this.inputTrack.enabled) {
super.handleTrackMuted();
}
get muted(): boolean {
return this.userMuted || this.systemMuted;
}

/**
* @inheritdoc
* Check whether or not this stream has been muted by the user. This is equivalent to checking the
* MediaStreamTrack "enabled" state.
*
* @returns True if the stream has been muted by the user, false otherwise.
*/
protected handleTrackUnmuted() {
if (this.inputTrack.enabled) {
super.handleTrackUnmuted();
}
get userMuted(): boolean {
return !this.inputTrack.enabled;
}

/**
* @inheritdoc
* Check whether or not this stream has been muted by the user. This is equivalent to checking the
* MediaStreamTrack "muted" state.
*
* @returns True if the stream has been muted by the system, false otherwise.
*/
get muted(): boolean {
// Calls to `setMuted` will only affect the "enabled" state, but there are specific cases in
// which the browser may mute the track, which will affect the "muted" state but not the
// "enabled" state, e.g. minimizing a shared window or unplugging a shared screen.
return !this.inputTrack.enabled || this.inputTrack.muted;
get systemMuted(): boolean {
return this.inputTrack.muted;
}

/**
* Set the mute state of this stream.
* Set the user mute state of this stream.
*
* Note: This sets the user-toggled mute state, equivalent to changing the "enabled" state of the
* track. It is separate from the system-toggled mute state.
*
* @param isMuted - True to mute, false to unmute.
*/
setMuted(isMuted: boolean): void {
setUserMuted(isMuted: boolean): void {
if (this.inputTrack.enabled === isMuted) {
this.inputTrack.enabled = !isMuted;
// setting `enabled` will not automatically emit MuteStateChange, so we emit it here
if (!this.inputTrack.muted) {
this[StreamEventNames.MuteStateChange].emit(isMuted);
}
this[LocalStreamEventNames.UserMuteStateChange].emit(isMuted);
}
}

Expand Down
5 changes: 2 additions & 3 deletions src/media/local-video-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import MediaStreamStub from '../mocks/media-stream-stub';
import { mocked } from '../mocks/mock';
import { LocalStreamEventNames } from './local-stream';
import { LocalVideoStream } from './local-video-stream';
import { StreamEventNames } from './stream';

jest.mock('../mocks/media-stream-stub');

Expand All @@ -21,7 +20,7 @@ describe('localVideoStream', () => {

it('should work', () => {
expect.hasAssertions();
videoStream.on(StreamEventNames.MuteStateChange, (muted: boolean) => {
videoStream.on(LocalStreamEventNames.UserMuteStateChange, (muted: boolean) => {
// eslint-disable-next-line no-console
console.log(`stream is muted? ${muted}`);
});
Expand All @@ -30,7 +29,7 @@ describe('localVideoStream', () => {
console.log('stream constraints changed');
});

videoStream.setMuted(true);
videoStream.setUserMuted(true);
videoStream.applyConstraints({
height: 720,
width: 1280,
Expand Down
75 changes: 74 additions & 1 deletion src/media/remote-stream.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMockedStream } from '../util/test-utils';
import { RemoteStream } from './remote-stream';
import { RemoteMediaState, RemoteStream, RemoteStreamEventNames } from './remote-stream';

describe('RemoteStream', () => {
const mockStream = createMockedStream();
Expand All @@ -8,6 +8,19 @@ describe('RemoteStream', () => {
remoteStream = new RemoteStream(mockStream);
});

describe('constructor', () => {
it('should add the correct event handlers on the track', () => {
expect.assertions(4);

const addEventListenerSpy = jest.spyOn(mockStream.getTracks()[0], 'addEventListener');

expect(addEventListenerSpy).toHaveBeenCalledTimes(3);
expect(addEventListenerSpy).toHaveBeenCalledWith('ended', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('mute', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('unmute', expect.anything());
});
});

describe('getSettings', () => {
it('should get the settings of the output track', () => {
expect.assertions(1);
Expand All @@ -30,6 +43,66 @@ describe('RemoteStream', () => {
expect(removeTrackSpy).toHaveBeenCalledWith(mockStream.getTracks()[0]);
expect(addTrackSpy).toHaveBeenCalledWith(newTrack);
});

it('should replace the event handlers on the output track', () => {
expect.assertions(8);

const removeEventListenerSpy = jest.spyOn(mockStream.getTracks()[0], 'removeEventListener');
const newTrack = new MediaStreamTrack();
const addEventListenerSpy = jest.spyOn(newTrack, 'addEventListener');

remoteStream.replaceTrack(newTrack);

expect(removeEventListenerSpy).toHaveBeenCalledTimes(3);
expect(removeEventListenerSpy).toHaveBeenCalledWith('ended', expect.anything());
expect(removeEventListenerSpy).toHaveBeenCalledWith('mute', expect.anything());
expect(removeEventListenerSpy).toHaveBeenCalledWith('unmute', expect.anything());

expect(addEventListenerSpy).toHaveBeenCalledTimes(3);
expect(addEventListenerSpy).toHaveBeenCalledWith('ended', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('mute', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('unmute', expect.anything());
});

it('should emit MediaStateChange event if new track is muted but old track is not', () => {
expect.assertions(2);

// Set old track to be unmuted.
Object.defineProperty(mockStream.getTracks()[0], 'muted', {
value: false,
configurable: true,
});

// Set new track to be muted.
const newTrack = new MediaStreamTrack();
Object.defineProperty(newTrack, 'muted', { value: true });

const emitSpy = jest.spyOn(remoteStream[RemoteStreamEventNames.MediaStateChange], 'emit');
remoteStream.replaceTrack(newTrack);

expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(RemoteMediaState.Stopped);
});

it('should emit MediaStateChange event if old track is muted but new track is not', () => {
expect.assertions(2);

// Set old track to be muted.
Object.defineProperty(mockStream.getTracks()[0], 'muted', {
value: true,
configurable: true,
});

// Set new track to be unmuted.
const newTrack = new MediaStreamTrack();
Object.defineProperty(newTrack, 'muted', { value: false });

const emitSpy = jest.spyOn(remoteStream[RemoteStreamEventNames.MediaStateChange], 'emit');
remoteStream.replaceTrack(newTrack);

expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(RemoteMediaState.Started);
});
});

describe('stop', () => {
Expand Down
Loading
Loading