Merge pull request #3747 from JakeTripplJ/screenshare-volume
Add volume control to screen shares
This commit is contained in:
@@ -255,6 +255,7 @@
|
|||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"mute_for_me": "Mute for me",
|
"mute_for_me": "Mute for me",
|
||||||
"muted_for_me": "Muted for me",
|
"muted_for_me": "Muted for me",
|
||||||
|
"screen_share_volume": "Screen share volume",
|
||||||
"volume": "Volume",
|
"volume": "Volume",
|
||||||
"waiting_for_media": "Waiting for media..."
|
"waiting_for_media": "Waiting for media..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
.footer.overlay.hidden {
|
.footer.overlay.hidden {
|
||||||
display: grid;
|
display: grid;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer.overlay:has(:focus-visible) {
|
.footer.overlay:has(:focus-visible) {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
|||||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
type PointerEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type TouchEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -110,8 +110,6 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
|||||||
|
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
|
|
||||||
const maxTapDurationMs = 400;
|
|
||||||
|
|
||||||
export interface ActiveCallProps extends Omit<
|
export interface ActiveCallProps extends Omit<
|
||||||
InCallViewProps,
|
InCallViewProps,
|
||||||
"vm" | "livekitRoom" | "connState"
|
"vm" | "livekitRoom" | "connState"
|
||||||
@@ -334,40 +332,20 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
) : null;
|
) : null;
|
||||||
}, [ringOverlay]);
|
}, [ringOverlay]);
|
||||||
|
|
||||||
// Ideally we could detect taps by listening for click events and checking
|
const onViewClick = useCallback(
|
||||||
// that the pointerType of the event is "touch", but this isn't yet supported
|
(e: ReactMouseEvent) => {
|
||||||
// in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility
|
if (
|
||||||
// Instead we have to watch for sufficiently fast touch events.
|
(e.nativeEvent as PointerEvent).pointerType === "touch" &&
|
||||||
const touchStart = useRef<number | null>(null);
|
// If an interactive element was tapped, don't count this as a tap on the screen
|
||||||
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
|
(e.target as Element).closest?.("button, input") === null
|
||||||
const onTouchEnd = useCallback(() => {
|
)
|
||||||
const start = touchStart.current;
|
vm.tapScreen();
|
||||||
if (start !== null && Date.now() - start <= maxTapDurationMs)
|
|
||||||
vm.tapScreen();
|
|
||||||
touchStart.current = null;
|
|
||||||
}, [vm]);
|
|
||||||
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
|
|
||||||
|
|
||||||
// We also need to tell the footer controls to prevent touch events from
|
|
||||||
// bubbling up, or else the footer will be dismissed before a click/change
|
|
||||||
// event can be registered on the control
|
|
||||||
const onControlsTouchEnd = useCallback(
|
|
||||||
(e: TouchEvent) => {
|
|
||||||
// Somehow applying pointer-events: none to the controls when the footer
|
|
||||||
// is hidden is not enough to stop clicks from happening as the footer
|
|
||||||
// becomes visible, so we check manually whether the footer is shown
|
|
||||||
if (showFooter) {
|
|
||||||
e.stopPropagation();
|
|
||||||
vm.tapControls();
|
|
||||||
} else {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[vm, showFooter],
|
[vm],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
const onPointerMove = useCallback(
|
||||||
(e: PointerEvent) => {
|
(e: ReactPointerEvent) => {
|
||||||
if (e.pointerType === "mouse") vm.hoverScreen();
|
if (e.pointerType === "mouse") vm.hoverScreen();
|
||||||
},
|
},
|
||||||
[vm],
|
[vm],
|
||||||
@@ -667,7 +645,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
key="audio"
|
key="audio"
|
||||||
muted={!audioEnabled}
|
muted={!audioEnabled}
|
||||||
onClick={toggleAudio ?? undefined}
|
onClick={toggleAudio ?? undefined}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
disabled={toggleAudio === null}
|
disabled={toggleAudio === null}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
/>,
|
/>,
|
||||||
@@ -675,7 +652,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
key="video"
|
key="video"
|
||||||
muted={!videoEnabled}
|
muted={!videoEnabled}
|
||||||
onClick={toggleVideo ?? undefined}
|
onClick={toggleVideo ?? undefined}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
disabled={toggleVideo === null}
|
disabled={toggleVideo === null}
|
||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
/>,
|
/>,
|
||||||
@@ -687,7 +663,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
className={styles.shareScreen}
|
className={styles.shareScreen}
|
||||||
enabled={sharingScreen}
|
enabled={sharingScreen}
|
||||||
onClick={vm.toggleScreenSharing}
|
onClick={vm.toggleScreenSharing}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
data-testid="incall_screenshare"
|
data-testid="incall_screenshare"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -699,18 +674,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
key="raise_hand"
|
key="raise_hand"
|
||||||
className={styles.raiseHand}
|
className={styles.raiseHand}
|
||||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (layout.type !== "pip")
|
if (layout.type !== "pip")
|
||||||
buttons.push(
|
buttons.push(<SettingsButton key="settings" onClick={openSettings} />);
|
||||||
<SettingsButton
|
|
||||||
key="settings"
|
|
||||||
onClick={openSettings}
|
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<EndCallButton
|
<EndCallButton
|
||||||
@@ -718,7 +686,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
onClick={function (): void {
|
onClick={function (): void {
|
||||||
vm.hangup();
|
vm.hangup();
|
||||||
}}
|
}}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
data-testid="incall_leave"
|
data-testid="incall_leave"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -751,7 +718,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
className={styles.layout}
|
className={styles.layout}
|
||||||
layout={gridMode}
|
layout={gridMode}
|
||||||
setLayout={setGridMode}
|
setLayout={setGridMode}
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -760,12 +726,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const allConnections = useBehavior(vm.allConnections$);
|
const allConnections = useBehavior(vm.allConnections$);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// The onClick handler here exists to control the visibility of the footer,
|
||||||
|
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||||
<div
|
<div
|
||||||
className={styles.inRoom}
|
className={styles.inRoom}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onTouchStart={onTouchStart}
|
onClick={onViewClick}
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
onTouchCancel={onTouchCancel}
|
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerOut={onPointerOut}
|
onPointerOut={onPointerOut}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChangeEvent, type FC, type TouchEvent, useCallback } from "react";
|
import { type ChangeEvent, type FC, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
@@ -22,15 +22,9 @@ interface Props {
|
|||||||
layout: Layout;
|
layout: Layout;
|
||||||
setLayout: (layout: Layout) => void;
|
setLayout: (layout: Layout) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
onTouchEnd?: (e: TouchEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutToggle: FC<Props> = ({
|
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||||
layout,
|
|
||||||
setLayout,
|
|
||||||
className,
|
|
||||||
onTouchEnd,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
@@ -47,7 +41,6 @@ export const LayoutToggle: FC<Props> = ({
|
|||||||
value="spotlight"
|
value="spotlight"
|
||||||
checked={layout === "spotlight"}
|
checked={layout === "spotlight"}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||||
@@ -58,7 +51,6 @@ export const LayoutToggle: FC<Props> = ({
|
|||||||
value="grid"
|
value="grid"
|
||||||
checked={layout === "grid"}
|
checked={layout === "grid"}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<GridIcon aria-hidden width={24} height={24} />
|
<GridIcon aria-hidden width={24} height={24} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { expect, onTestFinished, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
type LocalTrackPublication,
|
type LocalTrackPublication,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
|
Track,
|
||||||
TrackEvent,
|
TrackEvent,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { waitFor } from "@testing-library/dom";
|
import { waitFor } from "@testing-library/dom";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
mockRemoteMedia,
|
mockRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
mockRemoteParticipant,
|
mockRemoteParticipant,
|
||||||
|
mockRemoteScreenShare,
|
||||||
} from "../../utils/test";
|
} from "../../utils/test";
|
||||||
import { constant } from "../Behavior";
|
import { constant } from "../Behavior";
|
||||||
|
|
||||||
@@ -91,6 +93,73 @@ test("control a participant's volume", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("control a participant's screen share volume", () => {
|
||||||
|
const setVolumeSpy = vi.fn();
|
||||||
|
const vm = mockRemoteScreenShare(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||||
|
);
|
||||||
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
|
schedule("-ab---c---d|", {
|
||||||
|
a() {
|
||||||
|
// Try muting by toggling
|
||||||
|
vm.togglePlaybackMuted();
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||||
|
0,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
b() {
|
||||||
|
// Try unmuting by dragging the slider back up
|
||||||
|
vm.adjustPlaybackVolume(0.6);
|
||||||
|
vm.adjustPlaybackVolume(0.8);
|
||||||
|
vm.commitPlaybackVolume();
|
||||||
|
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||||
|
0.6,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||||
|
0.8,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
c() {
|
||||||
|
// Try muting by dragging the slider back down
|
||||||
|
vm.adjustPlaybackVolume(0.2);
|
||||||
|
vm.adjustPlaybackVolume(0);
|
||||||
|
vm.commitPlaybackVolume();
|
||||||
|
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||||
|
0.2,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||||
|
0,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
d() {
|
||||||
|
// Try unmuting by toggling
|
||||||
|
vm.togglePlaybackMuted();
|
||||||
|
// The volume should return to the last non-zero committed volume
|
||||||
|
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||||
|
0.8,
|
||||||
|
Track.Source.ScreenShareAudio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||||
|
a: 1,
|
||||||
|
b: 0,
|
||||||
|
c: 0.6,
|
||||||
|
d: 0.8,
|
||||||
|
e: 0.2,
|
||||||
|
f: 0,
|
||||||
|
g: 0.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("toggle fit/contain for a participant's video", () => {
|
test("toggle fit/contain for a participant's video", () => {
|
||||||
const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type RemoteParticipant } from "livekit-client";
|
import { Track, type RemoteParticipant } from "livekit-client";
|
||||||
import { map } from "rxjs";
|
import { map, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../Behavior";
|
import { type Behavior } from "../Behavior";
|
||||||
import {
|
import {
|
||||||
@@ -16,13 +16,20 @@ import {
|
|||||||
createBaseScreenShare,
|
createBaseScreenShare,
|
||||||
} from "./ScreenShareViewModel";
|
} from "./ScreenShareViewModel";
|
||||||
import { type ObservableScope } from "../ObservableScope";
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
|
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||||
|
import { observeTrackReference$ } from "../observeTrackReference";
|
||||||
|
|
||||||
export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel {
|
export interface RemoteScreenShareViewModel
|
||||||
|
extends BaseScreenShareViewModel, VolumeControls {
|
||||||
local: false;
|
local: false;
|
||||||
/**
|
/**
|
||||||
* Whether this screen share's video should be displayed.
|
* Whether this screen share's video should be displayed.
|
||||||
*/
|
*/
|
||||||
videoEnabled$: Behavior<boolean>;
|
videoEnabled$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* Whether this screen share should be considered to have an audio track.
|
||||||
|
*/
|
||||||
|
audioEnabled$: Behavior<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
||||||
@@ -36,9 +43,30 @@ export function createRemoteScreenShare(
|
|||||||
): RemoteScreenShareViewModel {
|
): RemoteScreenShareViewModel {
|
||||||
return {
|
return {
|
||||||
...createBaseScreenShare(scope, inputs),
|
...createBaseScreenShare(scope, inputs),
|
||||||
|
...createVolumeControls(scope, {
|
||||||
|
pretendToBeDisconnected$,
|
||||||
|
sink$: scope.behavior(
|
||||||
|
inputs.participant$.pipe(
|
||||||
|
map(
|
||||||
|
(p) => (volume) =>
|
||||||
|
p?.setVolume(volume, Track.Source.ScreenShareAudio),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
local: false,
|
local: false,
|
||||||
videoEnabled$: scope.behavior(
|
videoEnabled$: scope.behavior(
|
||||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||||
),
|
),
|
||||||
|
audioEnabled$: scope.behavior(
|
||||||
|
inputs.participant$.pipe(
|
||||||
|
switchMap((p) =>
|
||||||
|
p
|
||||||
|
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
||||||
|
: of(null),
|
||||||
|
),
|
||||||
|
map(Boolean),
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
.expand {
|
.expand {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
|
||||||
padding: var(--cpd-space-2x);
|
padding: var(--cpd-space-2x);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
border-radius: var(--cpd-radius-pill-effect);
|
||||||
@@ -108,6 +107,35 @@ Please see LICENSE in the repository root for full details.
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.volumeSlider {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable the hover effect for the screen share volume menu button */
|
||||||
|
.volumeMenuItem:hover {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volumeMenuItem {
|
||||||
|
gap: var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuMuteButton {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make icons change color with the theme */
|
||||||
|
.menuMuteButton > svg {
|
||||||
|
color: var(--cpd-color-icon-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.expand > svg {
|
.expand > svg {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--cpd-color-icon-primary);
|
color: var(--cpd-color-icon-primary);
|
||||||
@@ -119,17 +147,22 @@ Please see LICENSE in the repository root for full details.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand:active {
|
.expand:active,
|
||||||
|
.expand[data-state="open"] {
|
||||||
background: var(--cpd-color-gray-100);
|
background: var(--cpd-color-gray-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover) {
|
@media (hover) {
|
||||||
|
.tile > div > button {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
.tile:hover > div > button {
|
.tile:hover > div > button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile:has(:focus-visible) > div > button {
|
.tile:has(:focus-visible) > div > button,
|
||||||
|
.tile > div:has([data-state="open"]) > button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { test, expect, vi } from "vitest";
|
|||||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||||
import { axe } from "vitest-axe";
|
import { axe } from "vitest-axe";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { SpotlightTile } from "./SpotlightTile";
|
import { SpotlightTile } from "./SpotlightTile";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
mockLocalMedia,
|
mockLocalMedia,
|
||||||
mockRemoteMedia,
|
mockRemoteMedia,
|
||||||
mockRemoteParticipant,
|
mockRemoteParticipant,
|
||||||
|
mockRemoteScreenShare,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
@@ -78,3 +80,63 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||||
expect(toggleExpanded).toHaveBeenCalled();
|
expect(toggleExpanded).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Screen share volume UI is shown when screen share has audio", async () => {
|
||||||
|
const vm = mockRemoteScreenShare(
|
||||||
|
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true));
|
||||||
|
|
||||||
|
const toggleExpanded = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<SpotlightTile
|
||||||
|
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
expanded={false}
|
||||||
|
onToggleExpanded={toggleExpanded}
|
||||||
|
showIndicators
|
||||||
|
focusable
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
|
||||||
|
// Volume menu button should exist
|
||||||
|
expect(screen.queryByRole("button", { name: /volume/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Screen share volume UI is hidden when screen share has no audio", async () => {
|
||||||
|
const vm = mockRemoteScreenShare(
|
||||||
|
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false));
|
||||||
|
|
||||||
|
const toggleExpanded = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<SpotlightTile
|
||||||
|
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
expanded={false}
|
||||||
|
onToggleExpanded={toggleExpanded}
|
||||||
|
showIndicators
|
||||||
|
focusable
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
|
||||||
|
// Volume menu button should not exist
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /volume/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
CollapseIcon,
|
CollapseIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
VolumeOffIcon,
|
||||||
|
VolumeOnIcon,
|
||||||
|
VolumeOffSolidIcon,
|
||||||
|
VolumeOnSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type Observable, map } from "rxjs";
|
import { type Observable, map } from "rxjs";
|
||||||
@@ -27,6 +31,7 @@ import { useObservableRef } from "observable-hooks";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
|
import { Menu, MenuItem } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||||
@@ -45,6 +50,8 @@ import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
|||||||
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
||||||
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
|
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
|
||||||
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||||
|
import { Slider } from "../Slider";
|
||||||
|
import { platform } from "../Platform";
|
||||||
|
|
||||||
interface SpotlightItemBaseProps {
|
interface SpotlightItemBaseProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
@@ -224,6 +231,73 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
|
|
||||||
SpotlightItem.displayName = "SpotlightItem";
|
SpotlightItem.displayName = "SpotlightItem";
|
||||||
|
|
||||||
|
interface ScreenShareVolumeButtonProps {
|
||||||
|
vm: RemoteScreenShareViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||||
|
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||||
|
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||||
|
|
||||||
|
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||||
|
const VolumeSolidIcon = playbackMuted
|
||||||
|
? VolumeOffSolidIcon
|
||||||
|
: VolumeOnSolidIcon;
|
||||||
|
|
||||||
|
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
|
||||||
|
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
|
||||||
|
const onVolumeChange = useCallback(
|
||||||
|
(v: number) => vm.adjustPlaybackVolume(v),
|
||||||
|
[vm],
|
||||||
|
);
|
||||||
|
const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
audioEnabled && (
|
||||||
|
<Menu
|
||||||
|
open={volumeMenuOpen}
|
||||||
|
onOpenChange={setVolumeMenuOpen}
|
||||||
|
title={t("video_tile.screen_share_volume")}
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className={styles.expand}
|
||||||
|
aria-label={t("video_tile.screen_share_volume")}
|
||||||
|
>
|
||||||
|
<VolumeSolidIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
as="div"
|
||||||
|
className={styles.volumeMenuItem}
|
||||||
|
onSelect={null}
|
||||||
|
label={null}
|
||||||
|
hideChevron={true}
|
||||||
|
>
|
||||||
|
<button className={styles.menuMuteButton} onClick={onMuteButtonClick}>
|
||||||
|
<VolumeIcon aria-hidden width={24} height={24} />
|
||||||
|
</button>
|
||||||
|
<Slider
|
||||||
|
className={styles.volumeSlider}
|
||||||
|
label={t("video_tile.volume")}
|
||||||
|
value={playbackVolume}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onValueChange={onVolumeChange}
|
||||||
|
onValueCommit={onVolumeCommit}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
vm: SpotlightTileViewModel;
|
vm: SpotlightTileViewModel;
|
||||||
@@ -258,6 +332,7 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
const latestMedia = useLatest(media);
|
const latestMedia = useLatest(media);
|
||||||
const latestVisibleId = useLatest(visibleId);
|
const latestVisibleId = useLatest(visibleId);
|
||||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||||
|
const visibleMedia = media.at(visibleIndex);
|
||||||
const canGoBack = visibleIndex > 0;
|
const canGoBack = visibleIndex > 0;
|
||||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||||
|
|
||||||
@@ -365,16 +440,21 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.bottomRightButtons}>
|
|
||||||
<button
|
|
||||||
className={classNames(styles.expand)}
|
|
||||||
aria-label={"maximise"}
|
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
tabIndex={focusable ? undefined : -1}
|
|
||||||
>
|
|
||||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<div className={styles.bottomRightButtons}>
|
||||||
|
{visibleMedia?.type === "screen share" && !visibleMedia.local && (
|
||||||
|
<ScreenShareVolumeButton vm={visibleMedia} />
|
||||||
|
)}
|
||||||
|
{platform === "desktop" && (
|
||||||
|
<button
|
||||||
|
className={classNames(styles.expand)}
|
||||||
|
aria-label={"maximise"}
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
tabIndex={focusable ? undefined : -1}
|
||||||
|
>
|
||||||
|
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onToggleExpanded && (
|
{onToggleExpanded && (
|
||||||
<button
|
<button
|
||||||
className={classNames(styles.expand)}
|
className={classNames(styles.expand)}
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ import {
|
|||||||
createRemoteUserMedia,
|
createRemoteUserMedia,
|
||||||
type RemoteUserMediaViewModel,
|
type RemoteUserMediaViewModel,
|
||||||
} from "../state/media/RemoteUserMediaViewModel";
|
} from "../state/media/RemoteUserMediaViewModel";
|
||||||
|
import {
|
||||||
|
createRemoteScreenShare,
|
||||||
|
type RemoteScreenShareViewModel,
|
||||||
|
} from "../state/media/RemoteScreenShareViewModel";
|
||||||
|
|
||||||
export function withFakeTimers(continuation: () => void): void {
|
export function withFakeTimers(continuation: () => void): void {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -393,6 +397,31 @@ export function mockRemoteMedia(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mockRemoteScreenShare(
|
||||||
|
rtcMember: CallMembership,
|
||||||
|
roomMember: Partial<RoomMember>,
|
||||||
|
participant: RemoteParticipant | null,
|
||||||
|
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
remoteParticipants$: of(participant ? [participant] : []),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
): RemoteScreenShareViewModel {
|
||||||
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
|
return createRemoteScreenShare(testScope(), {
|
||||||
|
id: "screenshare",
|
||||||
|
userId: member.userId,
|
||||||
|
participant$: constant(participant),
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
livekitRoom$: constant(livekitRoom),
|
||||||
|
focusUrl$: constant("https://rtc-example.org"),
|
||||||
|
pretendToBeDisconnected$: constant(false),
|
||||||
|
displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
|
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function mockConfig(
|
export function mockConfig(
|
||||||
config: Partial<ResolvedConfigOptions> = {},
|
config: Partial<ResolvedConfigOptions> = {},
|
||||||
): MockInstance<() => ResolvedConfigOptions> {
|
): MockInstance<() => ResolvedConfigOptions> {
|
||||||
|
|||||||
Reference in New Issue
Block a user