Add button to disable incoming video

This commit is contained in:
David Baker
2026-09-01 14:35:20 +01:00
parent efdee7dcdb
commit f2efba1246
7 changed files with 103 additions and 1 deletions
+2
View File
@@ -137,6 +137,7 @@
"hangup_button_label": "End call",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
"hide_other_videos_button_label": "Hide other participants' video",
"invite_modal": {
"link_copied_toast": "Link copied to clipboard",
"title": "Invite to this call"
@@ -248,6 +249,7 @@
"resolution_label": "Resolution",
"screen_share_header": "Screen sharing"
},
"show_other_videos_button_label": "Show other participants' video",
"star_rating_input_label_one": "{{count}} star",
"star_rating_input_label_other": "{{count}} stars",
"start_new_call": "Start new call",
+33
View File
@@ -25,6 +25,8 @@ import {
OverflowVerticalIcon,
VolumeOnSolidIcon,
VolumeOffSolidIcon,
VisibilityOnIcon,
VisibilityOffIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
@@ -103,6 +105,37 @@ export const VideoButton: FC<VideoButtonProps> = ({
);
};
interface DisableRemoteVideoButtonProps
extends ComponentPropsWithoutRef<"button"> {
/** Whether other participants' video is currently being shown. */
enabled: boolean;
size?: "md" | "lg";
}
export const DisableRemoteVideoButton: FC<DisableRemoteVideoButtonProps> = ({
enabled,
...props
}) => {
const { t } = useTranslation();
const Icon = enabled ? VisibilityOnIcon : VisibilityOffIcon;
const label = enabled
? t("hide_other_videos_button_label")
: t("show_other_videos_button_label");
return (
<Tooltip label={label}>
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}
/>
</Tooltip>
);
};
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size: "md" | "lg";
+19
View File
@@ -19,6 +19,7 @@ import {
ReactionToggleButton,
LoudspeakerButton,
SettingsIconButton,
DisableRemoteVideoButton,
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
@@ -58,6 +59,8 @@ export interface FooterActions {
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the "hide other participants' video" button is visible */
toggleDisableRemoteVideo: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
@@ -70,6 +73,8 @@ export interface FooterState {
videoEnabled: boolean;
videoBusy: boolean;
videoBlurEnabled: boolean;
/** Whether other participants' video feeds are hidden to save bandwidth */
disableRemoteVideo: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -146,6 +151,8 @@ export const CallFooter: FC<FooterProps> = ({
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const disableRemoteVideo = useBehavior(vm.disableRemoteVideo$);
const toggleDisableRemoteVideo = useBehavior(vm.toggleDisableRemoteVideo$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
@@ -224,6 +231,18 @@ export const CallFooter: FC<FooterProps> = ({
);
}
if (toggleDisableRemoteVideo !== undefined) {
buttons.push(
<DisableRemoteVideoButton
size={buttonSize}
key="disable_remote_video"
enabled={!disableRemoteVideo}
onClick={toggleDisableRemoteVideo}
data-testid="incall_disable_remote_video"
/>,
);
}
if (toggleScreenSharing !== undefined) {
buttons.push(
<ShareScreenButton
+5
View File
@@ -188,6 +188,9 @@ export function createCallFooterViewModel(
sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
disableRemoteVideo$: callModel.disableRemoteVideo$,
toggleDisableRemoteVideo$: constant(callModel.toggleDisableRemoteVideo),
audioOutputSwitcher$: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
@@ -249,10 +252,12 @@ export function createLobbyFooterViewModel(
toggleAudio: undefined,
toggleVideo: undefined,
toggleScreenSharing: undefined,
toggleDisableRemoteVideo: undefined,
audioEnabled: undefined,
audioBusy: false,
videoEnabled: undefined,
videoBusy: false,
disableRemoteVideo: false,
layoutSwitchVm: null,
sharingScreen: false,
audioOutputSwitcher: undefined,
+25
View File
@@ -268,6 +268,16 @@ export interface CallViewModel {
*/
sharingScreen$: Behavior<boolean>;
/**
* Whether other participants' video feeds are hidden (and not downloaded)
* to save bandwidth. Resets to false at the start of every call.
*/
disableRemoteVideo$: Behavior<boolean>;
/**
* Toggles disableRemoteVideo$.
*/
toggleDisableRemoteVideo: () => void;
// UI interactions
/**
* Callback for when the user taps the call view.
@@ -730,6 +740,18 @@ export function createCallViewModel$(
),
);
/**
* Whether other participants' video feeds are hidden to save bandwidth.
* This is intentionally not persisted: it resets to false at the start of
* every call.
*/
const disableRemoteVideoToggle$ = new Subject<void>();
const disableRemoteVideo$ = createToggle$(
scope,
false,
disableRemoteVideoToggle$,
);
/**
* List of user media (camera feeds) that we want tiles for.
*/
@@ -771,6 +793,7 @@ export function createCallViewModel$(
),
mediaDevices,
pretendToBeDisconnected$: localMembership.reconnecting$,
disableRemoteVideo$,
displayName$: scope.behavior(
matrixMemberMetadataStore
.createDisplayNameBehavior$(scope, userId)
@@ -1786,6 +1809,8 @@ export function createCallViewModel$(
leave: localMembership.requestDisconnect,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,
disableRemoteVideo$: disableRemoteVideo$,
toggleDisableRemoteVideo: (): void => disableRemoteVideoToggle$.next(),
tapScreen: (): void => screenTap$.next(),
tapControls: (): void => controlsTap$.next(),
+16 -1
View File
@@ -34,11 +34,21 @@ export interface RemoteUserMediaInputs extends Omit<
> {
participant$: Behavior<RemoteParticipant | null>;
pretendToBeDisconnected$: Behavior<boolean>;
/**
* Whether other participants' video feeds should be hidden (and thus not
* downloaded, since LiveKit's adaptiveStream pauses tracks with no
* attached video element) to save bandwidth.
*/
disableRemoteVideo$: Behavior<boolean>;
}
export function createRemoteUserMedia(
scope: ObservableScope,
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
{
pretendToBeDisconnected$,
disableRemoteVideo$,
...inputs
}: RemoteUserMediaInputs,
): RemoteUserMediaViewModel {
const baseUserMedia = createBaseUserMedia(scope, {
...inputs,
@@ -68,6 +78,11 @@ export function createRemoteUserMedia(
),
),
),
video$: scope.behavior(
combineLatest([baseUserMedia.video$, disableRemoteVideo$]).pipe(
map(([video, disabled]) => (disabled ? undefined : video)),
),
),
waitingForMedia$: scope.behavior(
combineLatest(
[inputs.livekitRoom$, inputs.participant$],
@@ -89,6 +89,7 @@ interface WrappedUserMediaInputs extends Omit<
participant: TaggedParticipant;
mediaDevices: MediaDevices;
pretendToBeDisconnected$: Behavior<boolean>;
disableRemoteVideo$: Behavior<boolean>;
}
export function createWrappedUserMedia(
@@ -97,6 +98,7 @@ export function createWrappedUserMedia(
participant,
mediaDevices,
pretendToBeDisconnected$,
disableRemoteVideo$,
...inputs
}: WrappedUserMediaInputs,
): WrappedUserMediaViewModel {
@@ -110,6 +112,7 @@ export function createWrappedUserMedia(
: createRemoteUserMedia(scope, {
participant$: participant.value$,
pretendToBeDisconnected$,
disableRemoteVideo$,
...inputs,
});