diff --git a/.changeset/odd-files-talk.md b/.changeset/odd-files-talk.md new file mode 100644 index 000000000..283b734f0 --- /dev/null +++ b/.changeset/odd-files-talk.md @@ -0,0 +1,6 @@ +--- +'@livekit/components-react': minor +'@livekit/components-styles': minor +--- + +Improves the PreJoin component and allows for custom placeholders for each participant diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx index 3c6cf44bf..4d922ca5d 100644 --- a/examples/nextjs/pages/index.tsx +++ b/examples/nextjs/pages/index.tsx @@ -27,6 +27,14 @@ const EXAMPLE_ROUTES = { title: 'Example usage of @livekit/track-processors for background blur', href: () => `/processors`, }, + prejoin: { + title: 'Example usage of @livekit/prejoin', + href: () => `/prejoin`, + }, + videoCall: { + title: 'Video call example (1:1 call structure)', + href: () => `/video-call-example`, + }, } as const; const Home: NextPage = () => { diff --git a/examples/nextjs/pages/prejoin.tsx b/examples/nextjs/pages/prejoin.tsx index 884b873ae..440d68e5b 100644 --- a/examples/nextjs/pages/prejoin.tsx +++ b/examples/nextjs/pages/prejoin.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { PreJoin, setLogLevel } from '@livekit/components-react'; +import { PreJoin, PreJoinValues, setLogLevel } from '@livekit/components-react'; import type { NextPage } from 'next'; import { Track, TrackProcessor } from 'livekit-client'; import { BackgroundBlur } from '@livekit/track-processors'; @@ -12,21 +12,104 @@ const PreJoinExample: NextPage = () => { const [backgroundBlur, setBackgroundBlur] = React.useState< TrackProcessor | undefined >(undefined); + const [errorMessage, setErrorMessage] = React.useState(''); + const [validationError, setValidationError] = React.useState(''); React.useEffect(() => { setBackgroundBlur(BackgroundBlur()); }, []); + const handleError = (error: Error) => { + console.log('error', error); + setErrorMessage(error.message); + // Clear validation error when a device error occurs + setValidationError(''); + }; + + const handleValidate = (values: PreJoinValues) => { + const isValid = Boolean(values.audioAvailable || values.videoAvailable); + console.log('isValid', isValid, values); + + if (!isValid) { + setValidationError('At least one device (audio or video) must be available to join.'); + setErrorMessage(''); // Clear device error when validation fails + } else { + setValidationError(''); // Clear validation error on success + } + + return isValid; + }; + return (
+ {(errorMessage || validationError) && ( +
+
+ ⚠️ +
+ + {validationError ? 'Validation Error' : 'Device Error'} + +

+ {validationError || errorMessage} +

+
+ +
+
+ )} + { values.audioDeviceId; - }} - onValidate={(values) => { - return true; + // Clear errors on successful submit + setErrorMessage(''); + setValidationError(''); }} />
diff --git a/examples/nextjs/pages/video-call-example.tsx b/examples/nextjs/pages/video-call-example.tsx new file mode 100644 index 000000000..50bd1987c --- /dev/null +++ b/examples/nextjs/pages/video-call-example.tsx @@ -0,0 +1,639 @@ +import { + LiveKitRoom, + ParticipantTile, + RoomAudioRenderer, + useTracks, + GridLayout, + TrackToggle, + MediaDeviceMenu, + useDisconnectButton, + PermissionsModal, + useTrackToggle, + DevicePermissionError, +} from '@livekit/components-react'; +import '@livekit/components-styles'; +import { LocalParticipant, Track } from 'livekit-client'; +import { useEffect, useState } from 'react'; + +interface CallData { + uuid: string; + token: string; + livekitServerUrl: string; + audioOptions: { echoCancellation: boolean; noiseSuppression: boolean }; + videoOptions: { resolution: { width: number; height: number } }; +} + +const TOGGLE_BACKGROUND = '#6d6d6d'; + +const gridLayoutStyles = ` + .permission-denied-toggle { + border-radius: 24px 0 0 24px; + height: 44px; + } + + .custom-grid-layout { + --lk-row-count: 1 !important; + --lk-col-count: 1 !important; + } + + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='true'] { + position: absolute !important; + top: 72px; + right: 12px; + width: 30%; + max-height: 50%; + z-index: 1; + border-radius: 8px; + aspect-ratio: 9 / 16; + } + + @media (min-width: 640px) { + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='true'] { + aspect-ratio: 16 / 9; + width: 25%; + } + } + + @media (min-width: 1024px) { + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='true'] { + top: 12px; + width: 20%; + } + } + + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='true'] .lk-participant-metadata-item:first-child { + display: none; + } + + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='true'] .lk-participant-metadata { + justify-content: flex-end; + } + + .custom-grid-layout .lk-participant-placeholder { + padding: 2px; + background: #2d3748; + border-radius: 0; + } + + .custom-grid-layout .lk-participant-placeholder svg { + padding: 0; + } + + .custom-grid-layout .lk-participant-tile[data-lk-video-muted='true'] { + background: #4a5568; + } + + .custom-grid-layout .lk-participant-tile[data-lk-video-muted='true'] svg { + max-height: 320px; + } + + .custom-grid-layout .lk-participant-metadata-item { + background: transparent; + color: white; + opacity: 1; + } + + .custom-grid-layout .lk-participant-tile[data-lk-video-muted='true'] .lk-participant-metadata-item { + color: #a0aec0; + } + + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='false'] .lk-participant-metadata { + justify-content: flex-start; + top: 80px; + left: 12px; + right: 0; + bottom: unset; + } + + @media (min-width: 1024px) { + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='false'] .lk-participant-metadata { + top: 12px; + } + } + + .custom-grid-layout .lk-participant-tile[data-lk-local-participant='false'] .lk-participant-placeholder { + background: #4a5568; + } + + .custom-grid-layout video { + object-fit: contain !important; + } +`; + +if (typeof document !== 'undefined' && !document.getElementById('custom-grid-styles')) { + const styleTag = document.createElement('style'); + styleTag.id = 'custom-grid-styles'; + styleTag.textContent = gridLayoutStyles; + document.head.appendChild(styleTag); +} + +const barStyle: React.CSSProperties = { + width: '100%', + position: 'absolute', + bottom: 0, + left: 0, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flexWrap: 'nowrap', + padding: '12px', + gap: '8px', + overflow: 'visible', + zIndex: 10, +}; + +const mediaControlStyle: React.CSSProperties = { + background: TOGGLE_BACKGROUND, + color: 'white', + borderColor: TOGGLE_BACKGROUND, + borderRadius: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + height: '44px', + position: 'relative', +}; + +const sectionStyle: React.CSSProperties = { + display: 'flex', + gap: '8px', +}; + +const disconnectBtnStyle: React.CSSProperties = { + background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%)', + color: 'white', + border: 'none', + borderRadius: '24px', + padding: '10px 20px', + fontSize: '14px', + fontWeight: 500, + cursor: 'pointer', + height: '44px', +}; + +function ControlBar({ + onPermissionModalOpen, + hide, +}: { + onPermissionModalOpen?: (permissions: { audio: boolean; video: boolean }) => void; + hide?: boolean; +}) { + const { buttonProps: disconnectProps } = useDisconnectButton({}); + const { permissionDenied: audioPermissionDenied } = useTrackToggle({ + source: Track.Source.Microphone, + }); + const { permissionDenied: videoPermissionDenied } = useTrackToggle({ + source: Track.Source.Camera, + }); + + const handleOpenPermissionModal = () => { + onPermissionModalOpen?.({ audio: audioPermissionDenied, video: videoPermissionDenied }); + }; + + const getMediaControlStyle = (permissionDenied: boolean): React.CSSProperties => ({ + ...mediaControlStyle, + ...(permissionDenied && { + background: '#ea4335', + borderColor: '#ea4335', + }), + }); + + if (hide) return null; + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ); +} + +function ProfileImage({ + label, + size = 'medium', + circle = false, +}: { + label?: string; + size?: 'small' | 'medium' | 'large' | 'flex'; + circle?: boolean; +}) { + const sizes = { + small: '40px', + medium: '80px', + large: '120px', + flex: '100%', + }; + + return ( +
+ {label || '👤'} +
+ ); +} + +function Text({ children, type = 'body' }: { children: React.ReactNode; type?: 'body' | 'body4' }) { + const styles: Record = { + body: { fontSize: '14px', fontWeight: 'normal' }, + body4: { fontSize: '16px', fontWeight: 'normal' }, + }; + + return

{children}

; +} + +function VideoPlaceholder({ + label, + size = 'medium', + circle = false, +}: { + label?: string; + size?: 'small' | 'medium' | 'large' | 'flex'; + circle?: boolean; +}) { + return ( +
+ +
+ ); +} + +const videosStyle: React.CSSProperties = { + height: '100%', + width: '100%', + position: 'relative', +}; + +const waitingTileStyle: React.CSSProperties = { + color: 'white', + background: 'black', + height: '100%', + width: '100%', + position: 'absolute', + top: 0, + left: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: '12px', + padding: '48px 32px', + borderRadius: '8px', +}; + +function MyVideoConference({ + partnerLabel, + partnerName, + selfLabel, + onPermissionModalOpen, + callRejected, + onCallAgain, +}: { + partnerLabel?: string; + partnerName?: string; + selfLabel?: string; + onPermissionModalOpen?: (permissions: { audio: boolean; video: boolean }) => void; + callRejected?: boolean; + onCallAgain?: () => void; +}) { + // `useTracks` returns all camera and screen share tracks. If a user + // joins without a published camera track, a placeholder track is returned. + const tracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: true }], { + onlySubscribed: true, // Include local participant's video + }); + const [currentParticipants, setCurrentParticipants] = useState(1); + const [otherUserDisconnected, setOtherUserDisconnected] = useState(false); + const { buttonProps: disconnectProps } = useDisconnectButton({}); + + useEffect(() => { + if (tracks.length === 1 && currentParticipants > 1) setOtherUserDisconnected(true); + setCurrentParticipants(tracks.length); + }, [tracks.length, currentParticipants]); + + const placeholders: Record = {}; + tracks.forEach((track) => { + if (track.participant) { + const isLocal = track?.participant instanceof LocalParticipant; + + placeholders[track.participant.identity] = ( + + ); + } + }); + + if (!tracks || tracks?.length === 0) return null; + + return ( +
+ {!callRejected && ( + + + + )} + + {tracks.length === 1 && ( +
+ + {callRejected ? ( +
+ + {partnerName || 'Participant'} + + rejected the call +
+ + +
+
+ ) : ( + + {otherUserDisconnected + ? `${partnerName || 'Participant'} disconnected` + : `Waiting for ${partnerName || 'other participant'}...`} + + )} +
+ )} + + +
+ ); +} + +// Mock profile data +const profile = { + label: 'Y', + name: 'You', +}; + +const partnerProfile = { + label: 'P', + name: 'Partner', +}; + +function VideoCallExample() { + // Mock call data - replace with your actual data + const [callData, setCallData] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const [showPermissionModal, setShowPermissionModal] = useState(false); + const [deniedPermissions, setDeniedPermissions] = useState<{ + audio: boolean; + video: boolean; + }>({ audio: false, video: false }); + + // Initialize call data on client side to avoid hydration errors + useEffect(() => { + setCallData({ + uuid: 'test-room-' + Date.now(), + token: process?.env?.NEXT_PUBLIC_LIVEKIT_TOKEN || '', + livekitServerUrl: process?.env?.NEXT_PUBLIC_LK_SERVER_URL || '', + audioOptions: { echoCancellation: true, noiseSuppression: true }, + videoOptions: { resolution: { width: 1280, height: 720 } }, + }); + }, []); + + const handleDisconnect = () => { + setIsConnected(false); + }; + + const handleConnected = () => { + setIsConnected(true); + }; + + const handleError = (err: Error) => { + setError(err.message); + if (err instanceof DevicePermissionError) { + // Merge the new error with existing permissions instead of replacing + setDeniedPermissions((prev) => ({ + ...prev, + audio: err.deviceType === 'audio' ? true : prev.audio, + video: err.deviceType === 'video' ? true : prev.video, + })); + setShowPermissionModal(true); + } + }; + + const handleCallAgain = () => { + setCallRejected(false); + }; + + const handleSimulateRejection = () => { + setCallRejected(true); + }; + + if (!callData) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+
+

Video Call Example

+

+ Status:{' '} + + {callRejected ? 'Call Rejected' : isConnected ? 'Connected' : 'Connecting...'} + +

+

+ Room: {callData?.uuid || 'Loading...'} +

+ {error && ( +
+ Error: {error} +
+ )} +
+

+ Audio Options: {JSON.stringify(callData?.audioOptions || {})} +

+

+ Video Options: {JSON.stringify(callData?.videoOptions || {})} +

+ +
+
+ +
+ + { + setDeniedPermissions(permissions); + setShowPermissionModal(true); + }} + /> + + +
+ +
+

