Voice call Pip circular media view

This commit is contained in:
Timo K
2026-07-06 14:57:25 +02:00
parent ad76969ce2
commit 90f95c5bf1
4 changed files with 62 additions and 2 deletions

View File

@@ -238,7 +238,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Merge the refs so they can attach to the same element // Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2); const containerRef = useMergedRefs(containerRef1, containerRef2);
const { showControls, header: headerStyle } = useUrlParams(); const { showControls, header: headerStyle, callIntent } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleAudio = useBehavior(muteStates.audio.toggle$);
@@ -503,6 +503,8 @@ export const InCallView: FC<InCallViewProps> = ({
showRingingStatus={vm.ringingStatusLocation === "tile"} showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured} focusable={!contentObscured}
aria-hidden={contentObscured} aria-hidden={contentObscured}
// Use the Circular rendering of the audio tile in PiP
circularAudioTile={callIntent === "audio"}
/> />
); );
} }

View File

@@ -62,6 +62,42 @@ Please see LICENSE in the repository root for full details.
opacity: 50%; opacity: 50%;
} }
/* When no video is shown, circular media has no tile box at all: just the
avatar rendered on the app background */
.media.circular .bg {
background-color: transparent;
}
/* A disc behind the avatar, poking out around its edge to form a circular
speaking indicator. It uses the same gradient as the speaking border of grid
tiles. */
.media.circular .bg::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background:
linear-gradient(
119deg,
rgba(13, 92, 189, 0.7) 0%,
rgba(13, 189, 168, 0.7) 100%
),
linear-gradient(
180deg,
rgba(13, 92, 189, 0.9) 0%,
rgba(13, 189, 168, 0.9) 100%
);
background-blend-mode: overlay, normal;
opacity: 0; /* Hidden unless speaking */
transition: opacity ease 0.15s;
}
.media.circular.speaking .bg::before {
opacity: 1;
}
/* CSS makes us put a condition here, even though all we want to do is /* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cqmin units */ unconditionally select the container so we can use cqmin units */
@container mediaView (width > 0) { @container mediaView (width > 0) {
@@ -70,6 +106,13 @@ unconditionally select the container so we can use cqmin units */
inline-size: 50cqmin; inline-size: 50cqmin;
block-size: 50cqmin; block-size: 50cqmin;
} }
.media.circular .bg::before {
/* Sized so that the disc extends beyond the avatar by the width of the
speaking indicator */
inline-size: calc(50cqmin + 2 * var(--cpd-border-width-4));
block-size: calc(50cqmin + 2 * var(--cpd-border-width-4));
}
} }
.avatar > img { .avatar > img {

View File

@@ -54,6 +54,9 @@ interface Props extends ComponentProps<typeof animated.div> {
rtcBackendIdentity?: string; rtcBackendIdentity?: string;
// The focus url, mainly for debugging purposes // The focus url, mainly for debugging purposes
focusUrl?: string; focusUrl?: string;
circularAudioTile?: boolean;
// render a circular speaking indicator if circularAudioTile is enabled.
circularSpeakingIndicator?: boolean;
} }
export const MediaView: FC<Props> = ({ export const MediaView: FC<Props> = ({
@@ -84,6 +87,8 @@ export const MediaView: FC<Props> = ({
videoStreamStats, videoStreamStats,
rtcBackendIdentity, rtcBackendIdentity,
focusUrl, focusUrl,
circularAudioTile,
circularSpeakingIndicator,
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -112,7 +117,9 @@ export const MediaView: FC<Props> = ({
return ( return (
<animated.div <animated.div
className={classNames(styles.media, className, { className={classNames(styles.media, className, {
[styles.circular]: circularAudioTile && !videoEnabled,
[styles.mirror]: mirror, [styles.mirror]: mirror,
[styles.speaking]: circularSpeakingIndicator,
})} })}
style={style} style={style}
ref={ref} ref={ref}

View File

@@ -66,6 +66,7 @@ interface SpotlightItemBaseProps {
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
showNameTags: boolean; showNameTags: boolean;
focusable: boolean; focusable: boolean;
circularAudioTile?: boolean;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
} }
@@ -103,8 +104,9 @@ const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
...props ...props
}) => { }) => {
const waitingForMedia = useBehavior(vm.waitingForMedia$); const waitingForMedia = useBehavior(vm.waitingForMedia$);
const speaking = useBehavior(vm.speaking$);
return ( return (
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} /> <MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} circularSpeakingIndicator={speaking} />
); );
}; };
@@ -243,6 +245,7 @@ interface SpotlightItemProps {
targetHeight: number; targetHeight: number;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean; showRingingStatus: boolean;
circularAudioTile?: boolean;
focusable: boolean; focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>; intersectionObserver$: Observable<IntersectionObserver>;
/** /**
@@ -259,6 +262,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetHeight, targetHeight,
showNameTags, showNameTags,
showRingingStatus, showRingingStatus,
circularAudioTile,
focusable, focusable,
intersectionObserver$, intersectionObserver$,
snap, snap,
@@ -295,6 +299,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
showNameTags, showNameTags,
circularAudioTile,
focusable, focusable,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
}; };
@@ -389,6 +394,7 @@ interface Props {
showIndicators: boolean; showIndicators: boolean;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean; showRingingStatus: boolean;
circularAudioTile?: boolean;
focusable: boolean; focusable: boolean;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
@@ -404,6 +410,7 @@ export const SpotlightTile: FC<Props> = ({
showIndicators, showIndicators,
showNameTags, showNameTags,
showRingingStatus, showRingingStatus,
circularAudioTile,
focusable = true, focusable = true,
className, className,
style, style,
@@ -517,6 +524,7 @@ export const SpotlightTile: FC<Props> = ({
showRingingStatus={showRingingStatus} showRingingStatus={showRingingStatus}
showNameTags={showNameTags} showNameTags={showNameTags}
focusable={focusable} focusable={focusable}
circularAudioTile={circularAudioTile}
intersectionObserver$={intersectionObserver$} intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media // This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily // when the previous/next buttons are clicked: we temporarily