Skip to content
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
11 changes: 9 additions & 2 deletions packages/client/src/devices/CameraManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Observable } from 'rxjs';
import { firstValueFrom, Observable } from 'rxjs';
import { Call } from '../Call';
import { CameraDirection, CameraManagerState } from './CameraManagerState';
import { DeviceManager } from './DeviceManager';
Expand Down Expand Up @@ -140,7 +140,14 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
this.state.status === undefined &&
this.state.optimisticStatus === undefined;
let persistedPreferencesApplied = false;
if (shouldApplyDefaults && this.devicePersistence.enabled) {
const permissionState = await firstValueFrom(
this.state.browserPermissionState$,
);
if (
shouldApplyDefaults &&
this.devicePersistence.enabled &&
permissionState === 'granted'
) {
persistedPreferencesApplied =
await this.applyPersistedPreferences(enabledInCallType);
}
Expand Down
16 changes: 13 additions & 3 deletions packages/client/src/devices/DeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,19 @@ export abstract class DeviceManager<
if (this.devicePersistence.enabled) {
this.subscriptions.push(
createSubscription(
combineLatest([this.state.selectedDevice$, this.state.status$]),
([selectedDevice, status]) => {
if (!status) return;
combineLatest([
this.state.selectedDevice$,
this.state.status$,
this.state.browserPermissionState$,
]),
([selectedDevice, status, browserPermissionState]) => {
if (
!status ||
(this.isTrackStoppedDueToTrackEnd && status === 'disabled') ||
browserPermissionState !== 'granted'
)
return;

this.persistPreference(selectedDevice, status);
},
),
Expand Down
9 changes: 8 additions & 1 deletion packages/client/src/devices/MicrophoneManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,14 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
this.state.status === undefined &&
this.state.optimisticStatus === undefined;
let persistedPreferencesApplied = false;
if (shouldApplyDefaults && this.devicePersistence.enabled) {
const permissionState = await firstValueFrom(
this.state.browserPermissionState$,
);
if (
shouldApplyDefaults &&
this.devicePersistence.enabled &&
permissionState === 'granted'
) {
persistedPreferencesApplied = await this.applyPersistedPreferences(true);
}

Expand Down
20 changes: 16 additions & 4 deletions packages/client/src/devices/SpeakerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { combineLatest } from 'rxjs';
import { Call } from '../Call';
import { isReactNative } from '../helpers/platforms';
import { SpeakerState } from './SpeakerState';
import { deviceIds$, getAudioOutputDevices } from './devices';
import {
deviceIds$,
getAudioBrowserPermission,
getAudioOutputDevices,
} from './devices';
import {
AudioSettingsRequestDefaultDeviceEnum,
CallSettingsResponse,
Expand Down Expand Up @@ -111,9 +115,17 @@ export class SpeakerManager {

if (!isReactNative() && this.devicePersistence.enabled) {
this.subscriptions.push(
createSubscription(this.state.selectedDevice$, (selectedDevice) => {
this.persistSpeakerDevicePreference(selectedDevice);
}),
createSubscription(
combineLatest([
this.state.selectedDevice$,
getAudioBrowserPermission(this.call.tracer).asStateObservable(),
]),
([selectedDevice, browserPermissionState]) => {
if (!selectedDevice || browserPermissionState !== 'granted') return;

this.persistSpeakerDevicePreference(selectedDevice);
},
),
);
}
}
Expand Down
32 changes: 32 additions & 0 deletions packages/client/src/devices/__tests__/CameraManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ describe('CameraManager', () => {
});

it('should skip defaults when preferences are applied', async () => {
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('granted'),
);
const devicePersistence = { enabled: true, storageKey: '' };
const persistedManager = new CameraManager(call, devicePersistence);
const applySpy = vi
Expand All @@ -329,6 +332,32 @@ describe('CameraManager', () => {
expect(enableSpy).not.toHaveBeenCalled();
});

it('should skip persisted preferences when permission is not granted', async () => {
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('prompt'),
);
const devicePersistence = { enabled: true, storageKey: '' };
const persistedManager = new CameraManager(call, devicePersistence);
const applySpy = vi.spyOn(
persistedManager as never,
'applyPersistedPreferences',
);
const enableSpy = vi.spyOn(persistedManager, 'enable');

await persistedManager.apply(
fromPartial({
enabled: true,
target_resolution: { width: 640, height: 480 },
camera_facing: 'front',
camera_default_on: true,
}),
true,
);

expect(applySpy).not.toHaveBeenCalled();
expect(enableSpy).toHaveBeenCalled();
});

it('should not apply defaults when device is not pristine', async () => {
manager.state.setStatus('enabled');
const selectDirectionSpy = vi.spyOn(manager, 'selectDirection');
Expand Down Expand Up @@ -445,6 +474,9 @@ describe('CameraManager', () => {
createVideoStreamForDevice(selectedDevice.deviceId),
);
});
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('granted'),
);

const stressManager = new CameraManager(call, {
enabled: true,
Expand Down
71 changes: 71 additions & 0 deletions packages/client/src/devices/__tests__/DeviceManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ describe('Device Manager', () => {
beforeEach(() => {
storageKey = '@test/device-preferences';
localStorageMock = createLocalStorageMock();
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('granted'),
);
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: localStorageMock,
Expand Down Expand Up @@ -455,6 +458,74 @@ describe('Device Manager', () => {
},
]);
});

it('stores preferences when permission is granted', async () => {
const persistenceEnabledManager = new TestInputMediaDeviceManager(
manager['call'],
{ enabled: true, storageKey },
);
const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices');

emitDeviceIds(mockVideoDevices);
persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId);
persistenceEnabledManager.state.setStatus('enabled');

expect(readPreferences(storageKey).camera).toBeDefined();
expect(listDevicesSpy).toHaveBeenCalled();
expect(readPreferences(storageKey).camera).toEqual([
{
selectedDeviceId: mockVideoDevices[0].deviceId,
selectedDeviceLabel: mockVideoDevices[0].label,
muted: false,
},
]);
});

it('does not store preferences when permission is not granted', async () => {
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('prompt'),
);
const persistenceEnabledManager = new TestInputMediaDeviceManager(
manager['call'],
{ enabled: true, storageKey },
);
const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices');

emitDeviceIds(mockVideoDevices);
persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId);
persistenceEnabledManager.state.setStatus('enabled');

expect(readPreferences(storageKey).camera).toBeUndefined();
expect(listDevicesSpy).not.toHaveBeenCalled();
});

it('does not overwrite preferences when track ends unexpectedly', async () => {
const persistenceEnabledManager = new TestInputMediaDeviceManager(
manager['call'],
{ enabled: true, storageKey },
);

await persistenceEnabledManager.enable();

expect(readPreferences(storageKey).camera).toEqual([
{
selectedDeviceId: mockVideoDevices[0].deviceId,
selectedDeviceLabel: mockVideoDevices[0].label,
muted: false,
},
]);

const [track] = persistenceEnabledManager.state.mediaStream!.getTracks();
await ((track as MockTrack).eventHandlers['ended'] as Function)();

expect(readPreferences(storageKey).camera).toEqual([
{
selectedDeviceId: mockVideoDevices[0].deviceId,
selectedDeviceLabel: mockVideoDevices[0].label,
muted: false,
},
]);
});
});

describe('applyPersistedPreferences', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/client/src/devices/__tests__/MicrophoneManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,29 @@ describe('MicrophoneManager', () => {
expect(enableSpy).not.toHaveBeenCalled();
});

it('should skip persisted preferences when permission is not granted', async () => {
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('prompt'),
);
const devicePersistence = { enabled: true, storageKey: '' };
const persistedManager = new MicrophoneManager(
call,
devicePersistence,
'disable-tracks',
);
const applySpy = vi.spyOn(
persistedManager as never,
'applyPersistedPreferences',
);
const enableSpy = vi.spyOn(persistedManager, 'enable');

// @ts-expect-error - partial data
await persistedManager.apply({ mic_default_on: true }, true);

expect(applySpy).not.toHaveBeenCalled();
expect(enableSpy).toHaveBeenCalled();
});

it('should not apply defaults when mic is not pristine', async () => {
manager.state.setStatus('enabled');
const applySpy = vi.spyOn(manager as never, 'applyPersistedPreferences');
Expand Down
28 changes: 28 additions & 0 deletions packages/client/src/devices/__tests__/SpeakerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe('SpeakerManager.test', () => {
beforeEach(() => {
storageKey = '@test/speaker-preferences';
localStorageMock = createLocalStorageMock();
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
of('granted'),
);
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: localStorageMock,
Expand Down Expand Up @@ -125,6 +128,31 @@ describe('SpeakerManager.test', () => {
expect(manager.state.selectedDevice).toBe('');
});

it('persists speaker selection when permission is granted', async () => {
const persistedManager = new SpeakerManager(
new Call({
id: '',
type: '',
streamClient: new StreamClient('abc123'),
clientStore: new StreamVideoWriteableStateStore(),
}),
{ enabled: true, storageKey },
);
const listDevicesSpy = vi.spyOn(persistedManager, 'listDevices');
const audioOutputDevice = {
deviceId: 'speaker-1',
kind: 'audiooutput',
label: 'Speaker 1',
groupId: 'speaker-group',
} as MediaDeviceInfo;

emitDeviceIds([audioOutputDevice]);
persistedManager.select(audioOutputDevice.deviceId);

expect(listDevicesSpy).toHaveBeenCalled();
expect(persistedManager.state.selectedDevice).toBe('speaker-1');
});

describe('apply (web)', () => {
it('does nothing when persistence is disabled', () => {
const selectSpy = vi.spyOn(manager, 'select');
Expand Down
Loading