Setup Instructions

+
    +
  1. Set NEXT_PUBLIC_LIVEKIT_TOKEN in your .env.local file
  2. +
  3. Set NEXT_PUBLIC_LIVEKIT_URL in your .env.local file
  4. +
  5. Open this page in two different browser tabs/windows to test the video call
  6. +
  7. You should see your video in one tile, waiting for the partner in the other
  8. +
+

+ Note: This example mimics your app's + structure with GridLayout, ParticipantTile, and simple letter placeholders (Y for You, P + for Partner). +

+

+ Permission Testing: Try denying camera/microphone permissions to see the + TrackToggle components automatically detect and display the permission denied state with + warning icons. Click on a denied toggle to see the permissions modal with instructions. +

+
+ + {showPermissionModal && ( + setShowPermissionModal(false)} + /> + )} +
+ ); +} + +export default VideoCallExample; diff --git a/packages/core/src/persistent-storage/user-choices.ts b/packages/core/src/persistent-storage/user-choices.ts index 3542cee05..001694b6e 100644 --- a/packages/core/src/persistent-storage/user-choices.ts +++ b/packages/core/src/persistent-storage/user-choices.ts @@ -1,5 +1,6 @@ import { cssPrefix } from '../constants'; import { createLocalStorageInterface } from './local-storage-helpers'; +import type { DeviceStatusInfo } from '../types'; const USER_CHOICES_KEY = `${cssPrefix}-user-choices` as const; @@ -34,6 +35,12 @@ export type LocalUserChoices = { * @defaultValue `''` */ username: string; + /** + * The actual status of audio and video devices. + * This reflects the real device capabilities and permissions. + * @defaultValue `{ audio: 'disabled', video: 'disabled' }` + */ + deviceStatus: DeviceStatusInfo; }; export const defaultUserChoices: LocalUserChoices = { @@ -42,6 +49,10 @@ export const defaultUserChoices: LocalUserChoices = { videoDeviceId: 'default', audioDeviceId: 'default', username: '', + deviceStatus: { + audio: 'disabled', + video: 'disabled', + }, } as const; /** @@ -91,6 +102,7 @@ export function loadUserChoices( videoDeviceId: defaults?.videoDeviceId ?? defaultUserChoices.videoDeviceId, audioDeviceId: defaults?.audioDeviceId ?? defaultUserChoices.audioDeviceId, username: defaults?.username ?? defaultUserChoices.username, + deviceStatus: defaults?.deviceStatus ?? defaultUserChoices.deviceStatus, }; if (preventLoad) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cdbf36aa4..e8246dd67 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -88,3 +88,18 @@ export type RequireOnlyOne = Pick): Participant[]; diff --git a/packages/react/livekit-components-react-2.9.16.tgz b/packages/react/livekit-components-react-2.9.16.tgz new file mode 100644 index 000000000..26109d9ff Binary files /dev/null and b/packages/react/livekit-components-react-2.9.16.tgz differ diff --git a/packages/react/package.json b/packages/react/package.json index b2b471bf2..987068d35 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@livekit/components-react", - "version": "2.9.14", + "version": "2.9.16", "license": "Apache-2.0", "author": "LiveKit", "repository": { @@ -52,13 +52,13 @@ }, "typings": "dist/index.d.ts", "dependencies": { - "@livekit/components-core": "workspace:*", + "@livekit/components-core": "^0.12.9", "clsx": "2.1.1", "usehooks-ts": "3.1.1" }, "peerDependencies": { "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0", - "livekit-client": "catalog:", + "livekit-client": "^2.13.3", "react": ">=18", "react-dom": ">=18", "tslib": "^2.6.2" diff --git a/packages/react/src/assets/icons/ExclamationIcon.tsx b/packages/react/src/assets/icons/ExclamationIcon.tsx new file mode 100644 index 000000000..d640eb2c9 --- /dev/null +++ b/packages/react/src/assets/icons/ExclamationIcon.tsx @@ -0,0 +1,19 @@ +/** + * WARNING: This file was auto-generated by svgr. Do not edit. + */ +import * as React from 'react'; +import type { SVGProps } from 'react'; +/** + * @internal + */ +const SvgExclamationIcon = (props: SVGProps) => ( + + + + +); +export default SvgExclamationIcon; diff --git a/packages/react/src/assets/icons/index.ts b/packages/react/src/assets/icons/index.ts index 7425b1e9a..9d89c304b 100644 --- a/packages/react/src/assets/icons/index.ts +++ b/packages/react/src/assets/icons/index.ts @@ -3,6 +3,7 @@ export { default as CameraIcon } from './CameraIcon'; export { default as ChatCloseIcon } from './ChatCloseIcon'; export { default as ChatIcon } from './ChatIcon'; export { default as Chevron } from './Chevron'; +export { default as ExclamationIcon } from './ExclamationIcon'; export { default as FocusToggleIcon } from './FocusToggleIcon'; export { default as GearIcon } from './GearIcon'; export { default as LeaveIcon } from './LeaveIcon'; diff --git a/packages/react/src/assets/images/GrantPermissions.tsx b/packages/react/src/assets/images/GrantPermissions.tsx new file mode 100644 index 000000000..8b396240e --- /dev/null +++ b/packages/react/src/assets/images/GrantPermissions.tsx @@ -0,0 +1,214 @@ +/** + * WARNING: This file was auto-generated by svgr. Do not edit. + */ +import * as React from 'react'; +import type { SVGProps } from 'react'; +/** + * @internal + */ +const SvgGrantPermissions = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default SvgGrantPermissions; diff --git a/packages/react/src/assets/images/index.ts b/packages/react/src/assets/images/index.ts index 3421a15ff..30dc959c1 100644 --- a/packages/react/src/assets/images/index.ts +++ b/packages/react/src/assets/images/index.ts @@ -1 +1,2 @@ +export { default as GrantPermissions } from './GrantPermissions'; export { default as ParticipantPlaceholder } from './ParticipantPlaceholder'; diff --git a/packages/react/src/components/PermissionsModal.tsx b/packages/react/src/components/PermissionsModal.tsx new file mode 100644 index 000000000..4a2f28e8e --- /dev/null +++ b/packages/react/src/components/PermissionsModal.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { ChatCloseIcon } from '../assets/icons'; +import { GrantPermissions } from '../assets/images'; +import { getPrejoinTranslations, type PrejoinLanguage } from '../prefabs/prejoinTranslations'; + +export interface PermissionsModalProps { + language?: PrejoinLanguage; + deniedPermissions: { audio: boolean; video: boolean }; + onClose: () => void; +} + +export function PermissionsModal({ + language = 'en', + deniedPermissions, + onClose, +}: PermissionsModalProps) { + const t = getPrejoinTranslations(language); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!mounted) { + return null; + } + + const modalContent = ( +
{ + onClose(); + }} + > +
e.stopPropagation()}> + + +
+ +
+ +
+

