Prototyping of showing video tile for every call membership

...even if they don't have a LK participant
This commit is contained in:
Hugh Nimmo-Smith
2024-07-17 11:08:46 +01:00
parent 486430d1f0
commit 76a494464e
6 changed files with 290 additions and 82 deletions

View File

@@ -23,6 +23,7 @@
"@livekit/components-core": "^0.10.0", "@livekit/components-core": "^0.10.0",
"@livekit/components-react": "^2.0.0", "@livekit/components-react": "^2.0.0",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@nkzw/use-relative-time": "^1.1.0",
"@opentelemetry/api": "^1.4.0", "@opentelemetry/api": "^1.4.0",
"@opentelemetry/context-zone": "^1.9.1", "@opentelemetry/context-zone": "^1.9.1",
"@opentelemetry/exporter-jaeger": "^1.9.1", "@opentelemetry/exporter-jaeger": "^1.9.1",

View File

@@ -238,7 +238,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
const noControls = reducedControls && bounds.height <= 400; const noControls = reducedControls && bounds.height <= 400;
const vm = useCallViewModel( const vm = useCallViewModel(
rtcSession.room, rtcSession,
livekitRoom, livekitRoom,
matrixInfo.e2eeSystem.kind !== E2eeType.NONE, matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState, connState,

View File

@@ -52,6 +52,8 @@ import {
} from "rxjs"; } from "rxjs";
import { StateObservable, state } from "@react-rxjs/core"; import { StateObservable, state } from "@react-rxjs/core";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc/CallMembership";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { useObservable } from "./useObservable"; import { useObservable } from "./useObservable";
@@ -64,6 +66,7 @@ import {
MediaViewModel, MediaViewModel,
UserMediaViewModel, UserMediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
MembershipOnlyViewModel,
} from "./MediaViewModel"; } from "./MediaViewModel";
import { finalizeValue } from "../observable-utils"; import { finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
@@ -204,25 +207,45 @@ class ScreenShare {
} }
} }
type MediaItem = UserMedia | ScreenShare; class MembershipOnly {
public readonly vm: MembershipOnlyViewModel;
function findMatrixMember( public constructor(member: RoomMember) {
room: MatrixRoom, this.vm = new MembershipOnlyViewModel(member);
id: string, }
): RoomMember | undefined {
public destroy(): void {
this.vm.destroy();
}
}
type MediaItem = UserMedia | ScreenShare | MembershipOnly;
function matrixUserIdFromParticipantId(id: string): string | undefined {
if (!id) return undefined; if (!id) return undefined;
const parts = id.split(":"); const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon // must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) { if (parts.length < 3) {
logger.warn( logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination", `Livekit participants ID doesn't look like a userId:deviceId combination: ${id}`,
); );
return undefined; return undefined;
} }
parts.pop(); parts.pop();
const userId = parts.join(":"); const userId = parts.join(":");
return userId;
}
function findMatrixMember(
room: MatrixRoom,
id: string,
): RoomMember | undefined {
const userId = matrixUserIdFromParticipantId(id);
if (!userId) {
return undefined;
}
return room.getMember(userId) ?? undefined; return room.getMember(userId) ?? undefined;
} }
@@ -304,6 +327,24 @@ export class CallViewModel extends ViewModel {
}, },
); );
private readonly membershipsWithoutParticipant = combineLatest([
of(this.rtcSession.memberships),
this.remoteParticipants,
of(this.livekitRoom.localParticipant),
]).pipe(
scan((prev, [memberships, remoteParticipants, localParticipant]) => {
const participantIds = new Set(
remoteParticipants.map((p) =>
matrixUserIdFromParticipantId(p.identity),
),
);
participantIds.add(
matrixUserIdFromParticipantId(localParticipant.identity),
);
return memberships.filter((m) => !participantIds.has(m.sender ?? ""));
}, [] as CallMembership[]),
);
private readonly mediaItems: StateObservable<MediaItem[]> = state( private readonly mediaItems: StateObservable<MediaItem[]> = state(
combineLatest([ combineLatest([
this.remoteParticipants, this.remoteParticipants,
@@ -495,90 +536,136 @@ export class CallViewModel extends ViewModel {
combineLatest([ combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
this.membershipsWithoutParticipant,
]).pipe( ]).pipe(
scan((ts, [remoteParticipants, { participant: localParticipant }]) => { scan(
const ps = [localParticipant, ...remoteParticipants]; (
const tilesById = new Map(ts.map((t) => [t.id, t])); ts,
const now = Date.now(); [
let allGhosts = true; remoteParticipants,
{ participant: localParticipant },
membershipsWithoutParticipant,
],
) => {
const ps = [
localParticipant,
...remoteParticipants,
...membershipsWithoutParticipant,
];
const tilesById = new Map(ts.map((t) => [t.id, t]));
const now = Date.now();
let allGhosts = true;
const newTiles = ps.flatMap((p) => { const newTiles = ps.flatMap((p) => {
const userMediaId = p.identity; if (p instanceof CallMembership) {
const member = findMatrixMember(this.matrixRoom, userMediaId); const userId = p.sender ?? "";
allGhosts &&= member === undefined; const member = this.matrixRoom.getMember(userId);
const spokeRecently = if (!member) {
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000; logger.warn(
`Ruh, roh! No matrix member found for call membership '${userId}': ignoring`,
);
return [];
}
const membershipOnlyVm =
tilesById.get(userId)?.data ??
new MembershipOnlyViewModel(member);
tilesById.delete(userId);
// We always start with a local participant with the empty string as const membershipOnlyTile: TileDescriptor<MediaViewModel> = {
// their ID before we're connected, this is fine and we'll be in id: userId,
// "all ghosts" mode. focused: false,
if (userMediaId !== "" && member === undefined) { isPresenter: false,
logger.warn( isSpeaker: false,
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`, hasVideo: false,
); local: false,
} largeBaseSize: false,
data: membershipOnlyVm,
};
return [membershipOnlyTile];
}
const userMediaVm = const userMediaId = p.identity;
tilesById.get(userMediaId)?.data ?? const member = findMatrixMember(this.matrixRoom, userMediaId);
new UserMediaViewModel(userMediaId, member, p, this.encrypted); allGhosts &&= member === undefined;
tilesById.delete(userMediaId); const spokeRecently =
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
const userMediaTile: TileDescriptor<MediaViewModel> = { // We always start with a local participant with the empty string as
id: userMediaId, // their ID before we're connected, this is fine and we'll be in
focused: false, // "all ghosts" mode.
isPresenter: p.isScreenShareEnabled, if (userMediaId !== "" && member === undefined) {
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal, logger.warn(
hasVideo: p.isCameraEnabled, `Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
local: p.isLocal,
largeBaseSize: false,
data: userMediaVm,
};
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
const screenShareVm =
tilesById.get(screenShareId)?.data ??
new ScreenShareViewModel(
screenShareId,
member,
p,
this.encrypted,
); );
tilesById.delete(screenShareId); }
const screenShareTile: TileDescriptor<MediaViewModel> = { const userMediaVm =
id: screenShareId, tilesById.get(userMediaId)?.data ??
focused: true, new UserMediaViewModel(userMediaId, member, p, this.encrypted);
isPresenter: false, tilesById.delete(userMediaId);
isSpeaker: false,
hasVideo: true, const userMediaTile: TileDescriptor<MediaViewModel> = {
id: userMediaId,
focused: false,
isPresenter: p.isScreenShareEnabled,
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
hasVideo: p.isCameraEnabled,
local: p.isLocal, local: p.isLocal,
largeBaseSize: true, largeBaseSize: false,
placeNear: userMediaId, data: userMediaVm,
data: screenShareVm,
}; };
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// Any tiles left in the map are unused and should be destroyed if (p.isScreenShareEnabled) {
for (const t of tilesById.values()) t.data.destroy(); const screenShareId = `${userMediaId}:screen-share`;
const screenShareVm =
tilesById.get(screenShareId)?.data ??
new ScreenShareViewModel(
screenShareId,
member,
p,
this.encrypted,
);
tilesById.delete(screenShareId);
// If every item is a ghost, that probably means we're still connecting const screenShareTile: TileDescriptor<MediaViewModel> = {
// and shouldn't bother showing anything yet id: screenShareId,
return allGhosts ? [] : newTiles; focused: true,
}, [] as TileDescriptor<MediaViewModel>[]), isPresenter: false,
isSpeaker: false,
hasVideo: true,
local: p.isLocal,
largeBaseSize: true,
placeNear: userMediaId,
data: screenShareVm,
};
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// Any tiles left in the map are unused and should be destroyed
for (const t of tilesById.values()) t.data.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? [] : newTiles;
},
[] as TileDescriptor<MediaViewModel>[],
),
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.data.destroy(); for (const t of ts) t.data.destroy();
}), }),
), ),
); );
private get matrixRoom(): MatrixRoom {
return this.rtcSession.room;
}
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom, private readonly rtcSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encrypted: boolean, private readonly encrypted: boolean,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState: Observable<ECConnectionState>,
@@ -588,25 +675,25 @@ export class CallViewModel extends ViewModel {
} }
export function useCallViewModel( export function useCallViewModel(
matrixRoom: MatrixRoom, rtcSession: MatrixRTCSession,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
encrypted: boolean, encrypted: boolean,
connectionState: ECConnectionState, connectionState: ECConnectionState,
): CallViewModel { ): CallViewModel {
const prevMatrixRoom = usePrevious(matrixRoom); const prevRTCSession = usePrevious(rtcSession);
const prevLivekitRoom = usePrevious(livekitRoom); const prevLivekitRoom = usePrevious(livekitRoom);
const prevEncrypted = usePrevious(encrypted); const prevEncrypted = usePrevious(encrypted);
const connectionStateObservable = useObservable(connectionState); const connectionStateObservable = useObservable(connectionState);
const vm = useRef<CallViewModel>(); const vm = useRef<CallViewModel>();
if ( if (
matrixRoom !== prevMatrixRoom || rtcSession !== prevRTCSession ||
livekitRoom !== prevLivekitRoom || livekitRoom !== prevLivekitRoom ||
encrypted !== prevEncrypted encrypted !== prevEncrypted
) { ) {
vm.current?.destroy(); vm.current?.destroy();
vm.current = new CallViewModel( vm.current = new CallViewModel(
matrixRoom, rtcSession,
livekitRoom, livekitRoom,
encrypted, encrypted,
connectionStateObservable, connectionStateObservable,

View File

@@ -110,7 +110,10 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* Some participant's media. * Some participant's media.
*/ */
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; export type MediaViewModel =
| UserMediaViewModel
| ScreenShareViewModel
| MembershipOnlyViewModel;
/** /**
* Some participant's user media. * Some participant's user media.
@@ -239,3 +242,15 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
); );
} }
} }
/**
* Placeholder for a call membership that does not have a LiveKit participant associated with it.
*/
export class MembershipOnlyViewModel extends ViewModel {
public id: string;
public local = false;
public constructor(public readonly member: RoomMember) {
super();
this.id = member.userId;
}
}

View File

@@ -53,6 +53,7 @@ import {
Menu, Menu,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useStateObservable } from "@react-rxjs/core"; import { useStateObservable } from "@react-rxjs/core";
import useRelativeTime from "@nkzw/use-relative-time";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css"; import styles from "./VideoTile.module.css";
@@ -61,6 +62,7 @@ import {
ScreenShareViewModel, ScreenShareViewModel,
MediaViewModel, MediaViewModel,
UserMediaViewModel, UserMediaViewModel,
MembershipOnlyViewModel,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { subscribe } from "../state/subscribe"; import { subscribe } from "../state/subscribe";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
@@ -72,7 +74,7 @@ interface TileProps {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video?: TrackReferenceOrPlaceholder;
member: RoomMember | undefined; member: RoomMember | undefined;
videoEnabled: boolean; videoEnabled: boolean;
maximised: boolean; maximised: boolean;
@@ -110,6 +112,10 @@ const Tile = forwardRef<HTMLDivElement, TileProps>(
const { t } = useTranslation(); const { t } = useTranslation();
const mergedRef = useMergedRefs(tileRef, ref); const mergedRef = useMergedRefs(tileRef, ref);
const joinedCallTime = member?.events.member?.getTs() ?? 0;
const joinedCallAgo = useRelativeTime(joinedCallTime ?? 0);
return ( return (
<animated.div <animated.div
className={classNames(styles.videoTile, className, { className={classNames(styles.videoTile, className, {
@@ -129,7 +135,7 @@ const Tile = forwardRef<HTMLDivElement, TileProps>(
src={member?.getMxcAvatarUrl()} src={member?.getMxcAvatarUrl()}
className={styles.avatar} className={styles.avatar}
/> />
{video.publication !== undefined && ( {video?.publication !== undefined && (
<VideoTrack <VideoTrack
trackRef={video} trackRef={video}
// There's no reason for this to be focusable // There's no reason for this to be focusable
@@ -137,12 +143,21 @@ const Tile = forwardRef<HTMLDivElement, TileProps>(
disablePictureInPicture disablePictureInPicture
/> />
)} )}
{!video && member && joinedCallTime > 0 && (
<div style={{ textAlign: "center" }}>
<span title={member.userId}>{nameTag}</span> joined the call{" "}
<span title={new Date(joinedCallTime).toLocaleString()}>
{joinedCallAgo}
</span>{" "}
but is currently unreachable. Are they having connection problems?
</div>
)}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
<div className={styles.nameTag}> <div className={styles.nameTag}>
{nameTagLeadingIcon} {nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}> <Text as="span" size="sm" weight="medium" className={styles.name}>
{nameTag} <span title={member?.userId}>{nameTag}</span>{" "}
</Text> </Text>
{unencryptedWarning && ( {unencryptedWarning && (
<Tooltip <Tooltip
@@ -402,6 +417,75 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
ScreenShareTile.displayName = "ScreenShareTile"; ScreenShareTile.displayName = "ScreenShareTile";
interface MembershipOnlyTileProps {
vm: MembershipOnlyViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
}
const MembershipOnlyTile = subscribe<MembershipOnlyTileProps, HTMLDivElement>(
(
{
vm,
className,
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
fullscreen,
onToggleFullscreen,
},
ref,
) => {
const { t } = useTranslation();
const onClickFullScreen = useCallback(
() => onToggleFullscreen(vm.id),
[onToggleFullscreen, vm],
);
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
return (
<Tile
ref={ref}
className={classNames(className, styles.membershipOnly)}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
member={vm.member}
videoEnabled={true}
maximised={maximised}
unencryptedWarning={false}
nameTag={nameTag}
displayName={displayName}
primaryButton={
<button
aria-label={
fullscreen
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onClickFullScreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
}
/>
);
},
);
MembershipOnlyTile.displayName = "MembershipOnlyTile";
interface Props { interface Props {
vm: MediaViewModel; vm: MediaViewModel;
maximised: boolean; maximised: boolean;
@@ -471,7 +555,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
showSpeakingIndicator={showSpeakingIndicator} showSpeakingIndicator={showSpeakingIndicator}
/> />
); );
} else { } else if (vm instanceof ScreenShareViewModel) {
return ( return (
<ScreenShareTile <ScreenShareTile
ref={ref} ref={ref}
@@ -487,6 +571,22 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
onToggleFullscreen={onToggleFullscreen} onToggleFullscreen={onToggleFullscreen}
/> />
); );
} else if (vm instanceof MembershipOnlyViewModel) {
return (
<MembershipOnlyTile
ref={ref}
className={className}
style={style}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
fullscreen={fullscreen}
onToggleFullscreen={onToggleFullscreen}
/>
);
} }
}, },
); );

View File

@@ -1912,6 +1912,11 @@
version "3.2.14" version "3.2.14"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984"
"@nkzw/use-relative-time@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@nkzw/use-relative-time/-/use-relative-time-1.1.0.tgz#7eb52dc4c7ceaf49a1f05ecfbf725313cf5c1724"
integrity sha512-ogCL62FvScpRpsZUuaN6Jt0xPGRv62atQUNGyMcX+nZs4H5Fs5K1iA3MbSmkJ1y0n/N0RIRLc3VAp8o46lq2CA==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"