Skip to content
Draft
14 changes: 13 additions & 1 deletion locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@
"room_auth_view_continue_button": "Continue",
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"screenshare_button_label": "Share screen",
"screenshare_preview_button_label": "Screen shares",
"screenshare_preview_panel_title": "Active screen shares",
"screenshare_stop_viewing": "Stop viewing",
"screenshare_tile_viewing": "Viewing",
"settings": {
"audio_tab": {
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.",
Expand Down Expand Up @@ -232,7 +236,15 @@
"reactions_show_label": "Show reactions",
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
"show_hand_raised_timer_label": "Show hand raise duration"
}
},
"screen_share_tab": {
"audio_profile_heading": "Audio quality",
"audio_profile_music": "Music (48 kbps)",
"audio_profile_music_high_quality": "Music High Quality (96 kbps)",
"audio_profile_music_high_quality_stereo": "Music HQ Stereo (96 kbps)",
"audio_profile_music_stereo": "Music Stereo (64 kbps)"
},
"screen_share_tab_title": "Screen share"
},
"star_rating_input_label_one": "{{count}} star",
"star_rating_input_label_other": "{{count}} stars",
Expand Down
28 changes: 28 additions & 0 deletions src/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
VisibilityOnIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";

import styles from "./Button.module.css";
Expand Down Expand Up @@ -130,3 +131,30 @@ export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
</Tooltip>
);
};

interface ScreenShareViewButtonProps extends ComponentPropsWithoutRef<"button"> {
count: number;
open: boolean;
}

export const ScreenShareViewButton: FC<ScreenShareViewButtonProps> = ({
count,
open,
...props
}) => {
const { t } = useTranslation();
const label = t("screenshare_preview_button_label");

return (
<Tooltip label={label}>
<CpdButton
iconOnly
aria-label={`${label} (${count})`}
aria-expanded={open}
Icon={VisibilityOnIcon}
kind={open ? "primary" : "secondary"}
{...props}
/>
</Tooltip>
);
};
31 changes: 31 additions & 0 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
ScreenShareViewButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
Expand Down Expand Up @@ -107,6 +108,8 @@ import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { ScreenSharePreviewPanel } from "./ScreenSharePreviewPanel.tsx";
import { Modal } from "../Modal.tsx";

const logger = rootLogger.getChild("[InCallView]");

Expand Down Expand Up @@ -272,6 +275,10 @@ export const InCallView: FC<InCallViewProps> = ({
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const allScreenShares = useBehavior(vm.screenShares$);
const acceptedScreenShareIds = useBehavior(vm.acceptedScreenShareIds$);
const remoteScreenShareCount = allScreenShares.filter((s) => !s.local).length;
const [screenSharePanelOpen, setScreenSharePanelOpen] = useState(false);

const ringOverlay = useBehavior(vm.ringOverlay$);
const fatalCallError = useBehavior(vm.fatalError$);
Expand Down Expand Up @@ -571,6 +578,7 @@ export const InCallView: FC<InCallViewProps> = ({
vm={model}
expanded={spotlightExpanded}
onToggleExpanded={onToggleExpanded}
onDismissScreenShare={(id) => vm.dismissScreenShare(id)}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
Expand Down Expand Up @@ -692,6 +700,17 @@ export const InCallView: FC<InCallViewProps> = ({
/>,
);
}
if (remoteScreenShareCount > 0) {
buttons.push(
<ScreenShareViewButton
key="screen_share_view"
count={remoteScreenShareCount}
open={screenSharePanelOpen}
onClick={() => setScreenSharePanelOpen((prev) => !prev)}
onTouchEnd={onControlsTouchEnd}
/>,
);
}
if (supportsReactions) {
buttons.push(
<ReactionToggleButton
Expand Down Expand Up @@ -789,6 +808,18 @@ export const InCallView: FC<InCallViewProps> = ({
{footer}
{layout.type !== "pip" && (
<>
<Modal
open={screenSharePanelOpen}
title={t("screenshare_preview_panel_title")}
onDismiss={() => setScreenSharePanelOpen(false)}
>
<ScreenSharePreviewPanel
screenShares={allScreenShares}
acceptedIds={acceptedScreenShareIds}
onAccept={(id) => vm.acceptScreenShare(id)}
onDismiss={(id) => vm.dismissScreenShare(id)}
/>
</Modal>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
Expand Down
70 changes: 70 additions & 0 deletions src/room/ScreenSharePreviewPanel.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

.panel {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--cpd-space-4x);
padding: var(--cpd-space-4x);
}

.tile {
position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--cpd-space-3x);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition:
border-color 0.15s,
box-shadow 0.15s;
background: var(--cpd-color-bg-subtle-secondary);
}

.tile:hover {
border-color: var(--cpd-color-border-interactive-hovered);
}

.tile.accepted {
border-color: var(--cpd-color-border-interactive-primary);
box-shadow: 0 0 0 1px var(--cpd-color-border-interactive-primary);
}

.tile video {
width: 100%;
height: 100%;
object-fit: contain;
}

.tileOverlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
padding: var(--cpd-space-2x) var(--cpd-space-3x);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
color: var(--cpd-color-text-on-solid-primary);
}

.tileOverlay .badge {
display: inline-flex;
align-items: center;
gap: var(--cpd-space-1x);
padding: var(--cpd-space-1x) var(--cpd-space-2x);
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-bg-action-primary-rest);
font-size: var(--cpd-font-size-body-xs);
color: var(--cpd-color-text-on-solid-primary);
}

.empty {
padding: var(--cpd-space-8x);
text-align: center;
color: var(--cpd-color-text-secondary);
}
88 changes: 88 additions & 0 deletions src/room/ScreenSharePreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import { type FC } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { Text } from "@vector-im/compound-web";
import { VideoTrack } from "@livekit/components-react";

import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
import { useBehavior } from "../useBehavior";
import styles from "./ScreenSharePreviewPanel.module.css";

interface PreviewTileProps {
vm: ScreenShareViewModel;
accepted: boolean;
onToggle: () => void;
}

const PreviewTile: FC<PreviewTileProps> = ({ vm, accepted, onToggle }) => {
const { t } = useTranslation();
const displayName = useBehavior(vm.displayName$);
const video = useBehavior(vm.video$);

return (
<button
className={classNames(styles.tile, { [styles.accepted]: accepted })}
onClick={onToggle}
type="button"
aria-pressed={accepted}
aria-label={displayName}
>
{video?.publication !== undefined && (
<VideoTrack trackRef={video} tabIndex={-1} disablePictureInPicture />
)}
<div className={styles.tileOverlay}>
<Text as="span" size="sm" weight="medium">
{displayName}
</Text>
{accepted && (
<span className={styles.badge}>{t("screenshare_tile_viewing")}</span>
)}
</div>
</button>
);
};

interface ScreenSharePreviewPanelProps {
screenShares: ScreenShareViewModel[];
acceptedIds: Set<string>;
onAccept: (id: string) => void;
onDismiss: (id: string) => void;
}

export const ScreenSharePreviewPanel: FC<ScreenSharePreviewPanelProps> = ({
screenShares,
acceptedIds,
onAccept,
onDismiss,
}) => {
const remoteShares = screenShares.filter((s) => !s.local);

return (
<div className={styles.panel}>
{remoteShares.map((vm) => {
const accepted = acceptedIds.has(vm.id);
return (
<PreviewTile
key={vm.id}
vm={vm}
accepted={accepted}
onToggle={(): void => {
if (accepted) {
onDismiss(vm.id);
} else {
onAccept(vm.id);
}
}}
/>
);
})}
</div>
);
};
Loading