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/.npmrc b/.npmrc index 1e114d5aa..6ed7f6267 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,8 @@ resolve-peers-from-workspace-root=true manage-package-manager-versions=true -package-manager-strict-version=true \ No newline at end of file +package-manager-strict-version=true + +# Slow/unstable registry downloads (large native binaries) often hit the default timeout. +fetch-timeout=600000 +fetch-retries=5 +network-concurrency=3 diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx index 2d14b3f77..1fa64e0fe 100644 --- a/examples/nextjs/pages/index.tsx +++ b/examples/nextjs/pages/index.tsx @@ -28,6 +28,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..703d26d81 --- /dev/null +++ b/examples/nextjs/pages/video-call-example.tsx @@ -0,0 +1,636 @@ +import { + LiveKitRoom, + ParticipantTile, + RoomAudioRenderer, + useTracks, + GridLayout, + TrackToggle, + MediaDeviceMenu, + useDisconnectButton, + PermissionsModal, + 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 [audioPermissionDenied, setAudioPermissionDenied] = useState(false); + const [videoPermissionDenied, setVideoPermissionDenied] = useState(false); + + 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 [callRejected, setCallRejected] = 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/observables/participant.ts b/packages/core/src/observables/participant.ts index cc89b6b14..93188a85a 100644 --- a/packages/core/src/observables/participant.ts +++ b/packages/core/src/observables/participant.ts @@ -1,4 +1,3 @@ -import type { ParticipantPermission } from '@livekit/protocol'; import { Participant, RemoteParticipant, @@ -248,7 +247,7 @@ export function connectedParticipantObserver( export function participantPermissionObserver( participant: Participant, -): Observable { +): Observable { const observer = participantEventSelector( participant, ParticipantEvent.ParticipantPermissionsChanged, 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 ac5167bd7..456882390 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -87,3 +87,18 @@ export type RequireOnlyOne = Pick): Participant[]; diff --git a/packages/react/livekit-components-react-2.9.22.tgz b/packages/react/livekit-components-react-2.9.22.tgz new file mode 100644 index 000000000..59e510d1a Binary files /dev/null and b/packages/react/livekit-components-react-2.9.22.tgz differ diff --git a/packages/react/package.json b/packages/react/package.json index 29e1f8253..d6e8c7594 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@livekit/components-react", - "version": "2.9.24", + "version": "2.9.22", "license": "Apache-2.0", "author": "LiveKit", "repository": { @@ -52,7 +52,7 @@ }, "typings": "dist/index.d.ts", "dependencies": { - "@livekit/components-core": "workspace:*", + "@livekit/components-core": "^0.12.15", "clsx": "2.1.1", "events": "^3.3.0", "jose": "^6.0.12", diff --git a/packages/react/src/assets/icons/ExclamationIcon.tsx b/packages/react/src/assets/icons/ExclamationIcon.tsx new file mode 100644 index 000000000..60d8d1334 --- /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 4309ec084..57241746e 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 extends Omit< @@ -12,11 +13,15 @@ export interface TrackToggleProps extends Omit< 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. */ onChange?: (enabled: boolean, isUserInitiated: boolean) => void; + /** Called when permission-denied state changes (also called on initial mount). */ + onPermissionsChange?: (permissionDenied: boolean) => void; captureOptions?: CaptureOptionsBySource; publishOptions?: TrackPublishOptions; onDeviceError?: (error: Error) => void; @@ -35,22 +40,56 @@ export interface TrackToggleProps extends Omit< * ``` * @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 235d6c878..ec72df397 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 d5561b11d..1e1fb26f7 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, @@ -60,6 +61,7 @@ export * from './useIsRecording'; export * from './useTextStream'; export * from './useAgentExpression'; export * from './useTranscriptions'; +export { useDeviceState } from './useDeviceState'; export * from './useSequentialRoomConnectDisconnect'; export * from './useSession'; export { 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 2c1eb4fe4..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,19 +69,93 @@ 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); + }); + } }); }; @@ -123,6 +198,7 @@ export function useLiveKitRoom( onMediaDeviceFailure, onConnected, onDisconnected, + isDeniedError, ]); React.useEffect(() => { diff --git a/packages/react/src/hooks/useLocalParticipantPermissions.ts b/packages/react/src/hooks/useLocalParticipantPermissions.ts index 0da98e899..c47b89a3e 100644 --- a/packages/react/src/hooks/useLocalParticipantPermissions.ts +++ b/packages/react/src/hooks/useLocalParticipantPermissions.ts @@ -1,5 +1,5 @@ import { participantPermissionObserver } from '@livekit/components-core'; -import type { ParticipantPermission } from '@livekit/protocol'; +import type { Participant } from 'livekit-client'; import * as React from 'react'; import { useRoomContext } from '../context'; import { useObservableState } from './internal'; @@ -13,7 +13,7 @@ import { useObservableState } from './internal'; * ``` * @public */ -export function useLocalParticipantPermissions(): ParticipantPermission | undefined { +export function useLocalParticipantPermissions(): Participant['permissions'] { const room = useRoomContext(); const permissionObserver = React.useMemo( () => participantPermissionObserver(room.localParticipant), 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/useParticipantPermissions.ts b/packages/react/src/hooks/useParticipantPermissions.ts index 269144f7e..988e67056 100644 --- a/packages/react/src/hooks/useParticipantPermissions.ts +++ b/packages/react/src/hooks/useParticipantPermissions.ts @@ -1,5 +1,4 @@ import { participantPermissionObserver } from '@livekit/components-core'; -import type { ParticipantPermission } from '@livekit/protocol'; import type { Participant } from 'livekit-client'; import * as React from 'react'; import { useEnsureParticipant } from '../context'; @@ -21,7 +20,7 @@ export interface UseParticipantPermissionsOptions { /** @public */ export function useParticipantPermissions( options: UseParticipantPermissionsOptions = {}, -): ParticipantPermission | undefined { +): Participant['permissions'] { const p = useEnsureParticipant(options.participant); const permissionObserver = React.useMemo(() => participantPermissionObserver(p), [p]); const permissions = useObservableState(permissionObserver, p.permissions); 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 beb92c518..c850ab4f5 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 Room } from 'livekit-client'; import type { TrackToggleProps } from '../components'; @@ -29,6 +30,7 @@ export interface UseTrackToggleProps extends Omit< export function useTrackToggle({ source, onChange, + onPermissionsChange, initialState, captureOptions, publishOptions, @@ -41,18 +43,126 @@ export function useTrackToggle({ const track = roomFallback?.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 isPermissionError = React.useCallback((error: Error) => { + const name = (error.name || '').toLowerCase(); + const message = (error.message || '').toLowerCase(); + return ( + name.includes('notallowed') || + name.includes('permissiondenied') || + name.includes('security') || + message.includes('permission denied') || + message.includes('denied by system') || + message.includes('blocked') + ); + }, []); + const handleToggleDeviceError = React.useCallback( + (error: Error) => { + if (isPermissionError(error)) { + setPermissionDenied(true); + } + onDeviceError?.(error); + }, + [isPermissionError, onDeviceError], + ); const { toggle, className, pendingObserver, enabledObserver } = React.useMemo( () => roomFallback - ? setupMediaToggle(source, roomFallback, captureOptions, publishOptions, onDeviceError) + ? setupMediaToggle( + source, + roomFallback, + captureOptions, + publishOptions, + handleToggleDeviceError, + ) : setupManualToggle(), - [roomFallback, source, JSON.stringify(captureOptions), publishOptions], + [roomFallback, source, captureOptions, publishOptions, handleToggleDeviceError], ); const pending = useObservableState(pendingObserver, false); const enabled = useObservableState(enabledObserver, initialState ?? !!track?.isEnabled); + React.useEffect(() => { + if (enabled && permissionDenied) { + setPermissionDenied(false); + } + }, [enabled, permissionDenied]); + + React.useEffect(() => { + onPermissionsChange?.(permissionDenied); + }, [permissionDenied, onPermissionsChange]); + + // On mount (and when source changes), check browser-level permissions when available. + // This catches the case where permissions were denied before toggle interaction. + React.useEffect(() => { + if (source !== Track.Source.Microphone && source !== Track.Source.Camera) { + return; + } + if (typeof navigator === 'undefined' || !navigator.permissions?.query) { + return; + } + + let permissionStatus: PermissionStatus | undefined; + const permissionName: PermissionName = + source === Track.Source.Microphone ? 'microphone' : 'camera'; + + const updatePermissionDenied = () => { + if (!permissionStatus) return; + setPermissionDenied(permissionStatus.state === 'denied'); + }; + + navigator.permissions + .query({ name: permissionName }) + .then((status) => { + permissionStatus = status; + updatePermissionDenied(); + permissionStatus.onchange = updatePermissionDenied; + }) + .catch(() => { + // Some browsers do not support querying camera/microphone permissions. + }); + + return () => { + if (permissionStatus) { + permissionStatus.onchange = null; + } + }; + }, [source]); + + // Listen for device errors to detect permission denied + React.useEffect(() => { + if (!roomFallback) return; + + const handleDeviceError = (error: Error, kind?: MediaDeviceKind) => { + // Check if it's a permission denied error + const message = (error.message || '').toLowerCase(); + + if (!isPermissionError(error)) 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 + roomFallback.on(RoomEvent.MediaDevicesError, handleDeviceError); + + return () => { + roomFallback.off(RoomEvent.MediaDevicesError, handleDeviceError); + }; + }, [roomFallback, source, isPermissionError]); + React.useEffect(() => { onChange?.(enabled, userInteractionRef.current); userInteractionRef.current = false; @@ -82,13 +192,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 543c94804..7e1b83275 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,3 +30,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/MediaDeviceMenu.tsx b/packages/react/src/prefabs/MediaDeviceMenu.tsx index 208e47af3..7a0887952 100644 --- a/packages/react/src/prefabs/MediaDeviceMenu.tsx +++ b/packages/react/src/prefabs/MediaDeviceMenu.tsx @@ -67,7 +67,7 @@ export function MediaDeviceMenu({ React.useLayoutEffect(() => { let cleanup: ReturnType | undefined; if (button.current && tooltip.current && (devices || updateRequired)) { - cleanup = computeMenuPosition(button.current, tooltip.current, (x, y) => { + cleanup = computeMenuPosition(button.current, tooltip.current, (x: number, y: number) => { if (tooltip.current) { Object.assign(tooltip.current.style, { left: `${x}px`, top: `${y}px` }); } diff --git a/packages/react/src/prefabs/PreJoin.tsx b/packages/react/src/prefabs/PreJoin.tsx index 319c2ab7a..1bf476fbd 100644 --- a/packages/react/src/prefabs/PreJoin.tsx +++ b/packages/react/src/prefabs/PreJoin.tsx @@ -1,6 +1,7 @@ import type { CreateLocalTracksOptions, LocalAudioTrack, + LocalTrack, LocalVideoTrack, TrackProcessor, } from 'livekit-client'; @@ -14,14 +15,56 @@ 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 { BarVisualizer } from '../components/participant/BarVisualizer'; 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. @@ -31,12 +74,17 @@ export interface PreJoinProps extends Omit< React.HTMLAttributes, '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 passed 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; @@ -46,6 +94,8 @@ export interface PreJoinProps extends Omit< micLabel?: string; camLabel?: string; userLabel?: string; + /** Language for built-in labels/text. */ + language?: PrejoinLanguage; /** * If true, user choices are persisted across sessions. * @defaultValue true @@ -59,49 +109,204 @@ export interface PreJoinProps extends Omit< 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)) as Array< - LocalAudioTrack | LocalVideoTrack - >; + 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: T, + trackOption: CreateLocalTracksOptions[T] | false, + setTrack: React.Dispatch< + React.SetStateAction + >, + 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]); + } + if (trackType === 'audio' && localTrack.kind === 'audio') { + setTrack(localTrack as T extends 'audio' ? LocalAudioTrack | undefined : never); + } else if (trackType === 'video' && localTrack.kind === 'video') { + setTrack(localTrack as T extends 'audio' ? never : LocalVideoTrack | undefined); + } + } + } 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 @@ -111,7 +316,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 }); @@ -144,7 +349,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); } } }; @@ -157,7 +368,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(() => { @@ -225,14 +436,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, @@ -246,6 +463,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 @@ -255,6 +476,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); @@ -280,8 +569,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( @@ -303,14 +663,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]); @@ -318,13 +693,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(() => { @@ -334,16 +715,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); @@ -352,6 +747,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 (
@@ -365,41 +793,81 @@ export function PreJoin({ )}
-
+
setAudioEnabled(enabled)} + 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); + } + }} > - {micLabel} + {audioTrack && audioEnabled && ( + + )} -
- setAudioDeviceId(id)} - /> +
+
+ 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)} + />
@@ -426,16 +894,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.2.2.tgz b/packages/styles/livekit-components-styles-1.2.2.tgz new file mode 100644 index 000000000..9cc76b824 Binary files /dev/null and b/packages/styles/livekit-components-styles-1.2.2.tgz differ diff --git a/packages/styles/package.json b/packages/styles/package.json index c5978ccd0..ea598de9c 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@livekit/components-styles", - "version": "1.2.0", + "version": "1.2.2", "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 0150b11dc..a1c4b21fe 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..278d42ee6 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,47 @@ } .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; + } + + /* Mic level meter inside the PreJoin audio toggle */ + .button-group-pre-join .audio-bar-visualizer { + --va-bar-width: 2px; + --va-bar-gap: 3px; + --va-bar-border-radius: 1px; + width: auto; + height: 1rem; + margin-inline-start: 0.5rem; + background: transparent; + } + .username-container { display: flex; flex-direction: column; @@ -104,3 +121,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 ca2d42199..31b9f181e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: version: 5.8.2 typescript-eslint: specifier: ^8.58.0 - version: 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) + version: 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.8.2) docs/docs: dependencies: @@ -284,8 +284,8 @@ importers: packages/react: dependencies: '@livekit/components-core': - specifier: workspace:* - version: link:../core + specifier: ^0.12.15 + version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) '@livekit/krisp-noise-filter': specifier: ^0.2.12 || ^0.3.0 || ^0.4.0 version: 0.4.3(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22)) @@ -1867,6 +1867,9 @@ packages: '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} @@ -2278,6 +2281,13 @@ packages: '@livekit/changesets-changelog-github@0.2.0': resolution: {integrity: sha512-HWgR0biITeIaLeBRIUpqYJ0Wacmx7JJjlqhiJtqYrGItqw0kRUe6tTB6vN0fU7tK1oh08hlgr3iYdGLNLNbrNQ==} + '@livekit/components-core@0.12.15': + resolution: {integrity: sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g==} + engines: {node: '>=18'} + peerDependencies: + livekit-client: ^2.20.1 + tslib: ^2.6.2 + '@livekit/krisp-noise-filter@0.4.3': resolution: {integrity: sha512-Ciu6GO+i49UM1zpTy5RD+N4ylwePJYaiu2DwzcvfxT4oH51ZMdAvAqb3kJD5WRGEq8ZuvJbgfa+/xuAu+/3zQw==} peerDependencies: @@ -7714,6 +7724,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + loglevel@1.9.1: + resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} + engines: {node: '>= 0.6.0'} + loglevel@1.9.2: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} @@ -11130,11 +11144,6 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))': - dependencies: - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))': dependencies: eslint: 10.9.0(jiti@2.7.0)(supports-color@8.1.1) @@ -11160,14 +11169,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.5(supports-color@5.5.0)': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@5.5.0) - minimatch: 10.2.6 - transitivePeerDependencies: - - supports-color - '@eslint/config-array@0.23.5(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 3.0.5 @@ -11234,6 +11235,11 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.8.0': dependencies: '@floating-ui/core': 1.8.0 @@ -11692,6 +11698,14 @@ snapshots: '@changesets/types': 6.1.0 dotenv: 8.6.0 + '@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22) + loglevel: 1.9.1 + rxjs: 7.8.2 + tslib: 2.8.1 + '@livekit/krisp-noise-filter@0.4.3(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))': dependencies: '@livekit/mutex': 1.1.1 @@ -14271,22 +14285,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2))(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.67.0 - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - ignore: 7.0.6 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.8.2) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2))(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -14335,18 +14333,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@5.5.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 @@ -14383,15 +14369,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.8.2) - '@typescript-eslint/types': 8.67.0 - debug: 4.4.3(supports-color@5.5.0) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.67.0(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.4.2) @@ -14423,18 +14400,6 @@ snapshots: dependencies: typescript: 5.8.2 - '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - debug: 4.4.3(supports-color@5.5.0) - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - ts-api-utils: 2.5.0(typescript@5.8.2) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@typescript-eslint/types': 8.67.0 @@ -14473,21 +14438,6 @@ snapshots: '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.67.0(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@typescript-eslint/project-service': 8.67.0(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.8.2) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@5.5.0) - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.8.2) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.67.0(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@typescript-eslint/project-service': 8.67.0(supports-color@8.1.1)(typescript@5.4.2) @@ -14518,17 +14468,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2)': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0)) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@5.5.0)(typescript@5.8.2) - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1)) @@ -16591,43 +16530,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5(supports-color@5.5.0) - '@eslint/config-helpers': 0.7.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.6 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.7.0 - transitivePeerDependencies: - - supports-color - eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1)) @@ -18247,6 +18149,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + loglevel@1.9.1: {} + loglevel@1.9.2: {} longest-streak@3.1.0: {} @@ -20685,17 +20589,6 @@ snapshots: optionalDependencies: sass: 1.103.1 - typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2): - dependencies: - '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2))(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@5.5.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@5.5.0))(supports-color@5.5.0)(typescript@5.8.2) - eslint: 10.9.0(jiti@2.7.0)(supports-color@5.5.0) - typescript: 5.8.2 - transitivePeerDependencies: - - supports-color - typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2): dependencies: '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2))(eslint@10.9.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.4.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9f897ea8c..b24158286 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,11 @@ packages: - 'docs/*' - 'tooling/*' +# pnpm 11: keep large native downloads from timing out on slow networks. +fetchTimeout: 1200000 +fetchRetries: 5 +networkConcurrency: 1 + allowBuilds: '@parcel/watcher': false esbuild: true