diff --git a/locales/en/app.json b/locales/en/app.json index 543942e2e..1ae3c9206 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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", diff --git a/src/button/Button.tsx b/src/button/Button.tsx index b8d052d6a..c3b596fdd 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -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 = ({ ); }; +interface DisableRemoteVideoButtonProps + extends ComponentPropsWithoutRef<"button"> { + /** Whether other participants' video is currently being shown. */ + enabled: boolean; + size?: "md" | "lg"; +} + +export const DisableRemoteVideoButton: FC = ({ + 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 ( + + + + ); +}; + interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; size: "md" | "lg"; diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 4f79f236c..4758286e4 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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 = ({ 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 = ({ ); } + if (toggleDisableRemoteVideo !== undefined) { + buttons.push( + , + ); + } + if (toggleScreenSharing !== undefined) { buttons.push( 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, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 09f73d1a6..6d05efb10 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -268,6 +268,16 @@ export interface CallViewModel { */ sharingScreen$: Behavior; + /** + * Whether other participants' video feeds are hidden (and not downloaded) + * to save bandwidth. Resets to false at the start of every call. + */ + disableRemoteVideo$: Behavior; + /** + * 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(); + 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(), diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 4307dea41..7af469a3b 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -34,11 +34,21 @@ export interface RemoteUserMediaInputs extends Omit< > { participant$: Behavior; pretendToBeDisconnected$: Behavior; + /** + * 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; } 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$], diff --git a/src/state/media/WrappedUserMediaViewModel.ts b/src/state/media/WrappedUserMediaViewModel.ts index e9575d0c0..479b8cc4a 100644 --- a/src/state/media/WrappedUserMediaViewModel.ts +++ b/src/state/media/WrappedUserMediaViewModel.ts @@ -89,6 +89,7 @@ interface WrappedUserMediaInputs extends Omit< participant: TaggedParticipant; mediaDevices: MediaDevices; pretendToBeDisconnected$: Behavior; + disableRemoteVideo$: Behavior; } 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, });