diff --git a/.changeset/lemon-chairs-check.md b/.changeset/lemon-chairs-check.md new file mode 100644 index 000000000..6e060fe3c --- /dev/null +++ b/.changeset/lemon-chairs-check.md @@ -0,0 +1,6 @@ +--- +'@livekit/components-core': minor +'@livekit/components-react': minor +--- + +Add `usePersistentUserChoices` hook to save user choices saving functionality. diff --git a/packages/core/etc/components-core.api.md b/packages/core/etc/components-core.api.md index 261e2fb1e..d8c79d673 100644 --- a/packages/core/etc/components-core.api.md +++ b/packages/core/etc/components-core.api.md @@ -218,6 +218,10 @@ export function isTrackReferencePlaceholder(trackReference?: TrackReferenceOrPla // @internal (undocumented) export function isWeb(): boolean; +// @alpha +export function loadUserChoices(defaults?: Partial, +preventLoad?: boolean): UserChoices; + // @public (undocumented) export const log: loglevel.Logger; @@ -334,6 +338,10 @@ export function roomInfoObserver(room: Room): Observable<{ // @public (undocumented) export function roomObserver(room: Room): Observable; +// @alpha +export function saveUserChoices(deviceSettings: UserChoices, +preventSave?: boolean): void; + // @public (undocumented) export function screenShareObserver(room: Room): Observable; @@ -556,6 +564,15 @@ export type TrackSourceWithOptions = { // @public export function updatePages(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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 45f70e992..fcbc34c8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/persistent-storage/index.ts b/packages/core/src/persistent-storage/index.ts new file mode 100644 index 000000000..3e3a3d964 --- /dev/null +++ b/packages/core/src/persistent-storage/index.ts @@ -0,0 +1 @@ +export { saveUserChoices, loadUserChoices, type UserChoices } from './user-choices'; diff --git a/packages/core/src/persistent-storage/local-storage-helpers.ts b/packages/core/src/persistent-storage/local-storage-helpers.ts new file mode 100644 index 000000000..6651e14fd --- /dev/null +++ b/packages/core/src/persistent-storage/local-storage-helpers.ts @@ -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(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(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; + } +} diff --git a/packages/core/src/persistent-storage/user-choices.ts b/packages/core/src/persistent-storage/user-choices.ts new file mode 100644 index 000000000..f863be4f1 --- /dev/null +++ b/packages/core/src/persistent-storage/user-choices.ts @@ -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, + /** + * 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; + } +} diff --git a/packages/react/etc/components-react.api.md b/packages/react/etc/components-react.api.md index b126126b2..651c5e0f0 100644 --- a/packages/react/etc/components-react.api.md +++ b/packages/react/etc/components-react.api.md @@ -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'; @@ -182,7 +183,7 @@ export interface ConnectionStatusProps extends React_2.HTMLAttributes { // (undocumented) controls?: ControlBarControls; + // @alpha + saveUserChoices?: boolean; // (undocumented) variation?: 'minimal' | 'verbose' | 'textOnly'; } @@ -323,7 +326,7 @@ export interface LiveKitRoomProps extends Omit; -// @public (undocumented) +// @public @deprecated (undocumented) export type LocalUserChoices = { username: string; videoEnabled: boolean; @@ -424,9 +427,9 @@ export interface ParticipantTileProps extends React_2.HTMLAttributes, 'onSubmit' | 'onError'> { // (undocumented) camLabel?: string; @@ -440,6 +443,8 @@ export interface PreJoinProps extends Omit void; onSubmit?: (values: LocalUserChoices) => void; onValidate?: (values: LocalUserChoices) => boolean; + // @alpha + persistUserChoices?: boolean; // (undocumented) showE2EEOptions?: boolean; // (undocumented) @@ -825,6 +830,22 @@ export interface UseParticipantTileProps 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; + preventLoad?: boolean; + preventSave?: boolean; +} + // @public export function usePinnedTracks(layoutContext?: LayoutContextType): TrackReferenceOrPlaceholder[]; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5b54bccd1..2ce993810 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -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'; diff --git a/packages/react/src/hooks/usePersistentUserChoices.ts b/packages/react/src/hooks/usePersistentUserChoices.ts new file mode 100644 index 000000000..f516e8a6c --- /dev/null +++ b/packages/react/src/hooks/usePersistentUserChoices.ts @@ -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; + /** + * 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( + 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, + }; +} diff --git a/packages/react/src/prefabs/ControlBar.tsx b/packages/react/src/prefabs/ControlBar.tsx index d2581dd21..2d275a088 100644 --- a/packages/react/src/prefabs/ControlBar.tsx +++ b/packages/react/src/prefabs/ControlBar.tsx @@ -6,7 +6,7 @@ import { TrackToggle } from '../components/controls/TrackToggle'; import { StartAudio } from '../components/controls/StartAudio'; import { ChatIcon, LeaveIcon } from '../assets/icons'; import { ChatToggle } from '../components/controls/ChatToggle'; -import { useLocalParticipantPermissions } from '../hooks'; +import { useLocalParticipantPermissions, usePersistentUserChoices } from '../hooks'; import { useMediaQuery } from '../hooks/internal'; import { useMaybeLayoutContext } from '../context'; import { supportsScreenSharing } from '@livekit/components-core'; @@ -25,6 +25,13 @@ export type ControlBarControls = { export interface ControlBarProps extends React.HTMLAttributes { variation?: 'minimal' | 'verbose' | 'textOnly'; controls?: ControlBarControls; + /** + * If `true`, the user's device choices will be persisted. + * This will enables the user to have the same device choices when they rejoin the room. + * @defaultValue true + * @alpha + */ + saveUserChoices?: boolean; } /** @@ -43,7 +50,12 @@ export interface ControlBarProps extends React.HTMLAttributes { * ``` * @public */ -export function ControlBar({ variation, controls, ...props }: ControlBarProps) { +export function ControlBar({ + variation, + controls, + saveUserChoices = true, + ...props +}: ControlBarProps) { const [isChatOpen, setIsChatOpen] = React.useState(false); const layoutContext = useMaybeLayoutContext(); React.useEffect(() => { @@ -91,25 +103,46 @@ export function ControlBar({ variation, controls, ...props }: ControlBarProps) { const htmlProps = mergeProps({ className: 'lk-control-bar' }, props); + const { + saveAudioInputEnabled, + saveVideoInputEnabled, + saveAudioInputDeviceId, + saveVideoInputDeviceId, + } = usePersistentUserChoices({ preventSave: !saveUserChoices }); + return (
{visibleControls.microphone && (
- + {showText && 'Microphone'}
- + saveAudioInputDeviceId(deviceId ?? '')} + />
)} {visibleControls.camera && (
- + {showText && 'Camera'}
- + saveVideoInputDeviceId(deviceId ?? '')} + />
)} diff --git a/packages/react/src/prefabs/PreJoin.tsx b/packages/react/src/prefabs/PreJoin.tsx index b5f6bced4..87faebcb0 100644 --- a/packages/react/src/prefabs/PreJoin.tsx +++ b/packages/react/src/prefabs/PreJoin.tsx @@ -15,11 +15,15 @@ import { import * as React from 'react'; import { MediaDeviceMenu } from './MediaDeviceMenu'; import { TrackToggle } from '../components/controls/TrackToggle'; +import type { UserChoices } from '@livekit/components-core'; import { log } from '@livekit/components-core'; import { ParticipantPlaceholder } from '../assets/images'; -import { useMediaDevices } from '../hooks'; +import { useMediaDevices, usePersistentUserChoices } from '../hooks'; -/** @public */ +/** + * @deprecated Use `UserChoices` from `@livekit/components-core` instead. + * @public + */ export type LocalUserChoices = { username: string; videoEnabled: boolean; @@ -40,7 +44,10 @@ const DEFAULT_USER_CHOICES: LocalUserChoices = { sharedPassphrase: '', }; -/** @public */ +/** + * Props for the PreJoin component. + * @public + */ export interface PreJoinProps extends Omit, 'onSubmit' | 'onError'> { /** This function is called with the `LocalUserChoices` if validation is passed. */ @@ -59,6 +66,13 @@ export interface PreJoinProps camLabel?: string; userLabel?: string; showE2EEOptions?: boolean; + + /** + * If true, user choices are persisted across sessions. + * @defaultValue true + * @alpha + */ + persistUserChoices?: boolean; } /** @alpha */ @@ -222,27 +236,68 @@ export function PreJoin({ camLabel = 'Camera', userLabel = 'Username', showE2EEOptions = false, + persistUserChoices = true, ...htmlProps }: PreJoinProps) { const [userChoices, setUserChoices] = React.useState(DEFAULT_USER_CHOICES); const [username, setUsername] = React.useState( defaults.username ?? DEFAULT_USER_CHOICES.username, ); - const [videoEnabled, setVideoEnabled] = React.useState( - defaults.videoEnabled ?? DEFAULT_USER_CHOICES.videoEnabled, - ); - const initialVideoDeviceId = defaults.videoDeviceId ?? DEFAULT_USER_CHOICES.videoDeviceId; - const [videoDeviceId, setVideoDeviceId] = React.useState(initialVideoDeviceId); - const initialAudioDeviceId = defaults.audioDeviceId ?? DEFAULT_USER_CHOICES.audioDeviceId; + + // TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`. + const partialDefaults: Partial = { + ...(defaults.audioDeviceId !== undefined && { audioInputDeviceId: defaults.audioDeviceId }), + ...(defaults.videoDeviceId !== undefined && { videoInputDeviceId: defaults.videoDeviceId }), + ...(defaults.audioEnabled !== undefined && { audioInputEnabled: defaults.audioEnabled }), + ...(defaults.videoEnabled !== undefined && { videoInputEnabled: defaults.videoEnabled }), + ...(defaults.username !== undefined && { username: defaults.username }), + }; + + const { + userChoices: initialUserChoices, + saveAudioInputDeviceId, + saveAudioInputEnabled, + saveVideoInputDeviceId, + saveVideoInputEnabled, + } = usePersistentUserChoices({ + defaults: partialDefaults, + preventSave: !persistUserChoices, + preventLoad: !persistUserChoices, + }); + + // Initialize device settings const [audioEnabled, setAudioEnabled] = React.useState( - defaults.audioEnabled ?? DEFAULT_USER_CHOICES.audioEnabled, + defaults.audioEnabled ?? initialUserChoices.audioInputEnabled, ); + const [videoEnabled, setVideoEnabled] = React.useState( + defaults.videoEnabled ?? initialUserChoices.videoInputEnabled, + ); + + const initialAudioDeviceId = defaults.audioDeviceId ?? initialUserChoices.audioInputDeviceId; const [audioDeviceId, setAudioDeviceId] = React.useState(initialAudioDeviceId); + + const initialVideoDeviceId = defaults.videoDeviceId ?? initialUserChoices.videoInputDeviceId; + const [videoDeviceId, setVideoDeviceId] = React.useState(initialVideoDeviceId); + const [e2ee, setE2ee] = React.useState(defaults.e2ee ?? DEFAULT_USER_CHOICES.e2ee); const [sharedPassphrase, setSharedPassphrase] = React.useState( defaults.sharedPassphrase ?? DEFAULT_USER_CHOICES.sharedPassphrase, ); + // Update persistent device settings + React.useEffect(() => { + saveAudioInputEnabled(audioEnabled); + }, [audioEnabled, saveAudioInputEnabled]); + React.useEffect(() => { + saveVideoInputEnabled(videoEnabled); + }, [videoEnabled, saveVideoInputEnabled]); + React.useEffect(() => { + saveAudioInputDeviceId(audioDeviceId); + }, [audioDeviceId, saveAudioInputDeviceId]); + React.useEffect(() => { + saveVideoInputDeviceId(videoDeviceId); + }, [videoDeviceId, saveVideoInputDeviceId]); + const tracks = usePreviewTracks( { audio: audioEnabled ? { deviceId: initialAudioDeviceId } : false,