Files
jitsi-meet/react/features/base/avatar/components/web/StatelessAvatar.tsx
Hristo Terezov d06b847319 feat(pip): Add Picture-in-Picture support for Electron
Implements Picture-in-Picture functionality for the Electron wrapper to maintain video engagement when users are not actively focused on the conference window. This feature addresses the need to keep users visually connected to the conference even when multitasking.

Key features:
- Automatic PiP mode activation and deactivation based on user interaction
- Displays large video participant's stream or renders their avatar on canvas when video unavailable
- Provides audio/video mute controls via MediaSession API directly in PiP window
- Adds API events (_pip-requested) for Electron wrapper integration

Implementation includes new pip feature module with Redux architecture, canvas-based avatar rendering with custom backgrounds support, and integration with existing mute/unmute logic. Depends on jitsi-meet-electron-sdk#479 for proper user gesture handling in Electron.
2025-12-04 16:04:10 -06:00

232 lines
5.8 KiB
TypeScript

import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
import { pixelsToRem } from '../../../ui/functions.any';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import {
PRESENCE_AVAILABLE_COLOR,
PRESENCE_AWAY_COLOR,
PRESENCE_BUSY_COLOR,
PRESENCE_IDLE_COLOR
} from '../styles';
import {
AVATAR_DEFAULT_BACKGROUND_COLOR,
getAvatarFont,
getAvatarInitialsColor
} from './styles';
interface IProps extends IAvatarProps {
/**
* External class name passed through props.
*/
className?: string;
/**
* The default avatar URL if we want to override the app bundled one (e.g. AlwaysOnTop).
*/
defaultAvatar?: string;
/**
* ID of the component to be rendered.
*/
id?: string;
/**
* One of the expected status strings (e.g. 'available') to render a badge on the avatar, if necessary.
*/
status?: string;
/**
* TestId of the element, if any.
*/
testId?: string;
/**
* The URL of the avatar to render.
*/
url?: string | Function;
/**
* Indicates whether to load the avatar using CORS or not.
*/
useCORS?: boolean;
}
const useStyles = makeStyles()(theme => {
return {
avatar: {
backgroundColor: AVATAR_DEFAULT_BACKGROUND_COLOR,
borderRadius: '50%',
color: getAvatarInitialsColor(theme),
...getAvatarFont(theme),
fontSize: 'inherit',
objectFit: 'cover',
textAlign: 'center',
overflow: 'hidden',
'&.avatar-small': {
height: '28px !important',
width: '28px !important'
},
'&.avatar-xsmall': {
height: '16px !important',
width: '16px !important'
},
'& .jitsi-icon': {
transform: 'translateY(50%)'
},
'& .avatar-svg': {
height: '100%',
width: '100%'
}
},
initialsContainer: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
badge: {
position: 'relative',
'&.avatar-badge:after': {
borderRadius: '50%',
content: '""',
display: 'block',
height: '35%',
position: 'absolute',
bottom: 0,
width: '35%'
},
'&.avatar-badge-available:after': {
backgroundColor: PRESENCE_AVAILABLE_COLOR
},
'&.avatar-badge-away:after': {
backgroundColor: PRESENCE_AWAY_COLOR
},
'&.avatar-badge-busy:after': {
backgroundColor: PRESENCE_BUSY_COLOR
},
'&.avatar-badge-idle:after': {
backgroundColor: PRESENCE_IDLE_COLOR
}
}
};
});
const StatelessAvatar = ({
className,
color,
iconUser,
id,
initials,
onAvatarLoadError,
onAvatarLoadErrorParams,
size,
status,
testId,
url,
useCORS
}: IProps) => {
const { classes, cx } = useStyles();
const _getAvatarStyle = (backgroundColor?: string) => {
return {
background: backgroundColor || undefined,
fontSize: size ? pixelsToRem(size * 0.4) : '180%',
height: size || '100%',
width: size || '100%'
};
};
const _getAvatarClassName = (additional?: string) => cx('avatar', additional, className, classes.avatar);
const _getBadgeClassName = () => {
if (status) {
return cx('avatar-badge', `avatar-badge-${status}`, classes.badge);
}
return '';
};
const _onAvatarLoadError = useCallback(() => {
if (typeof onAvatarLoadError === 'function') {
onAvatarLoadError(onAvatarLoadErrorParams);
}
}, [ onAvatarLoadError, onAvatarLoadErrorParams ]);
if (isIcon(url)) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<Icon
size = '50%'
src = { url } />
</div>
);
}
if (url) {
return (
<div className = { _getBadgeClassName() }>
<img
alt = 'avatar'
className = { _getAvatarClassName() }
crossOrigin = { useCORS ? '' : undefined }
data-testid = { testId }
id = { id }
onError = { _onAvatarLoadError }
src = { url }
style = { _getAvatarStyle() } />
</div>
);
}
if (initials) {
return (
<div
className = { cx(_getAvatarClassName(), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle(color) }>
<div className = { classes.initialsContainer }>
{initials}
</div>
</div>
);
}
// default avatar
return (
<div
className = { cx(_getAvatarClassName('defaultAvatar'), _getBadgeClassName()) }
data-testid = { testId }
id = { id }
style = { _getAvatarStyle() }>
<Icon
size = { '50%' }
src = { iconUser } />
</div>
);
};
export default StatelessAvatar;