+ {(deniedPermissions.audio && deniedPermissions.video) || + (!deniedPermissions.audio && !deniedPermissions.video) + ? t.permissionTitleBoth + : deniedPermissions.audio + ? t.permissionTitleMic + : t.permissionTitleCam} +

+

+ {`${ + (deniedPermissions.audio && deniedPermissions.video) || + (!deniedPermissions.audio && !deniedPermissions.video) + ? t.blockedBoth + : deniedPermissions.audio + ? t.blockedMic + : t.blockedCam + } ${t.toEnableAccess}`} +

+
    +
  1. {t.step1}
  2. +
  3. + {(deniedPermissions.audio && deniedPermissions.video) || + (!deniedPermissions.audio && !deniedPermissions.video) + ? t.step2Both + : deniedPermissions.audio + ? t.step2Mic + : t.step2Cam} +
  4. +
  5. {t.step3}
  6. +
+
+
+
+ ); + + return createPortal(modalContent, document.body); +} + +export default PermissionsModal; diff --git a/packages/react/src/components/controls/TrackToggle.tsx b/packages/react/src/components/controls/TrackToggle.tsx index 86e5d3390..b20e3957c 100644 --- a/packages/react/src/components/controls/TrackToggle.tsx +++ b/packages/react/src/components/controls/TrackToggle.tsx @@ -1,8 +1,9 @@ import type { CaptureOptionsBySource, ToggleSource } from '@livekit/components-core'; import * as React from 'react'; +import { ExclamationIcon } from '../../assets/icons'; import { getSourceIcon } from '../../assets/icons/util'; import { useTrackToggle } from '../../hooks'; -import { TrackPublishOptions } from 'livekit-client'; +import type { TrackPublishOptions } from 'livekit-client'; /** @public */ export interface TrackToggleProps @@ -10,6 +11,8 @@ export interface TrackToggleProps source: T; showIcon?: boolean; initialState?: boolean; + /** When true, renders a disabled-style toggle that indicates permissions are denied. */ + permissionDenied?: boolean; /** * Function that is called when the enabled state of the toggle changes. * The second function argument `isUserInitiated` is `true` if the change was initiated by a user interaction, such as a click. @@ -33,22 +36,56 @@ export interface TrackToggleProps * ``` * @public */ -export const TrackToggle: ( - props: TrackToggleProps & React.RefAttributes, -) => React.ReactNode = /* @__PURE__ */ React.forwardRef(function TrackToggle< - T extends ToggleSource, ->({ showIcon, ...props }: TrackToggleProps, ref: React.ForwardedRef) { - const { buttonProps, enabled } = useTrackToggle(props); +export const TrackToggle = React.forwardRef(function TrackToggle( + { showIcon, permissionDenied: permissionDeniedProp, ...props }: TrackToggleProps, + ref: React.ForwardedRef, +) { + const { + buttonProps, + enabled, + permissionDenied: permissionDeniedFromHook, + } = useTrackToggle(props); const [isClient, setIsClient] = React.useState(false); + React.useEffect(() => { setIsClient(true); }, []); - return ( - isClient && ( - - ) + ); + } + + return ( + ); -}); +}) as ( + props: TrackToggleProps & React.RefAttributes, +) => React.ReactElement | null; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index ca61b230a..eeb7d9078 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -17,6 +17,7 @@ export * from './participant/VideoTrack'; export * from './participant/ParticipantName'; export * from './participant/TrackMutedIndicator'; export * from './ParticipantLoop'; +export * from './PermissionsModal'; export { RoomAudioRenderer, type RoomAudioRendererProps } from './RoomAudioRenderer'; export * from './RoomName'; export { Toast } from './Toast'; diff --git a/packages/react/src/components/participant/ParticipantTile.tsx b/packages/react/src/components/participant/ParticipantTile.tsx index 5d0706e0b..5870e5055 100644 --- a/packages/react/src/components/participant/ParticipantTile.tsx +++ b/packages/react/src/components/participant/ParticipantTile.tsx @@ -73,6 +73,7 @@ export interface ParticipantTileProps extends React.HTMLAttributes void; + placeholders?: { [index: string]: React.ReactNode }; } /** @@ -100,6 +101,7 @@ export const ParticipantTile: ( children, onParticipantClick, disableSpeakingIndicator, + placeholders, ...htmlProps }: ParticipantTileProps, ref, @@ -156,7 +158,9 @@ export const ParticipantTile: ( ) )}
- + {placeholders?.[trackReference.participant?.identity] ?? ( + + )}
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index cdd9ed342..56dd16b57 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -29,6 +29,7 @@ export { usePinnedTracks } from './usePinnedTracks'; export { type UseRemoteParticipantOptions, useRemoteParticipant } from './useRemoteParticipant'; export { type UseRemoteParticipantsOptions, useRemoteParticipants } from './useRemoteParticipants'; export { type UseRoomInfoOptions, useRoomInfo } from './useRoomInfo'; +export { useSelectedDevice } from './useSelectedDevice'; export { useSortedParticipants } from './useSortedParticipants'; export { useSpeakingParticipants } from './useSpeakingParticipants'; export { type UseStartAudioProps, useStartAudio } from './useStartAudio'; @@ -56,3 +57,4 @@ export * from './useParticipantAttributes'; export * from './useIsRecording'; export * from './useTextStream'; export * from './useTranscriptions'; +export { useDeviceState } from './useDeviceState'; diff --git a/packages/react/src/hooks/useDeviceState.ts b/packages/react/src/hooks/useDeviceState.ts new file mode 100644 index 000000000..3875504cf --- /dev/null +++ b/packages/react/src/hooks/useDeviceState.ts @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { useMediaDevices } from './useMediaDevices'; +import type { DeviceStatus, DeviceStatusInfo } from '@livekit/components-core'; + +/** + * Hook that manages the real-time state of media devices including permissions and availability + * @public + */ +export function useDeviceState() { + const [deviceStatus, setDeviceStatus] = React.useState({ + audio: 'disabled', + video: 'disabled', + }); + + const [permissionErrors, setPermissionErrors] = React.useState<{ + audio?: Error; + video?: Error; + }>({}); + + // Get device lists with permission requests + const audioDevices = useMediaDevices({ + kind: 'audioinput', + onError: (error) => setPermissionErrors((prev) => ({ ...prev, audio: error })), + }); + + const videoDevices = useMediaDevices({ + kind: 'videoinput', + onError: (error) => setPermissionErrors((prev) => ({ ...prev, video: error })), + }); + + // Update device status based on device availability and errors + React.useEffect(() => { + const newStatus: DeviceStatusInfo = { + audio: determineDeviceStatus(audioDevices, permissionErrors.audio), + video: determineDeviceStatus(videoDevices, permissionErrors.video), + }; + + setDeviceStatus(newStatus); + }, [audioDevices, videoDevices, permissionErrors]); + + // Clear permission errors when devices become available + React.useEffect(() => { + if (audioDevices.length > 0 && permissionErrors.audio) { + setPermissionErrors((prev) => ({ ...prev, audio: undefined })); + } + }, [audioDevices, permissionErrors.audio]); + + React.useEffect(() => { + if (videoDevices.length > 0 && permissionErrors.video) { + setPermissionErrors((prev) => ({ ...prev, video: undefined })); + } + }, [videoDevices, permissionErrors.video]); + + return { + deviceStatus, + permissionErrors, + audioDevices, + videoDevices, + // Helper to check if a device type is available + isDeviceAvailable: (type: 'audio' | 'video') => deviceStatus[type] === 'available', + // Helper to check if a device type has permission issues + hasPermissionError: (type: 'audio' | 'video') => !!permissionErrors[type], + }; +} + +/** + * Determines the status of a device type based on available devices and any errors + */ +function determineDeviceStatus(devices: MediaDeviceInfo[], error?: Error): DeviceStatus { + if (error) { + if (error.name === 'NotAllowedError') { + return 'permission-denied'; + } + return 'error'; + } + + if (devices.length === 0) { + return 'no-devices'; + } + + return 'available'; +} + diff --git a/packages/react/src/hooks/useLiveKitRoom.ts b/packages/react/src/hooks/useLiveKitRoom.ts index 0e01d7be7..acf49197a 100644 --- a/packages/react/src/hooks/useLiveKitRoom.ts +++ b/packages/react/src/hooks/useLiveKitRoom.ts @@ -6,6 +6,7 @@ import type { HTMLAttributes } from 'react'; import type { LiveKitRoomProps } from '../components'; import { mergeProps } from '../mergeProps'; +import { DevicePermissionError } from '../prefabs/PreJoin'; import { roomOptionsStringifyReplacer } from '../utils'; const defaultRoomProps: Partial = { @@ -68,23 +69,97 @@ export function useLiveKitRoom( return mergeProps(rest, { className }) as HTMLAttributes; }, [rest]); + // Utility to detect permission-denied style errors across browsers/wrappers + const isDeniedError = React.useCallback((err: Error): boolean => { + const name = (err.name || '').toLowerCase(); + const message = (err.message || '').toLowerCase(); + return ( + name.includes('notallowed') || + name.includes('permissiondenied') || + name.includes('security') || + message.includes('permission denied') || + message.includes('denied by system') || + message.includes('blocked') + ); + }, []); + React.useEffect(() => { if (!room) return; const onSignalConnected = () => { const localP = room.localParticipant; log.debug('trying to publish local tracks'); - Promise.all([ - localP.setMicrophoneEnabled(!!audio, typeof audio !== 'boolean' ? audio : undefined), - localP.setCameraEnabled(!!video, typeof video !== 'boolean' ? video : undefined), - localP.setScreenShareEnabled(!!screen, typeof screen !== 'boolean' ? screen : undefined), - ]).catch((e) => { - log.warn(e); - onError?.(e as Error); + + // Handle each track type separately to provide granular error handling + const enableAudio = async () => { + if (audio) { + try { + await localP.setMicrophoneEnabled(true, typeof audio !== 'boolean' ? audio : undefined); + } catch (e) { + const error = e as Error; + log.warn('Failed to enable microphone:', error); + + // Wrap permission errors with device context for better handling + const errorToReport = isDeniedError(error) + ? new DevicePermissionError(error, 'audio') + : error; + + // Don't call onError here - will be called after all tracks are attempted + throw errorToReport; + } + } + }; + + const enableVideo = async () => { + if (video) { + try { + await localP.setCameraEnabled(true, typeof video !== 'boolean' ? video : undefined); + } catch (e) { + const error = e as Error; + log.warn('Failed to enable camera:', error); + + // Wrap permission errors with device context for better handling + const errorToReport = isDeniedError(error) + ? new DevicePermissionError(error, 'video') + : error; + + // Don't call onError here - will be called after all tracks are attempted + throw errorToReport; + } + } + }; + + const enableScreen = async () => { + if (screen) { + try { + await localP.setScreenShareEnabled( + true, + typeof screen !== 'boolean' ? screen : undefined, + ); + } catch (e) { + log.warn('Failed to enable screen share:', e); + // Don't call onError here - will be called after all tracks are attempted + throw e as Error; + } + } + }; + + // Run all enables, then report errors after all attempts complete + Promise.allSettled([enableAudio(), enableVideo(), enableScreen()]).then((results) => { + const failures = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; + + if (failures.length > 0) { + log.debug(`${failures.length} track(s) failed to enable`); + + // Call onError for each failure sequentially + failures.forEach((failure) => { + onError?.(failure.reason as Error); + }); + } }); }; - const handleMediaDeviceError = (e: Error, kind: MediaDeviceKind) => { + const handleMediaDeviceError = (e: Error, kind?: MediaDeviceKind) => { const mediaDeviceFailure = MediaDeviceFailure.getFailure(e); onMediaDeviceFailure?.(mediaDeviceFailure, kind); }; @@ -123,6 +198,7 @@ export function useLiveKitRoom( onMediaDeviceFailure, onConnected, onDisconnected, + isDeniedError, ]); React.useEffect(() => { diff --git a/packages/react/src/hooks/useMediaDevices.ts b/packages/react/src/hooks/useMediaDevices.ts index 926e57f4c..0f3d2de31 100644 --- a/packages/react/src/hooks/useMediaDevices.ts +++ b/packages/react/src/hooks/useMediaDevices.ts @@ -15,13 +15,15 @@ import { createMediaDeviceObserver } from '@livekit/components-core'; export function useMediaDevices({ kind, onError, + requestPermissions, }: { kind: MediaDeviceKind; onError?: (e: Error) => void; + requestPermissions?: boolean; }) { const deviceObserver = React.useMemo( - () => createMediaDeviceObserver(kind, onError), - [kind, onError], + () => createMediaDeviceObserver(kind, onError, requestPermissions), + [kind, onError, requestPermissions], ); const devices = useObservableState(deviceObserver, [] as MediaDeviceInfo[]); return devices; diff --git a/packages/react/src/hooks/useSelectedDevice.ts b/packages/react/src/hooks/useSelectedDevice.ts new file mode 100644 index 000000000..c10f75b28 --- /dev/null +++ b/packages/react/src/hooks/useSelectedDevice.ts @@ -0,0 +1,76 @@ +import { type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client'; +import * as React from 'react'; +import { useMediaDevices } from './useMediaDevices'; + +/** + * /** + * The `useSelectedDevice` hook returns the current selected device (audio or video) of the participant. + * + * @example + * ```tsx + * const { selectedDevice } = useSelectedDevice({ + * kind: 'videoinput', + * track: track, + * }); + * + *
+ * {selectedDevice?.label} + *
+ * ``` + * @public + */ +export function useSelectedDevice({ + kind, + track, + deviceId, +}: { + kind: 'videoinput' | 'audioinput'; + track?: T; + deviceId?: string; +}) { + const [deviceError, setDeviceError] = React.useState(null); + + // Request permissions when no track exists to get device labels + const devices = useMediaDevices({ + kind, + requestPermissions: !track, + }); + const [selectedDevice, setSelectedDevice] = React.useState( + undefined, + ); + const [localDeviceId, setLocalDeviceId] = React.useState(deviceId); + + const prevDeviceId = React.useRef(localDeviceId); + + const getDeviceId = async () => { + try { + const newDeviceId = await track?.getDeviceId(false); + if (newDeviceId && localDeviceId !== newDeviceId) { + prevDeviceId.current = newDeviceId; + setLocalDeviceId(newDeviceId); + } + } catch (e) { + if (e instanceof Error) { + setDeviceError(e); + } + } + }; + + React.useEffect(() => { + if (track) getDeviceId(); + }, [track]); + + React.useEffect(() => { + // in case track doesn't exist, utilize the deviceId passed in + if (!track) setLocalDeviceId(deviceId); + }, [deviceId]); + + React.useEffect(() => { + setSelectedDevice(devices?.find((dev) => dev.deviceId === localDeviceId)); + }, [localDeviceId, devices]); + + return { + selectedDevice, + deviceError, + }; +} diff --git a/packages/react/src/hooks/useTrackToggle.ts b/packages/react/src/hooks/useTrackToggle.ts index 4e3a1410c..3aac58dd3 100644 --- a/packages/react/src/hooks/useTrackToggle.ts +++ b/packages/react/src/hooks/useTrackToggle.ts @@ -1,5 +1,6 @@ import type { ToggleSource } from '@livekit/components-core'; import { setupMediaToggle, setupManualToggle, log } from '@livekit/components-core'; +import { Track, RoomEvent } from 'livekit-client'; import * as React from 'react'; import type { TrackToggleProps } from '../components'; import { useMaybeRoomContext } from '../context'; @@ -34,6 +35,7 @@ export function useTrackToggle({ const track = room?.localParticipant?.getTrackPublication(source); /** `true` if a user interaction such as a click on the TrackToggle button has occurred. */ const userInteractionRef = React.useRef(false); + const [permissionDenied, setPermissionDenied] = React.useState(false); const { toggle, className, pendingObserver, enabledObserver } = React.useMemo( () => @@ -46,6 +48,47 @@ export function useTrackToggle({ const pending = useObservableState(pendingObserver, false); const enabled = useObservableState(enabledObserver, initialState ?? !!track?.isEnabled); + // Listen for device errors to detect permission denied + React.useEffect(() => { + if (!room) return; + + const handleDeviceError = (error: Error, kind?: MediaDeviceKind) => { + // Check if it's a permission denied error + const name = (error.name || '').toLowerCase(); + const message = (error.message || '').toLowerCase(); + const isPermissionError = + name.includes('notallowed') || + name.includes('permissiondenied') || + name.includes('security') || + message.includes('permission denied') || + message.includes('denied by system') || + message.includes('blocked'); + + if (!isPermissionError) return; + + // Determine if this error is for the current source + const errorMsg = message; + const isMicrophoneError = + kind === 'audioinput' || errorMsg.includes('microphone') || errorMsg.includes('audio'); + const isCameraError = + kind === 'videoinput' || errorMsg.includes('camera') || errorMsg.includes('video'); + + if ( + (source === Track.Source.Microphone && isMicrophoneError) || + (source === Track.Source.Camera && isCameraError) + ) { + setPermissionDenied(true); + } + }; + + // Listen to room MediaDevicesError events + room.on(RoomEvent.MediaDevicesError, handleDeviceError); + + return () => { + room.off(RoomEvent.MediaDevicesError, handleDeviceError); + }; + }, [room, source]); + React.useEffect(() => { onChange?.(enabled, userInteractionRef.current); userInteractionRef.current = false; @@ -75,13 +118,14 @@ export function useTrackToggle({ toggle, enabled, pending, + permissionDenied, track, buttonProps: { ...newProps, 'aria-pressed': enabled, 'data-lk-source': source, 'data-lk-enabled': enabled, - disabled: pending, + disabled: pending || (permissionDenied && !rest.onClick), onClick: clickHandler, } as React.ButtonHTMLAttributes, }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 035c7102f..4b701bcd8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -27,3 +27,8 @@ export type { GridLayoutDefinition, TextStreamData, } from '@livekit/components-core'; + +// Export types from React package +export type { PreJoinValues } from './prefabs/PreJoin'; +export type { PrejoinLanguage } from './prefabs/prejoinTranslations'; +export { DevicePermissionError } from './prefabs/PreJoin'; diff --git a/packages/react/src/prefabs/PreJoin.tsx b/packages/react/src/prefabs/PreJoin.tsx index 102cb5fe0..204ca0e11 100644 --- a/packages/react/src/prefabs/PreJoin.tsx +++ b/packages/react/src/prefabs/PreJoin.tsx @@ -15,14 +15,55 @@ import { Mutex, } from 'livekit-client'; import * as React from 'react'; +import { getPrejoinTranslations, type PrejoinLanguage } from './prejoinTranslations'; import { MediaDeviceMenu } from './MediaDeviceMenu'; import { TrackToggle } from '../components/controls/TrackToggle'; import type { LocalUserChoices } from '@livekit/components-core'; import { log } from '@livekit/components-core'; import { ParticipantPlaceholder } from '../assets/images'; +import { PermissionsModal } from '../components/PermissionsModal'; import { useMediaDevices, usePersistentUserChoices } from '../hooks'; import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles'; import { roomOptionsStringifyReplacer } from '../utils'; +import { useSelectedDevice } from '../hooks/useSelectedDevice'; + +// Device status type definition +type DeviceStatus = 'available' | 'permission-denied' | 'no-devices' | 'disabled' | 'error'; + +/** + * Enhanced error class that includes device information for better error handling. + * This extends the original error to preserve all its properties while adding device context. + * @public + */ +export class DevicePermissionError extends Error { + public deviceType: 'audio' | 'video'; + public deviceId?: string; + + constructor(originalError: Error, deviceType: 'audio' | 'video', deviceId?: string) { + // Call the parent Error constructor with the original error's message + super(originalError.message); + + // Preserve all properties from the original error + Object.assign(this, originalError); + + // Add our custom properties + this.deviceType = deviceType; + this.deviceId = deviceId; + this.name = 'DevicePermissionError'; + + // Maintain proper prototype chain for instanceof checks + Object.setPrototypeOf(this, DevicePermissionError.prototype); + } +} + +/** + * Extended values type that includes both user choices and device availability + * @public + */ +export type PreJoinValues = LocalUserChoices & { + audioAvailable: boolean; + videoAvailable: boolean; +}; /** * Props for the PreJoin component. @@ -30,12 +71,17 @@ import { roomOptionsStringifyReplacer } from '../utils'; */ export interface PreJoinProps extends Omit, 'onSubmit' | 'onError'> { - /** This function is called with the `LocalUserChoices` if validation is passed. */ - onSubmit?: (values: LocalUserChoices) => void; + /** This function is called with the `PreJoinValues` if validation is passed. */ + onSubmit?: (values: PreJoinValues) => void; /** * Provide your custom validation function. Only if validation is successful the user choices are past to the onSubmit callback. */ - onValidate?: (values: LocalUserChoices) => boolean; + onValidate?: (values: PreJoinValues) => boolean; + /** + * Called when an error occurs during device setup. Permission errors will be wrapped in + * `DevicePermissionError` with device context. Other errors (NotFoundError, NotReadableError, etc.) + * are passed through as-is. + */ onError?: (error: Error) => void; /** Prefill the input form with initial values. */ defaults?: Partial; @@ -45,6 +91,8 @@ export interface PreJoinProps micLabel?: string; camLabel?: string; userLabel?: string; + /** Language for built-in labels/text. */ + language?: PrejoinLanguage; /** * If true, user choices are persisted across sessions. * @defaultValue true @@ -58,47 +106,198 @@ export interface PreJoinProps export function usePreviewTracks( options: CreateLocalTracksOptions, onError?: (err: Error) => void, + setPermissionErrors?: React.Dispatch>, ) { - const [tracks, setTracks] = React.useState(); + const [audioTrack, setAudioTrack] = React.useState(); + const [videoTrack, setVideoTrack] = React.useState(); + + const [orphanTracks, setOrphanTracks] = React.useState([]); + + React.useEffect( + () => () => { + orphanTracks.forEach((track) => track.stop()); + }, + [orphanTracks], + ); const trackLock = React.useMemo(() => new Mutex(), []); + // Utility to detect permission-denied style errors across browsers/wrappers + const isDeniedError = (err: Error | undefined): boolean => { + if (!err) return false; + const name = (err.name || '').toLowerCase(); + const message = (err.message || '').toLowerCase(); + return ( + name.includes('notallowed') || + name.includes('permissiondenied') || + name.includes('security') || + message.includes('permission denied') || + message.includes('denied by system') || + message.includes('blocked') + ); + }; + + // Store current tracks in refs to avoid dependency cycles + const audioTrackRef = React.useRef(audioTrack); + const videoTrackRef = React.useRef(videoTrack); + + // Update refs when state changes React.useEffect(() => { - let needsCleanup = false; - let localTracks: Array = []; - trackLock.lock().then(async (unlock) => { - try { - if (options.audio || options.video) { - localTracks = await createLocalTracks(options); + audioTrackRef.current = audioTrack; + }, [audioTrack]); + + React.useEffect(() => { + videoTrackRef.current = videoTrack; + }, [videoTrack]); - if (needsCleanup) { - localTracks.forEach((tr) => tr.stop()); + // Shared function to handle track creation and cleanup + const handleTrackCreation = React.useCallback( + ( + trackType: 'audio' | 'video', + trackOption: CreateLocalTracksOptions['audio'] | CreateLocalTracksOptions['video'] | false, + setTrack: React.Dispatch>, + setPermissionErrors: React.Dispatch>, + ) => { + log.debug(`[PreJoin] handleTrackCreation called for ${trackType}`, { trackOption }); + + if (!trackOption) { + log.debug(`[PreJoin] ${trackType} track disabled, skipping creation`); + const currentTrack = trackType === 'audio' ? audioTrackRef.current : videoTrackRef.current; + + if (currentTrack) { + setOrphanTracks((prev) => [...prev, currentTrack]); + setTrack(undefined); + } + return; + } + + let isCancelled = false; + let localTrack: LocalTrack | undefined; + + trackLock.lock().then(async (unlock) => { + try { + log.debug(`[PreJoin] Attempting to create ${trackType} track`); + + // Check if cancelled before creating tracks + if (isCancelled) { + log.debug(`[PreJoin] ${trackType} track creation cancelled before start`); + return; + } + + const trackOptions: CreateLocalTracksOptions = { + audio: + trackType === 'audio' ? (trackOption as CreateLocalTracksOptions['audio']) : false, + video: + trackType === 'video' ? (trackOption as CreateLocalTracksOptions['video']) : false, + }; + + log.debug(`[PreJoin] Calling createLocalTracks for ${trackType}`, trackOptions); + const tracks = await createLocalTracks(trackOptions); + log.debug(`[PreJoin] Successfully created ${trackType} track`); + + // Check if cancelled after creating tracks + if (isCancelled) { + // Clean up the tracks we just created + tracks.forEach((track) => track.stop()); + return; + } + + localTrack = tracks.find((track) => track.kind === trackType); + + if (localTrack) { + // Stop previous track if it exists + const currentTrack = + trackType === 'audio' ? audioTrackRef.current : videoTrackRef.current; + + if (currentTrack) { + setOrphanTracks((prev) => [...prev, currentTrack]); + } + setTrack(localTrack); + } + } catch (e: unknown) { + log.error(`[PreJoin] Error creating ${trackType} track:`, e); + + if (onError && e instanceof Error) { + // Extract device ID for error context + const deviceId = + typeof trackOption === 'object' && trackOption !== null + ? typeof trackOption.deviceId === 'string' + ? trackOption.deviceId + : Array.isArray(trackOption.deviceId) + ? trackOption.deviceId[0] + : undefined + : undefined; + + // Wrap permission errors with enhanced context + if (isDeniedError(e)) { + log.debug(`[PreJoin] ${trackType} permission denied, calling onError`); + const enhancedError = new DevicePermissionError(e, trackType, deviceId); + onError(enhancedError); + + // Track permission errors for UI feedback + setPermissionErrors((prev) => ({ ...prev, [trackType]: enhancedError })); + } else { + // For other errors (NotFoundError, NotReadableError, etc.), + // still wrap with device context so consumers know which track failed + log.debug( + `[PreJoin] ${trackType} error (non-permission), calling onError with context`, + ); + const errorWithContext = new DevicePermissionError(e, trackType, deviceId); + errorWithContext.name = e.name; // Preserve original error name + onError(errorWithContext); + } } else { - setTracks(localTracks); + log.error(e); } + } finally { + unlock(); } - } catch (e: unknown) { - if (onError && e instanceof Error) { - onError(e); - } else { - log.error(e); + }); + + return () => { + isCancelled = true; + if (localTrack) { + localTrack.stop(); } - } finally { - unlock(); - } - }); + }; + }, + [trackLock, onError, setOrphanTracks], + ); - return () => { - needsCleanup = true; - localTracks.forEach((track) => { - track.stop(); - }); - }; - }, [JSON.stringify(options, roomOptionsStringifyReplacer), onError, trackLock]); + // Memoize the stringified options to prevent unnecessary re-renders + const audioOptionsString = React.useMemo( + () => JSON.stringify(options.audio, roomOptionsStringifyReplacer), + [options.audio], + ); + const videoOptionsString = React.useMemo( + () => JSON.stringify(options.video, roomOptionsStringifyReplacer), + [options.video], + ); + + // Create stable fallback function for setPermissionErrors + const noopSetPermissionErrors = React.useCallback(() => {}, []); + const stableSetPermissionErrors = setPermissionErrors || noopSetPermissionErrors; + + // Handle audio track + React.useEffect(() => { + return handleTrackCreation('audio', options.audio, setAudioTrack, stableSetPermissionErrors); + }, [handleTrackCreation, audioOptionsString, stableSetPermissionErrors]); + + // Handle video track + React.useEffect(() => { + return handleTrackCreation('video', options.video, setVideoTrack, stableSetPermissionErrors); + }, [handleTrackCreation, videoOptionsString, stableSetPermissionErrors]); + + // Combine tracks for the return value + const tracks = React.useMemo(() => { + const result: LocalTrack[] = []; + if (audioTrack) result.push(audioTrack); + if (videoTrack) result.push(videoTrack); + return result.length > 0 ? result : undefined; + }, [audioTrack, videoTrack]); return tracks; } - /** * @public * @deprecated use `usePreviewTracks` instead @@ -108,7 +307,7 @@ export function usePreviewDevice( deviceId: string, kind: 'videoinput' | 'audioinput', ) { - const [deviceError, setDeviceError] = React.useState(null); + const [deviceError, setDeviceError] = React.useState(null); const [isCreatingTrack, setIsCreatingTrack] = React.useState(false); const devices = useMediaDevices({ kind }); @@ -141,7 +340,13 @@ export function usePreviewDevice( setLocalTrack(track as T); } catch (e) { if (e instanceof Error) { - setDeviceError(e); + // Create enhanced error with device context + const enhancedError = new DevicePermissionError( + e, + kind === 'videoinput' ? 'video' : 'audio', + deviceId, + ); + setDeviceError(enhancedError); } } }; @@ -154,7 +359,7 @@ export function usePreviewDevice( const prevDeviceId = React.useRef(localDeviceId); React.useEffect(() => { - if (enabled && !localTrack && !deviceError && !isCreatingTrack) { + if (!isCreatingTrack && enabled && !localTrack && !deviceError) { log.debug('creating track', kind); setIsCreatingTrack(true); createTrack(localDeviceId, kind).finally(() => { @@ -222,14 +427,20 @@ export function PreJoin({ onSubmit, onError, debug, - joinLabel = 'Join Room', - micLabel = 'Microphone', - camLabel = 'Camera', - userLabel = 'Username', + language = 'en', + joinLabel: joinLabelProp, + micLabel: micLabelProp, + camLabel: camLabelProp, + userLabel: userLabelProp, persistUserChoices = true, videoProcessor, ...htmlProps }: PreJoinProps) { + const t = getPrejoinTranslations(language); + const joinLabel = joinLabelProp ?? t.join; + const micLabel = micLabelProp ?? t.microphone; + const camLabel = camLabelProp ?? t.camera; + const userLabel = userLabelProp ?? t.username; const { userChoices: initialUserChoices, saveAudioInputDeviceId, @@ -243,6 +454,10 @@ export function PreJoin({ preventLoad: !persistUserChoices, }); + // Get device lists for availability checking (only when needed) + const audioDevices = useMediaDevices({ kind: 'audioinput' }); + const videoDevices = useMediaDevices({ kind: 'videoinput' }); + const [userChoices, setUserChoices] = React.useState(initialUserChoices); // Initialize device settings @@ -252,6 +467,74 @@ export function PreJoin({ const [videoDeviceId, setVideoDeviceId] = React.useState(userChoices.videoDeviceId); const [username, setUsername] = React.useState(userChoices.username); + // Track permission errors + const [permissionErrors, setPermissionErrors] = React.useState<{ + audio?: Error; + video?: Error; + }>({}); + + // Track if we should show permission instructions modal + const [showPermissionModal, setShowPermissionModal] = React.useState(false); + + // Track which permissions are denied for modal content + const [deniedPermissions, setDeniedPermissions] = React.useState<{ + audio: boolean; + video: boolean; + }>({ audio: false, video: false }); + + // Enhanced device availability and permission checking + const isDeviceAvailable = React.useCallback( + (type: 'audio' | 'video') => { + const devices = type === 'audio' ? audioDevices : videoDevices; + return devices.length > 0; + }, + [audioDevices, videoDevices], + ); + + const hasPermissionError = React.useCallback( + (type: 'audio' | 'video') => { + return !!permissionErrors[type]; + }, + [permissionErrors], + ); + + const isPermissionDenied = React.useCallback( + (type: 'audio' | 'video') => { + const error = permissionErrors[type]; + if (!error) return false; + const name = (error.name || '').toLowerCase(); + const message = (error.message || '').toLowerCase(); + return ( + name.includes('notallowed') || + name.includes('permissiondenied') || + message.includes('permission denied') || + message.includes('denied by system') || + message.includes('blocked') + ); + }, + [permissionErrors], + ); + + // Create device status for LocalUserChoices compatibility + const deviceStatus = React.useMemo( + () => + ({ + audio: (() => { + if (hasPermissionError('audio')) { + return isPermissionDenied('audio') ? 'permission-denied' : 'error'; + } + return isDeviceAvailable('audio') ? 'available' : 'no-devices'; + })(), + video: (() => { + if (hasPermissionError('video')) { + return isPermissionDenied('video') ? 'permission-denied' : 'error'; + } + return isDeviceAvailable('video') ? 'available' : 'no-devices'; + })(), + }) as { audio: DeviceStatus; video: DeviceStatus }, + [isDeviceAvailable, hasPermissionError, isPermissionDenied], + ); + // Save user choices to persistent storage. React.useEffect(() => { saveAudioInputEnabled(audioEnabled); @@ -277,8 +560,79 @@ export function PreJoin({ : false, }, onError, + setPermissionErrors, ); + // Initial permission check to detect if permissions are already denied + React.useEffect(() => { + const checkPermissions = async () => { + try { + // Try to access media streams to check permissions + const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + audioStream.getTracks().forEach((track) => track.stop()); + + // If we get here, audio permission is granted + setPermissionErrors((prev) => ({ ...prev, audio: undefined })); + } catch (audioError) { + // Audio permission denied + if (audioError instanceof Error) { + setPermissionErrors((prev) => ({ ...prev, audio: audioError })); + + if (onError) { + const enhancedError = new DevicePermissionError(audioError, 'audio'); + onError(enhancedError); + } + } + } + + try { + // Try to access video stream to check permissions + const videoStream = await navigator.mediaDevices.getUserMedia({ video: true }); + videoStream.getTracks().forEach((track) => track.stop()); + + // If we get here, video permission is granted + setPermissionErrors((prev) => ({ ...prev, video: undefined })); + } catch (videoError) { + // Video permission denied + if (videoError instanceof Error) { + setPermissionErrors((prev) => ({ ...prev, video: videoError })); + + if (onError) { + const enhancedError = new DevicePermissionError(videoError, 'video'); + onError(enhancedError); + } + } + } + }; + + checkPermissions(); + }, [onError]); // Run once on mount + + // Debug logging (only when debug is enabled) + React.useEffect(() => { + if (debug) { + log.debug('PreJoin state:', { + audioEnabled, + videoEnabled, + audioDeviceId: initialUserChoices.audioDeviceId, + videoDeviceId: initialUserChoices.videoDeviceId, + tracks: tracks?.length || 0, + isDeviceAvailable: { + audio: isDeviceAvailable('audio'), + video: isDeviceAvailable('video'), + }, + }); + } + }, [ + debug, + audioEnabled, + videoEnabled, + initialUserChoices.audioDeviceId, + initialUserChoices.videoDeviceId, + tracks, + isDeviceAvailable, + ]); + const videoEl = React.useRef(null); const videoTrack = React.useMemo( @@ -300,14 +654,29 @@ export function PreJoin({ [tracks], ); + const { selectedDevice: selectedAudioDevice } = useSelectedDevice({ + kind: 'audioinput', + track: audioTrack, + deviceId: audioDeviceId, + }); + const { selectedDevice: selectedVideoDevice } = useSelectedDevice({ + kind: 'videoinput', + track: videoTrack, + deviceId: videoDeviceId, + }); + React.useEffect(() => { - if (videoEl.current && videoTrack) { + const videoElement = videoEl.current; + if (videoElement && videoTrack) { videoTrack.unmute(); - videoTrack.attach(videoEl.current); + videoTrack.attach(videoElement); } return () => { - videoTrack?.detach(); + if (videoTrack) { + if (videoElement) videoTrack.detach(videoElement); + videoTrack.stop(); + } }; }, [videoTrack]); @@ -315,13 +684,19 @@ export function PreJoin({ const handleValidation = React.useCallback( (values: LocalUserChoices) => { + const extendedValues: PreJoinValues = { + ...values, + audioAvailable: isDeviceAvailable('audio'), + videoAvailable: isDeviceAvailable('video'), + }; + if (typeof onValidate === 'function') { - return onValidate(values); + return onValidate(extendedValues); } else { return values.username !== ''; } }, - [onValidate], + [onValidate, isDeviceAvailable], ); React.useEffect(() => { @@ -331,16 +706,30 @@ export function PreJoin({ videoDeviceId, audioEnabled, audioDeviceId, + deviceStatus, }; setUserChoices(newUserChoices); setIsValid(handleValidation(newUserChoices)); - }, [username, videoEnabled, handleValidation, audioEnabled, audioDeviceId, videoDeviceId]); + }, [ + username, + videoEnabled, + handleValidation, + audioEnabled, + audioDeviceId, + videoDeviceId, + deviceStatus, + ]); function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (handleValidation(userChoices)) { if (typeof onSubmit === 'function') { - onSubmit(userChoices); + const extendedValues: PreJoinValues = { + ...userChoices, + audioAvailable: isDeviceAvailable('audio'), + videoAvailable: isDeviceAvailable('video'), + }; + onSubmit(extendedValues); } } else { log.warn('Validation failed with: ', userChoices); @@ -349,6 +738,39 @@ export function PreJoin({ useWarnAboutMissingStyles(); + React.useEffect(() => { + if (!debug) return; + // Log only when key states change + // eslint-disable-next-line no-console + console.log({ + permissionDeniedAudio: isPermissionDenied('audio'), + permissionErrorAudio: hasPermissionError('audio'), + permissionDeniedVideo: isPermissionDenied('video'), + permissionErrorVideo: hasPermissionError('video'), + audioEnabled, + videoEnabled, + permissionErrors, + }); + }, [debug, permissionErrors, audioEnabled, videoEnabled, isPermissionDenied, hasPermissionError]); + + // If permission becomes denied, stop attempting to create local tracks to avoid churn + const audioPermissionDenied = hasPermissionError('audio'); + const videoPermissionDenied = hasPermissionError('video'); + + React.useEffect(() => { + if (audioPermissionDenied && audioEnabled) { + setAudioEnabled(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioPermissionDenied]); // Only depend on permission state, not audioEnabled + + React.useEffect(() => { + if (videoPermissionDenied && videoEnabled) { + setVideoEnabled(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [videoPermissionDenied]); // Only depend on permission state, not videoEnabled + return (
@@ -362,41 +784,77 @@ export function PreJoin({ )}
-
+
setAudioEnabled(enabled)} - > - {micLabel} - -
- setAudioDeviceId(id)} - /> + onClick={ + hasPermissionError('audio') + ? () => { + setDeniedPermissions({ + audio: hasPermissionError('audio'), + video: hasPermissionError('video'), + }); + setShowPermissionModal(true); + } + : undefined + } + onChange={(enabled) => { + // Only update if not in permission denied state to avoid toggle loops + if (!hasPermissionError('audio')) { + setAudioEnabled(enabled); + } + }} + /> +
+
+ setAudioDeviceId(id)} + />
-
+
setVideoEnabled(enabled)} - > - {camLabel} - -
- setVideoDeviceId(id)} - /> + onClick={ + hasPermissionError('video') + ? () => { + setDeniedPermissions({ + audio: hasPermissionError('audio'), + video: hasPermissionError('video'), + }); + setShowPermissionModal(true); + } + : undefined + } + onChange={(enabled) => { + // Only update if not in permission denied state to avoid toggle loops + if (!hasPermissionError('video')) { + setVideoEnabled(enabled); + } + }} + /> +
+
+ setVideoDeviceId(id)} + />
@@ -423,16 +881,52 @@ export function PreJoin({ {debug && ( <> - User Choices: + {t.debugUserChoices}
    -
  • Username: {`${userChoices.username}`}
  • -
  • Video Enabled: {`${userChoices.videoEnabled}`}
  • -
  • Audio Enabled: {`${userChoices.audioEnabled}`}
  • -
  • Video Device: {`${userChoices.videoDeviceId}`}
  • -
  • Audio Device: {`${userChoices.audioDeviceId}`}
  • +
  • + {t.debugUsername}: {`${userChoices.username}`} +
  • +
  • + {t.debugVideoEnabled}: {`${userChoices.videoEnabled}`} +
  • +
  • + {t.debugAudioEnabled}: {`${userChoices.audioEnabled}`} +
  • +
  • + {t.debugVideoDevice}: {`${userChoices.videoDeviceId}`} +
  • +
  • + {t.debugAudioDevice}: {`${userChoices.audioDeviceId}`} +
  • +
  • + {t.debugAudioAvailable}: {`${isDeviceAvailable('audio')}`} +
  • +
  • + {t.debugVideoAvailable}: {`${isDeviceAvailable('video')}`} +
  • +
  • + {t.debugAudioPermDenied}: {`${isPermissionDenied('audio')}`} +
  • +
  • + {t.debugVideoPermDenied}: {`${isPermissionDenied('video')}`} +
  • +
  • + {t.debugAudioPermError}: {`${permissionErrors.audio?.name || 'none'}`} +
  • +
  • + {t.debugVideoPermError}: {`${permissionErrors.video?.name || 'none'}`} +
)} + + {showPermissionModal && ( + setShowPermissionModal(false)} + /> + )}
); } diff --git a/packages/react/src/prefabs/index.ts b/packages/react/src/prefabs/index.ts index 252496bae..45e4103ca 100644 --- a/packages/react/src/prefabs/index.ts +++ b/packages/react/src/prefabs/index.ts @@ -1,5 +1,6 @@ export { Chat, type ChatProps } from './Chat'; export { PreJoin, type PreJoinProps, usePreviewDevice, usePreviewTracks } from './PreJoin'; +export { type PrejoinLanguage } from './prejoinTranslations'; export { VideoConference, type VideoConferenceProps } from './VideoConference'; export { ControlBar, type ControlBarProps, type ControlBarControls } from './ControlBar'; export { MediaDeviceMenu, type MediaDeviceMenuProps } from './MediaDeviceMenu'; diff --git a/packages/react/src/prefabs/prejoinTranslations.ts b/packages/react/src/prefabs/prejoinTranslations.ts new file mode 100644 index 000000000..2942982aa --- /dev/null +++ b/packages/react/src/prefabs/prejoinTranslations.ts @@ -0,0 +1,110 @@ +export type PrejoinLanguage = 'en' | 'de'; + +export type PrejoinTranslations = { + join: string; + microphone: string; + camera: string; + username: string; + ariaClose: string; + permissionTitleBoth: string; + permissionTitleMic: string; + permissionTitleCam: string; + blockedBoth: string; + blockedMic: string; + blockedCam: string; + toEnableAccess: string; + step1: string; + step2Both: string; + step2Mic: string; + step2Cam: string; + step3: string; + debugUserChoices: string; + debugUsername: string; + debugVideoEnabled: string; + debugAudioEnabled: string; + debugVideoDevice: string; + debugAudioDevice: string; + debugAudioAvailable: string; + debugVideoAvailable: string; + debugAudioPermDenied: string; + debugVideoPermDenied: string; + debugAudioPermError: string; + debugVideoPermError: string; +}; + +export const prejoinTranslations: Record = { + en: { + join: 'Join Room', + microphone: 'Microphone', + camera: 'Camera', + username: 'Username', + ariaClose: 'Close', + permissionTitleBoth: 'Microphone & Camera Access Required', + permissionTitleMic: 'Microphone Access Required', + permissionTitleCam: 'Camera Access Required', + blockedBoth: 'This application is blocked from using your microphone and camera.', + blockedMic: 'This application is blocked from using your microphone.', + blockedCam: 'This application is blocked from using your camera.', + toEnableAccess: 'To enable access:', + step1: `Click the settings or info icon next to the website address in your browser. If you don't see it, open your browser settings and look for site permissions.`, + step2Both: + 'Check microphone and camera access. Make sure access to both devices is enabled in your browser settings.', + step2Mic: + 'Check microphone access. Make sure access to the mic is enabled in your browser settings.', + step2Cam: + 'Check camera access. Make sure access to the camera is enabled in your browser settings.', + step3: 'Refresh the page to apply the changes.', + debugUserChoices: 'User Choices:', + debugUsername: 'Username', + debugVideoEnabled: 'Video Enabled', + debugAudioEnabled: 'Audio Enabled', + debugVideoDevice: 'Video Device', + debugAudioDevice: 'Audio Device', + debugAudioAvailable: 'Audio Available', + debugVideoAvailable: 'Video Available', + debugAudioPermDenied: 'Audio Permission Denied', + debugVideoPermDenied: 'Video Permission Denied', + debugAudioPermError: 'Audio Permission Error', + debugVideoPermError: 'Video Permission Error', + }, + de: { + join: 'Raum betreten', + microphone: 'Mikrofon', + camera: 'Kamera', + username: 'Benutzername', + ariaClose: 'Schließen', + permissionTitleBoth: 'Zugriff auf Mikrofon & Kamera erforderlich', + permissionTitleMic: 'Zugriff auf Mikrofon erforderlich', + permissionTitleCam: 'Zugriff auf Kamera erforderlich', + blockedBoth: + 'Diese Anwendung ist daran gehindert, dein Mikrofon und deine Kamera zu verwenden.', + blockedMic: 'Diese Anwendung ist daran gehindert, dein Mikrofon zu verwenden.', + blockedCam: 'Diese Anwendung ist daran gehindert, deine Kamera zu verwenden.', + toEnableAccess: 'So aktivierst du den Zugriff:', + step1: `Klicke auf das Schloss- oder Info-Symbol neben der Website-Adresse in deinem Browser. Falls du es nicht findest, öffne die Einstellungen deines Browsers und suche nach Website-Berechtigungen.`, + step2Both: + 'Überprüfe den Zugriff auf Mikrofon und Kamera. Stelle sicher, dass der Zugriff auf beide Geräte in den Browsereinstellungen aktiviert ist.', + step2Mic: + 'Überprüfe den Mikrofonzugriff. Stelle sicher, dass der Zugriff auf das Mikrofon in den Browsereinstellungen aktiviert ist.', + step2Cam: + 'Überprüfe den Kamerazugriff. Stelle sicher, dass der Zugriff auf die Kamera in den Browsereinstellungen aktiviert ist.', + step3: 'Lade die Seite neu, um die Änderungen zu übernehmen.', + debugUserChoices: 'Benutzerauswahl:', + debugUsername: 'Benutzername', + debugVideoEnabled: 'Video aktiviert', + debugAudioEnabled: 'Audio aktiviert', + debugVideoDevice: 'Videogerät', + debugAudioDevice: 'Audiogerät', + debugAudioAvailable: 'Audio verfügbar', + debugVideoAvailable: 'Video verfügbar', + debugAudioPermDenied: 'Audio-Zugriff verweigert', + debugVideoPermDenied: 'Video-Zugriff verweigert', + debugAudioPermError: 'Fehler beim Audio-Zugriff', + debugVideoPermError: 'Fehler beim Video-Zugriff', + }, +}; + +export function getPrejoinTranslations(language: PrejoinLanguage): PrejoinTranslations { + const defaultLang: PrejoinLanguage = 'en'; + return prejoinTranslations[language] ?? prejoinTranslations[defaultLang]; +} diff --git a/packages/styles/assets/icons/exclamation-icon.svg b/packages/styles/assets/icons/exclamation-icon.svg new file mode 100644 index 000000000..05d6cc401 --- /dev/null +++ b/packages/styles/assets/icons/exclamation-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/styles/assets/images/grant-permissions.svg b/packages/styles/assets/images/grant-permissions.svg new file mode 100644 index 000000000..e56171588 --- /dev/null +++ b/packages/styles/assets/images/grant-permissions.svg @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/styles/livekit-components-styles-1.1.7.tgz b/packages/styles/livekit-components-styles-1.1.7.tgz new file mode 100644 index 000000000..828d69686 Binary files /dev/null and b/packages/styles/livekit-components-styles-1.1.7.tgz differ diff --git a/packages/styles/package.json b/packages/styles/package.json index 57be9a647..e052e2358 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@livekit/components-styles", - "version": "1.1.6", + "version": "1.1.7", "license": "Apache-2.0", "author": "LiveKit", "repository": { diff --git a/packages/styles/scss/components/_permissions.scss b/packages/styles/scss/components/_permissions.scss new file mode 100644 index 000000000..219879e4b --- /dev/null +++ b/packages/styles/scss/components/_permissions.scss @@ -0,0 +1,142 @@ +@import '../preflight'; +@import '../components/controls/button'; + +.track-toggle-container { + position: relative; +} + +.permission-denied { + --control-bg: #ea4335; + --control-hover-bg: #cd3a2e; + --control-active-bg: #ac3126; + --control-active-hover-bg: #cd3a2e; + border-color: #cd3a2e; + transition: background-color 0.2s ease-in-out; + + &:hover { + --control-bg: var(--control-hover-bg); + } + + &:active { + --control-bg: var(--control-active-bg); + } +} + +.permission-warning-icon { + position: absolute; + top: -4px; + right: -4px; + background-color: #fa7b17; + border-radius: 50%; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.permission-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.permission-modal-content { + background-color: var(--bg4); + padding: 1.5rem; + border-radius: 32px; + max-width: 840px; + max-height: 100%; + width: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + overflow: scroll; + + @media screen and (min-width: 600px) { + flex-direction: row; + gap: 2rem; + padding: 2rem; + } +} + +.permission-modal-close-button { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: var(--control-fg); + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--control-active-hover-bg); + } +} + +.permission-modal-image-container { + width: 200px; + height: 200px; + overflow: hidden; + border-radius: 8px; + margin: 0 auto; + flex-shrink: 1; + + svg { + width: 100%; + height: 100%; + object-fit: contain; + } + + @media screen and (min-width: 600px) { + flex-shrink: 0; + width: 240px; + height: 240px; + } +} + +.permission-modal-title { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: bold; + text-align: center; + + @media screen and (min-width: 600px) { + margin-bottom: 1rem; + text-align: left; + } +} + +.permission-modal-description { + margin: 0 0 1rem 0; + font-size: 1.15rem; + line-height: 1.5; + text-align: left; +} + +.permission-modal-instructions { + font-size: 1.15rem; + margin: 0 0 1rem 0; + padding-left: 1rem; + line-height: 1.5; + + > li { + margin-bottom: 1rem; + } +} diff --git a/packages/styles/scss/components/_toast.scss b/packages/styles/scss/components/_toast.scss index 8e23571ab..2427b2696 100644 --- a/packages/styles/scss/components/_toast.scss +++ b/packages/styles/scss/components/_toast.scss @@ -7,7 +7,7 @@ align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background-color: var(--lk-bg); + background-color: var(--bg); border: 1px solid var(--border-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow); diff --git a/packages/styles/scss/components/controls/_pagination-control.scss b/packages/styles/scss/components/controls/_pagination-control.scss index 6759987f4..b7a6675df 100644 --- a/packages/styles/scss/components/controls/_pagination-control.scss +++ b/packages/styles/scss/components/controls/_pagination-control.scss @@ -5,8 +5,8 @@ transform: translateX(-50%); display: flex; align-items: stretch; - background-color: var(--lk-control-bg); - border-radius: var(--lk-border-radius); + background-color: var(--control-bg); + border-radius: var(--border-radius); transition: opacity ease-in-out 0.15s; opacity: 0; @@ -30,7 +30,7 @@ .pagination-count { padding: 0.5rem 0.875rem; - border-inline: 1px solid var(--lk-bg); + border-inline: 1px solid var(--bg); } [data-user-interaction='true'].pagination-control { diff --git a/packages/styles/scss/components/index.scss b/packages/styles/scss/components/index.scss index d0b5c5461..eb00672a0 100644 --- a/packages/styles/scss/components/index.scss +++ b/packages/styles/scss/components/index.scss @@ -12,3 +12,4 @@ // General @use 'toast'; @use 'room'; +@use 'permissions'; diff --git a/packages/styles/scss/prefabs/chat.scss b/packages/styles/scss/prefabs/chat.scss index 8221fbe25..3446224d8 100644 --- a/packages/styles/scss/prefabs/chat.scss +++ b/packages/styles/scss/prefabs/chat.scss @@ -20,7 +20,7 @@ transform: translateX(-50%); background-color: transparent; &:hover { - background-color: var(--lk-control-active-hover-bg); + background-color: var(--control-active-hover-bg); } } } diff --git a/packages/styles/scss/prefabs/prejoin.scss b/packages/styles/scss/prefabs/prejoin.scss index c5756cc95..e4970339a 100644 --- a/packages/styles/scss/prefabs/prejoin.scss +++ b/packages/styles/scss/prefabs/prejoin.scss @@ -1,4 +1,5 @@ @import '../preflight'; +@import '../components/controls/button'; .prejoin { @extend %container-style; @@ -19,7 +20,7 @@ height: auto; aspect-ratio: 16 / 10; background-color: black; - border-radius: var(--lk-border-radius); + border-radius: var(--border-radius); overflow: hidden; video, .camera-off-note { @@ -59,31 +60,36 @@ } .button-group-container { - display: flex; - flex-wrap: nowrap; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; > .button-group { - width: 50%; - - > .button { - justify-content: left; - } - - > .button:first-child { - width: 100%; - } + width: 100%; } } - @media (max-width: 400px) { + + @media (max-width: 600px) { .button-group-container { - flex-wrap: wrap; + display: flex; + flex-direction: column; + > .button-group { width: 100%; } } } + .button-menu { + height: 100%; + } + + /* Safari fix for button groups */ + .button-group-pre-join, + .button-group-menu-pre-join { + height: auto; + } + .username-container { display: flex; flex-direction: column; @@ -104,3 +110,42 @@ } } } + +.button-group-pre-join { + @extend .button-group; + flex-wrap: nowrap; + width: 100%; + gap: 0.5rem; + + > .button-menu { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + height: 100%; + } +} + +.button-group-menu-pre-join { + @extend .button-group-menu; + flex: 1; + display: flex; + align-items: center; + flex-wrap: nowrap; + white-space: nowrap; + overflow: hidden; + + > label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.button-menu { + @extend .button-menu; + &::after { + margin-left: 0; + } +} diff --git a/packages/styles/scss/prefabs/video-conference.scss b/packages/styles/scss/prefabs/video-conference.scss index 89497316d..5ef90a882 100644 --- a/packages/styles/scss/prefabs/video-conference.scss +++ b/packages/styles/scss/prefabs/video-conference.scss @@ -35,16 +35,16 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - background: var(--lk-bg); + background: var(--bg); padding: 1rem; - border-radius: var(--lk-border-radius); + border-radius: var(--border-radius); display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.75rem 1.25rem; - background-color: var(--lk-bg); + background-color: var(--bg); border: 1px solid var(--border-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow); diff --git a/packages/styles/scss/themes/huddle.scss b/packages/styles/scss/themes/huddle.scss index 698f053be..f2abf1ba2 100644 --- a/packages/styles/scss/themes/huddle.scss +++ b/packages/styles/scss/themes/huddle.scss @@ -6,7 +6,9 @@ .button { border: 0px; - box-shadow: 0 0 1px rgb(29 28 29 / 13%), 0 1px 3px 0 rgb(0 0 0 / 8%); + box-shadow: + 0 0 1px rgb(29 28 29 / 13%), + 0 1px 3px 0 rgb(0 0 0 / 8%); background-position: center; background-repeat: no-repeat; background-size: 60%; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df28d3b4f..26888438f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,8 +216,8 @@ importers: packages/react: dependencies: '@livekit/components-core': - specifier: workspace:* - version: link:../core + specifier: ^0.12.9 + version: 0.12.9(livekit-client@2.15.1(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) '@livekit/krisp-noise-filter': specifier: ^0.2.12 || ^0.3.0 version: 0.2.12(livekit-client@2.15.1(@types/dom-mediacapture-record@1.0.22)) @@ -225,7 +225,7 @@ importers: specifier: 2.1.1 version: 2.1.1 livekit-client: - specifier: 'catalog:' + specifier: ^2.13.3 version: 2.15.1(@types/dom-mediacapture-record@1.0.22) tslib: specifier: ^2.6.2 @@ -2087,6 +2087,13 @@ packages: '@livekit/changesets-changelog-github@0.0.4': resolution: {integrity: sha512-MXaiLYwgkYciZb8G2wkVtZ1pJJzZmVx5cM30Q+ClslrIYyAqQhRbPmZDM79/5CGxb1MTemR/tfOM25tgJgAK0g==} + '@livekit/components-core@0.12.9': + resolution: {integrity: sha512-bwrZsHf6GaHIO+lLyA6Yps1STTX9YIeL3ixwt+Ufi88OgkNYdp41Ug8oeVDlf7tzdxa+r3Xkfaj/qvIG84Yo6A==} + engines: {node: '>=18'} + peerDependencies: + livekit-client: ^2.13.3 + tslib: ^2.6.2 + '@livekit/krisp-noise-filter@0.2.12': resolution: {integrity: sha512-z7qSa3A6fn/DYTt0rITNAK0sNpBTzlnb29aM0ks8UfpbfTnnjAaFv3AC695mUq9iICPKrd5jOQT71gowiQ+Otg==} peerDependencies: @@ -10056,6 +10063,14 @@ snapshots: transitivePeerDependencies: - encoding + '@livekit/components-core@0.12.9(livekit-client@2.15.1(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + dependencies: + '@floating-ui/dom': 1.6.13 + livekit-client: 2.15.1(@types/dom-mediacapture-record@1.0.22) + loglevel: 1.9.1 + rxjs: 7.8.2 + tslib: 2.8.1 + '@livekit/krisp-noise-filter@0.2.12(livekit-client@2.15.1(@types/dom-mediacapture-record@1.0.22))': dependencies: livekit-client: 2.15.1(@types/dom-mediacapture-record@1.0.22)