diff --git a/locales/en/app.json b/locales/en/app.json index 0b0ac7b4dc..b166c6a93b 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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)", "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.", @@ -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", diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 3136e2da26..e8e4035d27 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -17,6 +17,7 @@ import { EndCallIcon, ShareScreenSolidIcon, SettingsSolidIcon, + VisibilityOnIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; @@ -130,3 +131,30 @@ export const SettingsButton: FC> = ( ); }; + +interface ScreenShareViewButtonProps extends ComponentPropsWithoutRef<"button"> { + count: number; + open: boolean; +} + +export const ScreenShareViewButton: FC = ({ + count, + open, + ...props +}) => { + const { t } = useTranslation(); + const label = t("screenshare_preview_button_label"); + + return ( + + + + ); +}; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 135745eb37..8bd960e3eb 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -40,6 +40,7 @@ import { ShareScreenButton, SettingsButton, ReactionToggleButton, + ScreenShareViewButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { type HeaderStyle, useUrlParams } from "../UrlParams"; @@ -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]"); @@ -272,6 +275,10 @@ export const InCallView: FC = ({ 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$); @@ -571,6 +578,7 @@ export const InCallView: FC = ({ vm={model} expanded={spotlightExpanded} onToggleExpanded={onToggleExpanded} + onDismissScreenShare={(id) => vm.dismissScreenShare(id)} targetWidth={targetWidth} targetHeight={targetHeight} showIndicators={showSpotlightIndicatorsValue} @@ -692,6 +700,17 @@ export const InCallView: FC = ({ />, ); } + if (remoteScreenShareCount > 0) { + buttons.push( + setScreenSharePanelOpen((prev) => !prev)} + onTouchEnd={onControlsTouchEnd} + />, + ); + } if (supportsReactions) { buttons.push( = ({ {footer} {layout.type !== "pip" && ( <> + setScreenSharePanelOpen(false)} + > + vm.acceptScreenShare(id)} + onDismiss={(id) => vm.dismissScreenShare(id)} + /> + void; +} + +const PreviewTile: FC = ({ vm, accepted, onToggle }) => { + const { t } = useTranslation(); + const displayName = useBehavior(vm.displayName$); + const video = useBehavior(vm.video$); + + return ( + + ); +}; + +interface ScreenSharePreviewPanelProps { + screenShares: ScreenShareViewModel[]; + acceptedIds: Set; + onAccept: (id: string) => void; + onDismiss: (id: string) => void; +} + +export const ScreenSharePreviewPanel: FC = ({ + screenShares, + acceptedIds, + onAccept, + onDismiss, +}) => { + const remoteShares = screenShares.filter((s) => !s.local); + + return ( +
+ {remoteShares.map((vm) => { + const accepted = acceptedIds.has(vm.id); + return ( + { + if (accepted) { + onDismiss(vm.id); + } else { + onAccept(vm.id); + } + }} + /> + ); + })} +
+ ); +}; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 2b4078aa50..4a4b88d30d 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -5,10 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + type ChangeEvent, + type FC, + type ReactNode, + useCallback, + useEffect, + useId, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { type MatrixClient } from "matrix-js-sdk"; -import { Button, Root as Form, Separator } from "@vector-im/compound-web"; +import { + Button, + InlineField, + Label, + RadioControl, + Root as Form, + Separator, +} from "@vector-im/compound-web"; import { type Room as LivekitRoom } from "livekit-client"; import { Modal } from "../Modal"; @@ -24,6 +39,8 @@ import { soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, developerMode, + screenShareAudioProfile as screenShareAudioProfileSetting, + ScreenShareAudioProfile, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -38,6 +55,7 @@ import { useBehavior } from "../useBehavior"; type SettingsTab = | "audio" | "video" + | "screen_share" | "profile" | "preferences" | "feedback" @@ -107,6 +125,17 @@ export const SettingsModal: FC = ({ const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume); const [showDeveloperSettingsTab] = useSetting(developerMode); + const [screenShareAudioProfile, setScreenShareAudioProfile] = useSetting( + screenShareAudioProfileSetting, + ); + const screenShareAudioProfileRadioGroup = useId(); + const onScreenShareAudioProfileChange = useCallback( + (e: ChangeEvent) => { + setScreenShareAudioProfile(e.target.value as ScreenShareAudioProfile); + }, + [setScreenShareAudioProfile], + ); + const { available: isRageshakeAvailable } = useSubmitRageshake(); // For controlled devices, we will not show the input section: @@ -169,6 +198,85 @@ export const SettingsModal: FC = ({ ), }; + const screenShareTab: Tab = { + key: "screen_share", + name: t("settings.screen_share_tab_title"), + content: ( + <> +

{t("settings.screen_share_tab.audio_profile_heading")}

+
+ + } + > + + + + } + > + + + + } + > + + + + } + > + + +
+ + ), + }; + const videoTab: Tab = { key: "video", name: t("common.video"), @@ -217,7 +325,7 @@ export const SettingsModal: FC = ({ ), }; - const tabs = [audioTab, videoTab]; + const tabs = [audioTab, videoTab, screenShareTab]; if (widget === null) tabs.push(profileTab); tabs.push(preferencesTab); if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a674f1aae0..d4534803a2 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -145,3 +145,16 @@ export const customLivekitUrl = new Setting( "custom-livekit-url", null, ); + +export enum ScreenShareAudioProfile { + Music = "music", + MusicStereo = "musicStereo", + MusicHighQuality = "musicHighQuality", + MusicHighQualityStereo = "musicHighQualityStereo", +} + +export const screenShareAudioProfile = new Setting( + "screen-share-audio-profile", + ScreenShareAudioProfile.MusicHighQuality, +); + diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index c19c4818dc..a66c2379d6 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -15,6 +15,7 @@ import { } from "livekit-client"; import { type Room as MatrixRoom } from "matrix-js-sdk"; import { + BehaviorSubject, catchError, combineLatest, distinctUntilChanged, @@ -371,6 +372,24 @@ export interface CallViewModel { * Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit */ connected$: Behavior; + + // Screen share opt-in + /** + * All active screen shares (for the preview panel). + */ + screenShares$: Behavior; + /** + * Set of screen share IDs the user has opted into viewing. + */ + acceptedScreenShareIds$: Behavior>; + /** + * Opt in to viewing a remote screen share. + */ + acceptScreenShare: (id: string) => void; + /** + * Stop viewing a remote screen share. + */ + dismissScreenShare: (id: string) => void; } /** @@ -819,6 +838,51 @@ export function createCallViewModel$( ), ); + // Screen share opt-in state + const acceptedScreenShareIds$ = new BehaviorSubject>( + new Set(), + ); + const acceptScreenShare = (id: string): void => { + const next = new Set(acceptedScreenShareIds$.value); + next.add(id); + acceptedScreenShareIds$.next(next); + }; + const dismissScreenShare = (id: string): void => { + const next = new Set(acceptedScreenShareIds$.value); + next.delete(id); + acceptedScreenShareIds$.next(next); + }; + + // Clean up accepted IDs when screen shares disappear + screenShares$ + .pipe( + map((shares) => new Set(shares.map((s) => s.id))), + scope.bind(), + ) + .subscribe((activeIds) => { + const accepted = acceptedScreenShareIds$.value; + let changed = false; + for (const id of accepted) { + if (!activeIds.has(id)) { + accepted.delete(id); + changed = true; + } + } + if (changed) acceptedScreenShareIds$.next(new Set(accepted)); + }); + + /** + * Screen shares the user has opted into viewing (plus local screen shares + * which always bypass opt-in). + */ + const acceptedScreenShares$ = scope.behavior( + combineLatest([screenShares$, acceptedScreenShareIds$]).pipe( + map(([shares, acceptedIds]) => + shares.filter((s) => s.local || acceptedIds.has(s.id)), + ), + ), + ); + const joinSoundEffect$ = userMedia$.pipe( pairwise(), filter( @@ -932,9 +996,9 @@ export function createCallViewModel$( ); const spotlight$ = scope.behavior( - screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) return of(screenShares); + acceptedScreenShares$.pipe( + switchMap((acceptedScreenShares) => { + if (acceptedScreenShares.length > 0) return of(acceptedScreenShares); return spotlightSpeaker$.pipe( map((speaker) => (speaker ? [speaker] : [])), @@ -947,12 +1011,12 @@ export function createCallViewModel$( const pip$ = scope.behavior( combineLatest([ // TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits - screenShares$, + acceptedScreenShares$, spotlightSpeaker$, mediaItems$, ]).pipe( - switchMap(([screenShares, spotlight, mediaItems]) => { - if (screenShares.length > 0) { + switchMap(([acceptedScreenShares, spotlight, mediaItems]) => { + if (acceptedScreenShares.length > 0) { return spotlightSpeaker$; } if (!spotlight || spotlight.local) { @@ -973,11 +1037,7 @@ export function createCallViewModel$( ); const hasRemoteScreenShares$ = scope.behavior( - spotlight$.pipe( - map((spotlight) => - spotlight.some((vm) => vm.type === "screen share" && !vm.local), - ), - ), + acceptedScreenShares$.pipe(map((shares) => shares.some((vm) => !vm.local))), ); const pipEnabled$ = scope.behavior(setPipEnabled$, false); @@ -1555,6 +1615,11 @@ export function createCallViewModel$( reconnecting$: localMembership.reconnecting$, livekitRoomItems$, connected$: localMembership.connected$, + + screenShares$, + acceptedScreenShareIds$, + acceptScreenShare, + dismissScreenShare, }; } diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index eb641ca7cd..08ade3dd75 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -12,6 +12,7 @@ import { type ScreenShareCaptureOptions, RoomEvent, MediaDeviceFailure, + AudioPresets, } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { @@ -53,7 +54,11 @@ import { import { ElementWidgetActions, widget } from "../../../widget.ts"; import { getUrlParams } from "../../../UrlParams.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; -import { MatrixRTCMode } from "../../../settings/settings.ts"; +import { + MatrixRTCMode, + screenShareAudioProfile, + ScreenShareAudioProfile, +} from "../../../settings/settings.ts"; import { Config } from "../../../config/Config.ts"; import { ConnectionState, @@ -637,12 +642,35 @@ export const createLocalMembership$ = ({ !getUrlParams().hideScreensharing ) { toggleScreenSharing = (): void => { + const profile = screenShareAudioProfile.getValue(); + const stereo = + profile === ScreenShareAudioProfile.MusicStereo || + profile === ScreenShareAudioProfile.MusicHighQualityStereo; + const screenshareSettings: ScreenShareCaptureOptions = { - audio: true, + audio: { + autoGainControl: false, + echoCancellation: false, + noiseSuppression: false, + channelCount: stereo ? 2 : 1, + }, selfBrowserSurface: "include", surfaceSwitching: "include", systemAudio: "include", }; + + const profileMap = { + [ScreenShareAudioProfile.Music]: AudioPresets.music, + [ScreenShareAudioProfile.MusicStereo]: AudioPresets.musicStereo, + [ScreenShareAudioProfile.MusicHighQuality]: + AudioPresets.musicHighQuality, + [ScreenShareAudioProfile.MusicHighQualityStereo]: + AudioPresets.musicHighQualityStereo, + }; + const audioPreset = + profileMap[screenShareAudioProfile.getValue()] ?? + AudioPresets.musicHighQuality; + const targetScreenshareState = !sharingScreen$.value; logger.info( `toggleScreenSharing called. Switching ${ @@ -658,7 +686,10 @@ export const createLocalMembership$ = ({ // is still initializing or publishing tracks, because there's no // technical reason to disallow this. LiveKit will publish if it can. participant$.value - ?.setScreenShareEnabled(targetScreenshareState, screenshareSettings) + ?.setScreenShareEnabled(targetScreenshareState, screenshareSettings, { + audioPreset, + forceStereo: stereo, + }) .catch(logger.error); }; } diff --git a/src/state/media/RemoteScreenShareViewModel.ts b/src/state/media/RemoteScreenShareViewModel.ts index eff6d9c14f..d600144c88 100644 --- a/src/state/media/RemoteScreenShareViewModel.ts +++ b/src/state/media/RemoteScreenShareViewModel.ts @@ -6,10 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type RemoteParticipant } from "livekit-client"; +import { + type RemoteAudioTrack, + type RemoteParticipant, + Track, +} from "livekit-client"; import { map } from "rxjs"; import { type Behavior } from "../Behavior"; +import { createVolumeControls, type VolumeControls } from "../VolumeControls"; import { type BaseScreenShareInputs, type BaseScreenShareViewModel, @@ -17,7 +22,9 @@ import { } from "./ScreenShareViewModel"; import { type ObservableScope } from "../ObservableScope"; -export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { +export interface RemoteScreenShareViewModel + extends BaseScreenShareViewModel, + VolumeControls { local: false; /** * Whether this screen share's video should be displayed. @@ -36,6 +43,19 @@ export function createRemoteScreenShare( ): RemoteScreenShareViewModel { return { ...createBaseScreenShare(scope, inputs), + ...createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + inputs.participant$.pipe( + map((p) => (volume: number) => { + const track = p?.getTrackPublication( + Track.Source.ScreenShareAudio, + )?.track as RemoteAudioTrack | undefined; + track?.setVolume(volume); + }), + ), + ), + }), local: false, videoEnabled$: scope.behavior( pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 622496d23b..a456dc1489 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -167,3 +167,7 @@ Please see LICENSE in the repository root for full details. .indicators > .item[data-visible="true"] { background: var(--cpd-color-gray-1400); } + +.volumeSlider { + width: 100%; +} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 75c69479ae..79f404b18d 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -20,7 +20,13 @@ import { CollapseIcon, ChevronLeftIcon, ChevronRightIcon, + CloseIcon, + MicOffIcon, + OverflowHorizontalIcon, + VolumeOnIcon, + VolumeOffIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Menu, MenuItem, ToggleMenuItem } from "@vector-im/compound-web"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; import { useObservableRef } from "observable-hooks"; @@ -30,6 +36,7 @@ import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; +import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { useInitial } from "../useInitial"; @@ -148,6 +155,73 @@ const SpotlightRemoteScreenShareItem: FC< ); }; +/** + * Volume/mute controls for a remote screen share, rendered as a menu button + * in the SpotlightTile's bottom-right button bar. + */ +interface SpotlightScreenShareVolumeMenuProps { + vm: RemoteScreenShareViewModel; + displayName: string; + focusable: boolean; +} + +const SpotlightScreenShareVolumeMenu: FC< + SpotlightScreenShareVolumeMenuProps +> = ({ vm, displayName, focusable }) => { + const { t } = useTranslation(); + const playbackMuted = useBehavior(vm.playbackMuted$); + const playbackVolume = useBehavior(vm.playbackVolume$); + const [menuOpen, setMenuOpen] = useState(false); + const onSelectMute = useCallback( + (e: Event) => { + e.preventDefault(); + vm.togglePlaybackMuted(); + }, + [vm], + ); + + const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon; + + return ( + + + + } + side="left" + align="start" + > + + {/* TODO: Figure out how to make this slider keyboard accessible */} + + + + + ); +}; + interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; @@ -229,6 +303,7 @@ interface Props { vm: SpotlightTileViewModel; expanded: boolean; onToggleExpanded: (() => void) | null; + onDismissScreenShare?: (id: string) => void; targetWidth: number; targetHeight: number; showIndicators: boolean; @@ -242,6 +317,7 @@ export const SpotlightTile: FC = ({ vm, expanded, onToggleExpanded, + onDismissScreenShare, targetWidth, targetHeight, showIndicators, @@ -329,6 +405,14 @@ export const SpotlightTile: FC = ({ const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon; + const visibleVm = media.find((m) => m.id === visibleId); + const visibleRemoteScreenShare = + visibleVm && + visibleVm.type === "screen share" && + !visibleVm.local + ? visibleVm + : undefined; + return ( = ({ ))}
+ {visibleRemoteScreenShare && ( + + )} + {onDismissScreenShare && + media.find((m) => m.id === visibleId)?.type === "screen share" && + !media.find((m) => m.id === visibleId)?.local && ( + + )}