Merge pull request #3336 from element-hq/robin/switch-camera-tile
Move the switch camera button to the local user's tile
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
|||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
ShareScreenSolidIcon,
|
ShareScreenSolidIcon,
|
||||||
SettingsSolidIcon,
|
SettingsSolidIcon,
|
||||||
SwitchCameraSolidIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
@@ -67,23 +66,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
|
|
||||||
props,
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={t("switch_camera")}>
|
|
||||||
<CpdButton
|
|
||||||
iconOnly
|
|
||||||
Icon={SwitchCameraSolidIcon}
|
|
||||||
kind="secondary"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ const TileWrapper_ = memo(
|
|||||||
useDrag((state) => onDrag?.current!(id, state), {
|
useDrag((state) => onDrag?.current!(id, state), {
|
||||||
target: ref,
|
target: ref,
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
preventScroll: true,
|
// Previous designs, which allowed tiles to be dragged and dropped around
|
||||||
|
// the scrolling grid, required us to set preventScroll to true here. But
|
||||||
|
// our designs no longer call for this, and meanwhile there's a bug in
|
||||||
|
// use-gesture that causes filterTaps + preventScroll to break buttons
|
||||||
|
// within tiles (like the 'switch camera' button) on mobile.
|
||||||
|
// https://github.com/pmndrs/use-gesture/issues/593
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
@media (max-width: 340px) {
|
@media (max-width: 340px) {
|
||||||
.invite,
|
.invite,
|
||||||
.switchCamera,
|
|
||||||
.shareScreen {
|
.shareScreen {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
ShareScreenButton,
|
ShareScreenButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
ReactionToggleButton,
|
ReactionToggleButton,
|
||||||
SwitchCameraButton,
|
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
@@ -94,7 +93,6 @@ import {
|
|||||||
useReactionsSender,
|
useReactionsSender,
|
||||||
} from "../reactions/useReactionsSender";
|
} from "../reactions/useReactionsSender";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
import { useSwitchCamera } from "./useSwitchCamera";
|
|
||||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||||
import {
|
import {
|
||||||
@@ -318,7 +316,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const showFooter = useBehavior(vm.showFooter$);
|
const showFooter = useBehavior(vm.showFooter$);
|
||||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
|
||||||
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
|
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
|
||||||
|
|
||||||
// Ideally we could detect taps by listening for click events and checking
|
// Ideally we could detect taps by listening for click events and checking
|
||||||
@@ -676,15 +673,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
if (switchCamera !== null)
|
|
||||||
buttons.push(
|
|
||||||
<SwitchCameraButton
|
|
||||||
key="switch_camera"
|
|
||||||
className={styles.switchCamera}
|
|
||||||
onClick={switchCamera}
|
|
||||||
onTouchEnd={onControlsTouchEnd}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
if (canScreenshare && !hideScreensharing) {
|
if (canScreenshare && !hideScreensharing) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<ShareScreenButton
|
<ShareScreenButton
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
type LocalVideoTrack,
|
type LocalVideoTrack,
|
||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { map } from "rxjs";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
EndCallButton,
|
EndCallButton,
|
||||||
MicButton,
|
MicButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
SwitchCameraButton,
|
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from "../button/Button";
|
} from "../button/Button";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -47,7 +45,6 @@ import { E2eeType } from "../e2ee/e2eeType";
|
|||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
|
|
||||||
import {
|
import {
|
||||||
useTrackProcessor,
|
useTrackProcessor,
|
||||||
useTrackProcessorSync,
|
useTrackProcessorSync,
|
||||||
@@ -195,12 +192,6 @@ export const LobbyView: FC<Props> = ({
|
|||||||
}, [devices, videoInputId, videoTrack]);
|
}, [devices, videoInputId, videoTrack]);
|
||||||
|
|
||||||
useTrackProcessorSync(videoTrack);
|
useTrackProcessorSync(videoTrack);
|
||||||
const showSwitchCamera = useShowSwitchCamera(
|
|
||||||
useObservable(
|
|
||||||
(inputs$) => inputs$.pipe(map(([video]) => video)),
|
|
||||||
[videoTrack],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||||
// animations and don't have to feel bad about reusing its CSS
|
// animations and don't have to feel bad about reusing its CSS
|
||||||
@@ -257,9 +248,6 @@ export const LobbyView: FC<Props> = ({
|
|||||||
onClick={onVideoPress}
|
onClick={onVideoPress}
|
||||||
disabled={muteStates.video.setEnabled === null}
|
disabled={muteStates.video.setEnabled === null}
|
||||||
/>
|
/>
|
||||||
{showSwitchCamera && (
|
|
||||||
<SwitchCameraButton onClick={showSwitchCamera} />
|
|
||||||
)}
|
|
||||||
<SettingsButton onClick={openSettings} />
|
<SettingsButton onClick={openSettings} />
|
||||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 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 {
|
|
||||||
fromEvent,
|
|
||||||
map,
|
|
||||||
merge,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
startWith,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
|
||||||
import {
|
|
||||||
facingModeFromLocalTrack,
|
|
||||||
type LocalVideoTrack,
|
|
||||||
TrackEvent,
|
|
||||||
} from "livekit-client";
|
|
||||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
|
||||||
import { platform } from "../Platform";
|
|
||||||
import { useLatest } from "../useLatest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the user should be shown a button to switch their camera,
|
|
||||||
* producing a callback if so.
|
|
||||||
*/
|
|
||||||
export function useSwitchCamera(
|
|
||||||
video$: Observable<LocalVideoTrack | null>,
|
|
||||||
): (() => void) | null {
|
|
||||||
const mediaDevices = useMediaDevices();
|
|
||||||
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
|
||||||
|
|
||||||
// Produce an observable like the input 'video' observable, except make it
|
|
||||||
// emit whenever the track is muted or the device changes
|
|
||||||
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
|
|
||||||
(inputs$) =>
|
|
||||||
inputs$.pipe(
|
|
||||||
switchMap(([video$]) => video$),
|
|
||||||
switchMap((video) => {
|
|
||||||
if (video === null) return of(null);
|
|
||||||
return merge(
|
|
||||||
fromEvent(video, TrackEvent.Restarted).pipe(
|
|
||||||
startWith(null),
|
|
||||||
map(() => video),
|
|
||||||
),
|
|
||||||
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[video$],
|
|
||||||
);
|
|
||||||
|
|
||||||
const switchCamera$: Observable<(() => void) | null> = useObservable(
|
|
||||||
(inputs$) =>
|
|
||||||
platform === "desktop"
|
|
||||||
? of(null)
|
|
||||||
: inputs$.pipe(
|
|
||||||
switchMap(([track$]) => track$),
|
|
||||||
map((track) => {
|
|
||||||
if (track === null) return null;
|
|
||||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
|
||||||
// If the camera isn't front or back-facing, don't provide a switch
|
|
||||||
// camera shortcut at all
|
|
||||||
if (facingMode !== "user" && facingMode !== "environment")
|
|
||||||
return null;
|
|
||||||
// Restart the track with a camera facing the opposite direction
|
|
||||||
return (): void =>
|
|
||||||
void track
|
|
||||||
.restartTrack({
|
|
||||||
facingMode: facingMode === "user" ? "environment" : "user",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Inform the MediaDeviceContext which camera was chosen
|
|
||||||
const deviceId =
|
|
||||||
track.mediaStreamTrack.getSettings().deviceId;
|
|
||||||
if (deviceId !== undefined) setVideoInput.current(deviceId);
|
|
||||||
})
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error("Failed to switch camera", facingMode, e),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[videoTrack$],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useObservableEagerState(switchCamera$);
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
LocalVideoTrack,
|
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
|
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +58,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
observeTrackReference$,
|
|
||||||
RemoteUserMediaViewModel,
|
RemoteUserMediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
@@ -263,6 +260,7 @@ class UserMedia {
|
|||||||
participant: LocalParticipant | RemoteParticipant | undefined,
|
participant: LocalParticipant | RemoteParticipant | undefined,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
mediaDevices: MediaDevices,
|
||||||
displayname$: Observable<string>,
|
displayname$: Observable<string>,
|
||||||
handRaised$: Observable<Date | null>,
|
handRaised$: Observable<Date | null>,
|
||||||
reaction$: Observable<ReactionOption | null>,
|
reaction$: Observable<ReactionOption | null>,
|
||||||
@@ -276,6 +274,7 @@ class UserMedia {
|
|||||||
this.participant$ as Behavior<LocalParticipant>,
|
this.participant$ as Behavior<LocalParticipant>,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
|
mediaDevices,
|
||||||
this.scope.behavior(displayname$),
|
this.scope.behavior(displayname$),
|
||||||
this.scope.behavior(handRaised$),
|
this.scope.behavior(handRaised$),
|
||||||
this.scope.behavior(reaction$),
|
this.scope.behavior(reaction$),
|
||||||
@@ -390,18 +389,6 @@ function getRoomMemberFromRtcMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
|
|
||||||
observeTrackReference$(
|
|
||||||
this.livekitRoom.localParticipant,
|
|
||||||
Track.Source.Camera,
|
|
||||||
).pipe(
|
|
||||||
map((trackRef) => {
|
|
||||||
const track = trackRef?.publication?.track;
|
|
||||||
return track instanceof LocalVideoTrack ? track : null;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raw list of RemoteParticipants as reported by LiveKit
|
* The raw list of RemoteParticipants as reported by LiveKit
|
||||||
*/
|
*/
|
||||||
@@ -616,6 +603,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.mediaDevices,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||||
),
|
),
|
||||||
@@ -680,6 +668,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
participant,
|
participant,
|
||||||
this.options.encryptionSystem,
|
this.options.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
|
this.mediaDevices,
|
||||||
this.memberDisplaynames$.pipe(
|
this.memberDisplaynames$.pipe(
|
||||||
map(
|
map(
|
||||||
(m) => m.get(participant.identity) ?? "[👻]",
|
(m) => m.get(participant.identity) ?? "[👻]",
|
||||||
|
|||||||
@@ -5,14 +5,40 @@ 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 { expect, test, vi } from "vitest";
|
import { expect, onTestFinished, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type LocalTrackPublication,
|
||||||
|
LocalVideoTrack,
|
||||||
|
TrackEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { waitFor } from "@testing-library/dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
mockLocalParticipant,
|
||||||
|
mockMediaDevices,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
withLocalMedia,
|
withLocalMedia,
|
||||||
withRemoteMedia,
|
withRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
|
import { getValue } from "../utils/observable";
|
||||||
|
import { constant } from "./Behavior";
|
||||||
|
|
||||||
|
global.MediaStreamTrack = class {} as unknown as {
|
||||||
|
new (): MediaStreamTrack;
|
||||||
|
prototype: MediaStreamTrack;
|
||||||
|
};
|
||||||
|
global.MediaStream = class {} as unknown as {
|
||||||
|
new (): MediaStream;
|
||||||
|
prototype: MediaStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
|
vi.mock("../Platform", () => ({
|
||||||
|
get platform(): string {
|
||||||
|
return platformMock();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||||
|
|
||||||
@@ -79,17 +105,23 @@ test("toggle fit/contain for a participant's video", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("local media remembers whether it should always be shown", async () => {
|
test("local media remembers whether it should always be shown", async () => {
|
||||||
await withLocalMedia(rtcMembership, {}, (vm) =>
|
await withLocalMedia(
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
rtcMembership,
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
{},
|
||||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
mockLocalParticipant({}),
|
||||||
}),
|
mockMediaDevices({}),
|
||||||
|
(vm) =>
|
||||||
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
|
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||||
|
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// Next local media should start out *not* always shown
|
// Next local media should start out *not* always shown
|
||||||
await withLocalMedia(
|
await withLocalMedia(
|
||||||
rtcMembership,
|
rtcMembership,
|
||||||
|
|
||||||
{},
|
{},
|
||||||
|
mockLocalParticipant({}),
|
||||||
|
mockMediaDevices({}),
|
||||||
(vm) =>
|
(vm) =>
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||||
@@ -97,3 +129,77 @@ test("local media remembers whether it should always be shown", async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("switch cameras", async () => {
|
||||||
|
// Camera switching is only available on mobile
|
||||||
|
platformMock.mockReturnValue("android");
|
||||||
|
onTestFinished(() => void platformMock.mockReset());
|
||||||
|
|
||||||
|
// Construct a mock video track which knows how to be restarted
|
||||||
|
const track = new LocalVideoTrack({
|
||||||
|
getConstraints() {},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
} as unknown as MediaStreamTrack);
|
||||||
|
|
||||||
|
let deviceId = "front camera";
|
||||||
|
const restartTrack = vi.fn(async ({ facingMode }) => {
|
||||||
|
deviceId = facingMode === "user" ? "front camera" : "back camera";
|
||||||
|
track.emit(TrackEvent.Restarted);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
track.restartTrack = restartTrack;
|
||||||
|
|
||||||
|
Object.defineProperty(track, "mediaStreamTrack", {
|
||||||
|
get() {
|
||||||
|
return {
|
||||||
|
label: "Video",
|
||||||
|
getSettings: (): object => ({
|
||||||
|
deviceId,
|
||||||
|
facingMode: deviceId === "front camera" ? "user" : "environment",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectVideoInput = vi.fn();
|
||||||
|
|
||||||
|
await withLocalMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockLocalParticipant({
|
||||||
|
getTrackPublication() {
|
||||||
|
return { track } as unknown as LocalTrackPublication;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mockMediaDevices({
|
||||||
|
videoInput: {
|
||||||
|
available$: constant(new Map()),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: selectVideoInput,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (vm) => {
|
||||||
|
// Switch to back camera
|
||||||
|
getValue(vm.switchCamera$)!();
|
||||||
|
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
facingMode: "environment",
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||||
|
});
|
||||||
|
expect(deviceId).toBe("back camera");
|
||||||
|
|
||||||
|
// Switch to front camera
|
||||||
|
getValue(vm.switchCamera$)!();
|
||||||
|
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||||
|
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||||
|
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||||
|
});
|
||||||
|
expect(deviceId).toBe("front camera");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
LocalTrack,
|
LocalTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
type Participant,
|
type Participant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
RemoteTrack,
|
RemoteTrack,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type RoomMember } from "matrix-js-sdk";
|
import { type RoomMember } from "matrix-js-sdk";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
type Observable,
|
type Observable,
|
||||||
@@ -51,6 +53,8 @@ import { accumulate } from "../utils/observable";
|
|||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { type ReactionOption } from "../reactions";
|
import { type ReactionOption } from "../reactions";
|
||||||
|
import { platform } from "../Platform";
|
||||||
|
import { type MediaDevices } from "./MediaDevices";
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
export function observeTrackReference$(
|
export function observeTrackReference$(
|
||||||
@@ -443,20 +447,38 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
*/
|
*/
|
||||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
/**
|
/**
|
||||||
* Whether the video should be mirrored.
|
* The local video track as an observable that emits whenever the track
|
||||||
|
* changes, the camera is switched, or the track is muted.
|
||||||
*/
|
*/
|
||||||
public readonly mirror$ = this.scope.behavior(
|
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
this.video$.pipe(
|
this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication?.track;
|
||||||
if (!(track instanceof LocalTrack)) return of(false);
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
// Watch for track restarts, because they indicate a camera switch
|
return merge(
|
||||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
startWith(null),
|
// This event is also emitted when unmuting the track object.
|
||||||
// Mirror only front-facing cameras (those that face the user)
|
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
startWith(null),
|
||||||
|
map(() => track),
|
||||||
|
),
|
||||||
|
// When the track object is muted, reset it to null.
|
||||||
|
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the video should be mirrored.
|
||||||
|
*/
|
||||||
|
public readonly mirror$ = this.scope.behavior(
|
||||||
|
this.videoTrack$.pipe(
|
||||||
|
// Mirror only front-facing cameras (those that face the user)
|
||||||
|
map(
|
||||||
|
(track) =>
|
||||||
|
track !== null &&
|
||||||
|
facingModeFromLocalTrack(track).facingMode === "user",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,12 +489,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for switching between the front and back cameras.
|
||||||
|
*/
|
||||||
|
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||||
|
this.scope.behavior(
|
||||||
|
platform === "desktop"
|
||||||
|
? of(null)
|
||||||
|
: this.videoTrack$.pipe(
|
||||||
|
map((track) => {
|
||||||
|
if (track === null) return null;
|
||||||
|
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||||
|
// If the camera isn't front or back-facing, don't provide a switch
|
||||||
|
// camera shortcut at all
|
||||||
|
if (facingMode !== "user" && facingMode !== "environment")
|
||||||
|
return null;
|
||||||
|
// Restart the track with a camera facing the opposite direction
|
||||||
|
return (): void =>
|
||||||
|
void track
|
||||||
|
.restartTrack({
|
||||||
|
facingMode: facingMode === "user" ? "environment" : "user",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Inform the MediaDevices which camera was chosen
|
||||||
|
const deviceId =
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId;
|
||||||
|
if (deviceId !== undefined)
|
||||||
|
this.mediaDevices.videoInput.select(deviceId);
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Failed to switch camera", facingMode, e),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant$: Behavior<LocalParticipant | undefined>,
|
participant$: Behavior<LocalParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
|
private readonly mediaDevices: MediaDevices,
|
||||||
displayName$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
|
|||||||
@@ -83,3 +83,25 @@ borders don't support gradients */
|
|||||||
.volumeSlider {
|
.volumeSlider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--cpd-color-bg-action-secondary-rest);
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera > svg {
|
||||||
|
color: var(--cpd-color-icon-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover) {
|
||||||
|
.tile .switchCamera:hover {
|
||||||
|
background: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
border-color: var(--cpd-color-border-interactive-hovered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .switchCamera:active {
|
||||||
|
background: var(--cpd-color-bg-subtle-primary);
|
||||||
|
border-color: var(--cpd-color-border-interactive-hovered);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
UserProfileIcon,
|
UserProfileIcon,
|
||||||
ExpandIcon,
|
ExpandIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
|
SwitchCameraSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -65,6 +66,7 @@ interface UserMediaTileProps extends TileProps {
|
|||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
locallyMuted: boolean;
|
locallyMuted: boolean;
|
||||||
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
locallyMuted,
|
locallyMuted,
|
||||||
|
primaryButton,
|
||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
className,
|
className,
|
||||||
@@ -160,20 +163,22 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
}
|
}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
<Menu
|
primaryButton ?? (
|
||||||
open={menuOpen}
|
<Menu
|
||||||
onOpenChange={setMenuOpen}
|
open={menuOpen}
|
||||||
title={displayName}
|
onOpenChange={setMenuOpen}
|
||||||
trigger={
|
title={displayName}
|
||||||
<button aria-label={t("common.options")}>
|
trigger={
|
||||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
<button aria-label={t("common.options")}>
|
||||||
</button>
|
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||||
}
|
</button>
|
||||||
side="left"
|
}
|
||||||
align="start"
|
side="left"
|
||||||
>
|
align="start"
|
||||||
{menu}
|
>
|
||||||
</Menu>
|
{menu}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
raisedHandTime={handRaised ?? undefined}
|
raisedHandTime={handRaised ?? undefined}
|
||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
@@ -208,6 +213,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mirror = useBehavior(vm.mirror$);
|
const mirror = useBehavior(vm.mirror$);
|
||||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||||
|
const switchCamera = useBehavior(vm.switchCamera$);
|
||||||
|
|
||||||
const latestAlwaysShow = useLatest(alwaysShow);
|
const latestAlwaysShow = useLatest(alwaysShow);
|
||||||
const onSelectAlwaysShow = useCallback(
|
const onSelectAlwaysShow = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
@@ -223,6 +230,17 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
locallyMuted={false}
|
locallyMuted={false}
|
||||||
mirror={mirror}
|
mirror={mirror}
|
||||||
|
primaryButton={
|
||||||
|
switchCamera === null ? undefined : (
|
||||||
|
<button
|
||||||
|
className={styles.switchCamera}
|
||||||
|
aria-label={t("switch_camera")}
|
||||||
|
onClick={switchCamera}
|
||||||
|
>
|
||||||
|
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
menuStart={
|
menuStart={
|
||||||
<ToggleMenuItem
|
<ToggleMenuItem
|
||||||
Icon={VisibilityOnIcon}
|
Icon={VisibilityOnIcon}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
|
|
||||||
.nameTag {
|
.nameTag {
|
||||||
grid-area: nameTag;
|
grid-area: nameTag;
|
||||||
|
place-self: end start;
|
||||||
padding: var(--cpd-space-1x);
|
padding: var(--cpd-space-1x);
|
||||||
padding-block: var(--cpd-space-1x);
|
padding-block: var(--cpd-space-1x);
|
||||||
color: var(--cpd-color-text-primary);
|
color: var(--cpd-color-text-primary);
|
||||||
@@ -173,7 +174,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fg > button:active {
|
.fg > button:active {
|
||||||
background: var(--cpd-color-bg-action-primary-pressed) !important;
|
background: var(--cpd-color-bg-action-primary-pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg > button[data-state="open"] {
|
.fg > button[data-state="open"] {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import userEvent from "@testing-library/user-event";
|
|||||||
|
|
||||||
import { SpotlightTile } from "./SpotlightTile";
|
import { SpotlightTile } from "./SpotlightTile";
|
||||||
import {
|
import {
|
||||||
|
mockLocalParticipant,
|
||||||
|
mockMediaDevices,
|
||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
withLocalMedia,
|
withLocalMedia,
|
||||||
withRemoteMedia,
|
withRemoteMedia,
|
||||||
@@ -39,6 +41,8 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
rawDisplayName: "Bob",
|
rawDisplayName: "Bob",
|
||||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||||
},
|
},
|
||||||
|
mockLocalParticipant({}),
|
||||||
|
mockMediaDevices({}),
|
||||||
async (vm2) => {
|
async (vm2) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const toggleExpanded = vi.fn();
|
const toggleExpanded = vi.fn();
|
||||||
|
|||||||
@@ -243,9 +243,10 @@ export function mockLocalParticipant(
|
|||||||
export async function withLocalMedia(
|
export async function withLocalMedia(
|
||||||
localRtcMember: CallMembership,
|
localRtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
|
localParticipant: LocalParticipant,
|
||||||
|
mediaDevices: MediaDevices,
|
||||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const localParticipant = mockLocalParticipant({});
|
|
||||||
const vm = new LocalUserMediaViewModel(
|
const vm = new LocalUserMediaViewModel(
|
||||||
"local",
|
"local",
|
||||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||||
@@ -254,6 +255,7 @@ export async function withLocalMedia(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({ localParticipant }),
|
mockLivekitRoom({ localParticipant }),
|
||||||
|
mediaDevices,
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
|
|||||||
Reference in New Issue
Block a user