Skip to content

Commit

Permalink
Add usePersistentUserChoices hook to save device settings and usernam…
Browse files Browse the repository at this point in the history
…e to local storage (#683)
  • Loading branch information
Ocupe authored Nov 6, 2023
1 parent 99b6fda commit 01611f5
Show file tree
Hide file tree
Showing 11 changed files with 356 additions and 20 deletions.
6 changes: 6 additions & 0 deletions .changeset/lemon-chairs-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@livekit/components-core': minor
'@livekit/components-react': minor
---

Add `usePersistentUserChoices` hook to save user choices saving functionality.
17 changes: 17 additions & 0 deletions packages/core/etc/components-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export function isTrackReferencePlaceholder(trackReference?: TrackReferenceOrPla
// @internal (undocumented)
export function isWeb(): boolean;

// @alpha
export function loadUserChoices(defaults?: Partial<UserChoices>,
preventLoad?: boolean): UserChoices;

// @public (undocumented)
export const log: loglevel.Logger;

Expand Down Expand Up @@ -334,6 +338,10 @@ export function roomInfoObserver(room: Room): Observable<{
// @public (undocumented)
export function roomObserver(room: Room): Observable<Room>;

// @alpha
export function saveUserChoices(deviceSettings: UserChoices,
preventSave?: boolean): void;

// @public (undocumented)
export function screenShareObserver(room: Room): Observable<ScreenShareTrackMap>;

Expand Down Expand Up @@ -556,6 +564,15 @@ export type TrackSourceWithOptions = {
// @public
export function updatePages<T extends UpdatableItem>(currentList: T[], nextList: T[], maxItemsOnPage: number): T[];

// @public
export type UserChoices = {
videoInputEnabled: boolean;
audioInputEnabled: boolean;
videoInputDeviceId: string;
audioInputDeviceId: string;
username: string;
};

// @public (undocumented)
export type VideoSource = Track.Source.Camera | Track.Source.ScreenShare;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export * from './observables/track';
export * from './observables/dataChannel';
export * from './observables/dom-event';

export * from './persistent-storage';

export { log, setLogLevel } from './logger';
1 change: 1 addition & 0 deletions packages/core/src/persistent-storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { saveUserChoices, loadUserChoices, type UserChoices } from './user-choices';
45 changes: 45 additions & 0 deletions packages/core/src/persistent-storage/local-storage-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { log } from '../logger';

/**
* Set an object to local storage by key
* @param key - the key to set the object to local storage
* @param value - the object to set to local storage
* @internal
*/
export function setLocalStorageObject<T extends object>(key: string, value: T): void {
if (typeof localStorage === 'undefined') {
log.error('Local storage is not available.');
return;
}

try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
log.error(`Error setting item to local storage: ${error}`);
}
}

/**
* Get an object from local storage by key
* @param key - the key to retrieve the object from local storage
* @returns the object retrieved from local storage, or null if the key does not exist
* @internal
*/
export function getLocalStorageObject<T extends object>(key: string): T | undefined {
if (typeof localStorage === 'undefined') {
log.error('Local storage is not available.');
return undefined;
}

try {
const item = localStorage.getItem(key);
if (!item) {
log.warn(`Item with key ${key} does not exist in local storage.`);
return undefined;
}
return JSON.parse(item);
} catch (error) {
log.error(`Error getting item from local storage: ${error}`);
return undefined;
}
}
92 changes: 92 additions & 0 deletions packages/core/src/persistent-storage/user-choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cssPrefix } from '../constants';
import { getLocalStorageObject, setLocalStorageObject } from './local-storage-helpers';

const USER_CHOICES_KEY = `${cssPrefix}-device-settings` as const;

/**
* Represents the user's choices for video and audio input devices,
* as well as their username.
*/
export type UserChoices = {
/**
* Whether video input is enabled.
* @defaultValue `true`
*/
videoInputEnabled: boolean;
/**
* Whether audio input is enabled.
* @defaultValue `true`
*/
audioInputEnabled: boolean;
/**
* The device ID of the video input device to use.
* @defaultValue `''`
*/
videoInputDeviceId: string;
/**
* The device ID of the audio input device to use.
* @defaultValue `''`
*/
audioInputDeviceId: string;
/**
* The username to use.
* @defaultValue `''`
*/
username: string;
};

const defaultUserChoices: UserChoices = {
videoInputEnabled: true,
audioInputEnabled: true,
videoInputDeviceId: '',
audioInputDeviceId: '',
username: '',
} as const;

/**
* Saves user choices to local storage.
* @param deviceSettings - The device settings to be stored.
* @alpha
*/
export function saveUserChoices(
deviceSettings: UserChoices,
/**
* Whether to prevent saving user choices to local storage.
*/
preventSave: boolean = false,
): void {
if (preventSave === true) {
return;
}
setLocalStorageObject(USER_CHOICES_KEY, deviceSettings);
}

/**
* Reads the user choices from local storage, or returns the default settings if none are found.
* @param defaults - The default device settings to use if none are found in local storage.
* @defaultValue `defaultUserChoices`
*
* @alpha
*/
export function loadUserChoices(
defaults?: Partial<UserChoices>,
/**
* Whether to prevent loading from local storage and return default values instead.
* @defaultValue false
*/
preventLoad: boolean = false,
): UserChoices {
const fallback: UserChoices = {
videoInputEnabled: defaults?.videoInputEnabled ?? defaultUserChoices.videoInputEnabled,
audioInputEnabled: defaults?.audioInputEnabled ?? defaultUserChoices.audioInputEnabled,
videoInputDeviceId: defaults?.videoInputDeviceId ?? defaultUserChoices.videoInputDeviceId,
audioInputDeviceId: defaults?.audioInputDeviceId ?? defaultUserChoices.audioInputDeviceId,
username: defaults?.username ?? defaultUserChoices.username,
};

if (preventLoad) {
return fallback;
} else {
return getLocalStorageObject(USER_CHOICES_KEY) ?? fallback;
}
}
29 changes: 25 additions & 4 deletions packages/react/etc/components-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { TrackPublication } from 'livekit-client';
import type { TrackReference } from '@livekit/components-core';
import { TrackReferenceOrPlaceholder } from '@livekit/components-core';
import type { TrackSourceWithOptions } from '@livekit/components-core';
import type { UserChoices } from '@livekit/components-core';
import type { VideoCaptureOptions } from 'livekit-client';
import type { VideoSource } from '@livekit/components-core';
import type { WidgetState } from '@livekit/components-core';
Expand Down Expand Up @@ -182,7 +183,7 @@ export interface ConnectionStatusProps extends React_2.HTMLAttributes<HTMLDivEle
}

// @public
export function ControlBar({ variation, controls, ...props }: ControlBarProps): React_2.JSX.Element;
export function ControlBar({ variation, controls, saveUserChoices, ...props }: ControlBarProps): React_2.JSX.Element;

// @public (undocumented)
export type ControlBarControls = {
Expand All @@ -197,6 +198,8 @@ export type ControlBarControls = {
export interface ControlBarProps extends React_2.HTMLAttributes<HTMLDivElement> {
// (undocumented)
controls?: ControlBarControls;
// @alpha
saveUserChoices?: boolean;
// (undocumented)
variation?: 'minimal' | 'verbose' | 'textOnly';
}
Expand Down Expand Up @@ -323,7 +326,7 @@ export interface LiveKitRoomProps extends Omit<React_2.HTMLAttributes<HTMLDivEle
// @internal (undocumented)
export const LKFeatureContext: React_2.Context<FeatureFlags | undefined>;

// @public (undocumented)
// @public @deprecated (undocumented)
export type LocalUserChoices = {
username: string;
videoEnabled: boolean;
Expand Down Expand Up @@ -424,9 +427,9 @@ export interface ParticipantTileProps extends React_2.HTMLAttributes<HTMLDivElem
}

// @public
export function PreJoin({ defaults, onValidate, onSubmit, onError, debug, joinLabel, micLabel, camLabel, userLabel, showE2EEOptions, ...htmlProps }: PreJoinProps): React_2.JSX.Element;
export function PreJoin({ defaults, onValidate, onSubmit, onError, debug, joinLabel, micLabel, camLabel, userLabel, showE2EEOptions, persistUserChoices, ...htmlProps }: PreJoinProps): React_2.JSX.Element;

// @public (undocumented)
// @public
export interface PreJoinProps extends Omit<React_2.HTMLAttributes<HTMLDivElement>, 'onSubmit' | 'onError'> {
// (undocumented)
camLabel?: string;
Expand All @@ -440,6 +443,8 @@ export interface PreJoinProps extends Omit<React_2.HTMLAttributes<HTMLDivElement
onError?: (error: Error) => void;
onSubmit?: (values: LocalUserChoices) => void;
onValidate?: (values: LocalUserChoices) => boolean;
// @alpha
persistUserChoices?: boolean;
// (undocumented)
showE2EEOptions?: boolean;
// (undocumented)
Expand Down Expand Up @@ -825,6 +830,22 @@ export interface UseParticipantTileProps<T extends HTMLElement> extends React_2.
trackRef?: TrackReferenceOrPlaceholder;
}

// @alpha
export function usePersistentUserChoices(options?: UsePersistentUserChoicesOptions): {
userChoices: UserChoices;
saveAudioInputEnabled: (isEnabled: boolean) => void;
saveVideoInputEnabled: (isEnabled: boolean) => void;
saveAudioInputDeviceId: (deviceId: string) => void;
saveVideoInputDeviceId: (deviceId: string) => void;
};

// @alpha
export interface UsePersistentUserChoicesOptions {
defaults?: Partial<UserChoices>;
preventLoad?: boolean;
preventSave?: boolean;
}

// @public
export function usePinnedTracks(layoutContext?: LayoutContextType): TrackReferenceOrPlaceholder[];

Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ export { UseVisualStableUpdateOptions, useVisualStableUpdate } from './useVisual
export { UseTrackOptions, useTrack } from './useTrack';
export { useTrackByName } from './useTrackByName';
export { useChat } from './useChat';
export {
usePersistentUserChoices,
type UsePersistentUserChoicesOptions,
} from './usePersistentUserChoices';
60 changes: 60 additions & 0 deletions packages/react/src/hooks/usePersistentUserChoices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { UserChoices } from '@livekit/components-core';
import { loadUserChoices, saveUserChoices } from '@livekit/components-core';
import * as React from 'react';

/**
* Options for the `usePersistentDeviceSettings` hook.
* @alpha
*/
export interface UsePersistentUserChoicesOptions {
/**
* The default value to use if reading from local storage returns no results or fails.
*/
defaults?: Partial<UserChoices>;
/**
* Whether to prevent saving to persistent storage.
* @defaultValue false
*/
preventSave?: boolean;
/**
* Whether to prevent loading user choices from persistent storage and use `defaults` instead.
* @defaultValue false
*/
preventLoad?: boolean;
}

/**
* A hook that provides access to user choices stored in local storage, such as
* selected media devices and their current state (on or off), as well as the user name.
* @alpha
*/
export function usePersistentUserChoices(options: UsePersistentUserChoicesOptions = {}) {
const [userChoices, setSettings] = React.useState<UserChoices>(
loadUserChoices(options.defaults, options.preventLoad ?? false),
);

const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, audioInputEnabled: isEnabled }));
}, []);
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, videoInputEnabled: isEnabled }));
}, []);
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, audioInputDeviceId: deviceId }));
}, []);
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, videoInputDeviceId: deviceId }));
}, []);

React.useEffect(() => {
saveUserChoices(userChoices, options.preventSave ?? false);
}, [userChoices, options.preventSave]);

return {
userChoices,
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
};
}
Loading

0 comments on commit 01611f5

Please sign in to comment.