Files
ThreadNet-Web/apps/web/src/components/views/voip/VideoFeed.tsx
T
Michael TelatynskiandGitHub 4ec8f1edcd Remove LegacyCallHandler singleton (#34086)
* Remove dead code

* Remove LegacyCallHandler singleton

Route via SDKContext to cut import cycles

* Remove unused setting

* Fix tests

* Fix tests

* Cascade SDKContext through PersistedElement

* Improve coverage

* Iterate

* Improve coverage

* Improve coverage
2026-07-07 12:29:54 +00:00

226 lines
8.1 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2015, 2016 , 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import classnames from "classnames";
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import React from "react";
import { type CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { logger } from "matrix-js-sdk/src/logger";
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import { MicOffSolidIcon, MicOnSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import SettingsStore from "../../../settings/SettingsStore";
import RoomAvatar from "../avatars/RoomAvatar";
import { SDKContext } from "../../../contexts/SDKContext.ts";
interface IProps {
call: MatrixCall;
feed: CallFeed;
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize?: (e: Event) => void;
primary?: boolean;
secondary?: boolean;
}
interface IState {
audioMuted: boolean;
videoMuted: boolean;
}
export default class VideoFeed extends React.PureComponent<IProps, IState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
private element?: HTMLVideoElement;
public constructor(props: IProps) {
super(props);
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
};
}
public componentDidMount(): void {
this.updateFeed(null, this.props.feed);
this.playMedia();
}
public componentWillUnmount(): void {
this.updateFeed(this.props.feed, null);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
this.updateFeed(prevProps.feed, this.props.feed);
// If the mutes state has changed, we try to playMedia()
if (prevState.videoMuted !== this.state.videoMuted || prevProps.feed.stream !== this.props.feed.stream) {
this.playMedia();
}
}
public static getDerivedStateFromProps(props: IProps): IState {
return {
audioMuted: props.feed.isAudioMuted(),
videoMuted: props.feed.isVideoMuted(),
};
}
private setElementRef = (element: HTMLVideoElement): void => {
if (!element) {
this.element?.removeEventListener("resize", this.onResize);
return;
}
this.element = element;
element.addEventListener("resize", this.onResize);
};
private updateFeed(oldFeed: CallFeed | null, newFeed: CallFeed | null): void {
if (oldFeed === newFeed) return;
if (oldFeed) {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
if (this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.props.feed.measureVolumeActivity(false);
}
this.stopMedia();
}
if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
if (this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.props.feed.measureVolumeActivity(true);
}
this.playMedia();
}
}
private async playMedia(): Promise<void> {
const element = this.element;
if (!element) return;
// We play audio in AudioFeed, not here
element.muted = true;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
await element.play();
} catch (e) {
logger.info(
`Failed to play media element with feed for userId ` +
`${this.props.feed.userId} with purpose ${this.props.feed.purpose}`,
e,
);
}
}
private stopMedia(): void {
const element = this.element;
if (!element) return;
element.pause();
element.removeAttribute("src");
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = (): void => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
this.playMedia();
};
private onMuteStateChanged = (): void => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
};
private onResize = (e: Event): void => {
if (this.props.onResize && !this.props.feed.isLocal()) {
this.props.onResize(e);
}
};
public render(): React.ReactNode {
const { pipMode, primary, secondary, feed } = this.props;
const wrapperClasses = classnames("mx_VideoFeed", {
mx_VideoFeed_primary: primary,
mx_VideoFeed_secondary: secondary,
mx_VideoFeed_voice: this.state.videoMuted,
});
let micIcon;
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
micIcon = (
<div className="mx_VideoFeed_mic">
{this.state.audioMuted ? <MicOffSolidIcon /> : <MicOnSolidIcon />}
</div>
);
}
let content;
if (this.state.videoMuted) {
const callRoomId = this.context.legacyCallHandler.roomIdForCall(this.props.call);
const callRoom = (callRoomId ? this.context.client?.getRoom(callRoomId) : undefined) ?? undefined;
let avatarSize;
if (pipMode && primary) avatarSize = "76px";
else if (pipMode && !primary) avatarSize = "16px";
else if (!pipMode && primary) avatarSize = "160px";
else; // TBD
content = <RoomAvatar room={callRoom} size={avatarSize} />;
} else {
const videoClasses = classnames("mx_VideoFeed_video", {
mx_VideoFeed_video_mirror:
this.props.feed.isLocal() &&
this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia &&
SettingsStore.getValue("VideoView.flipVideoHorizontally"),
});
content = <video className={videoClasses} ref={this.setElementRef} />;
}
return (
<div className={wrapperClasses}>
{micIcon}
{content}
</div>
);
}
}