Respond to suggestions from code review
This commit is contained in:
@@ -126,9 +126,9 @@ export type GridMode = "grid" | "spotlight";
|
|||||||
export type WindowMode = "normal" | "full screen" | "pip";
|
export type WindowMode = "normal" | "full screen" | "pip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the grid.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
*/
|
*/
|
||||||
enum GridBin {
|
enum SortingBin {
|
||||||
SelfStart,
|
SelfStart,
|
||||||
Presenters,
|
Presenters,
|
||||||
Speakers,
|
Speakers,
|
||||||
@@ -235,74 +235,76 @@ export class CallViewModel extends ViewModel {
|
|||||||
|
|
||||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||||
// they've left
|
// they've left
|
||||||
private readonly remoteParticipantHolds = zip(
|
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||||
this.connectionState,
|
zip(
|
||||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
this.connectionState,
|
||||||
(s, ps) => {
|
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||||
// Whenever we switch focuses, we should retain all the previous
|
(s, ps) => {
|
||||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
// Whenever we switch focuses, we should retain all the previous
|
||||||
// give their clients time to switch over and avoid jarring layout shifts
|
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||||
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
// give their clients time to switch over and avoid jarring layout shifts
|
||||||
return concat(
|
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
||||||
// Hold these participants
|
return concat(
|
||||||
of({ hold: ps }),
|
// Hold these participants
|
||||||
// Wait for time to pass and the connection state to have changed
|
of({ hold: ps }),
|
||||||
Promise.all([
|
// Wait for time to pass and the connection state to have changed
|
||||||
new Promise<void>((resolve) =>
|
Promise.all([
|
||||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
new Promise<void>((resolve) =>
|
||||||
),
|
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||||
new Promise<void>((resolve) => {
|
),
|
||||||
const subscription = this.connectionState
|
new Promise<void>((resolve) => {
|
||||||
.pipe(this.scope.bind())
|
const subscription = this.connectionState
|
||||||
.subscribe((s) => {
|
.pipe(this.scope.bind())
|
||||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
.subscribe((s) => {
|
||||||
resolve();
|
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||||
subscription.unsubscribe();
|
resolve();
|
||||||
}
|
subscription.unsubscribe();
|
||||||
});
|
}
|
||||||
}),
|
});
|
||||||
// Then unhold them
|
}),
|
||||||
]).then(() => Promise.resolve({ unhold: ps })),
|
// Then unhold them
|
||||||
);
|
]).then(() => Promise.resolve({ unhold: ps })),
|
||||||
} else {
|
);
|
||||||
return EMPTY;
|
} else {
|
||||||
}
|
return EMPTY;
|
||||||
},
|
}
|
||||||
).pipe(
|
},
|
||||||
mergeAll(),
|
).pipe(
|
||||||
// Aggregate the hold instructions into a single list showing which
|
mergeAll(),
|
||||||
// participants are being held
|
// Aggregate the hold instructions into a single list showing which
|
||||||
scan(
|
// participants are being held
|
||||||
(holds, instruction) =>
|
scan(
|
||||||
"hold" in instruction
|
(holds, instruction) =>
|
||||||
? [instruction.hold, ...holds]
|
"hold" in instruction
|
||||||
: holds.filter((h) => h !== instruction.unhold),
|
? [instruction.hold, ...holds]
|
||||||
[] as RemoteParticipant[][],
|
: holds.filter((h) => h !== instruction.unhold),
|
||||||
),
|
[] as RemoteParticipant[][],
|
||||||
startWith([]),
|
),
|
||||||
);
|
startWith([]),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly remoteParticipants = combineLatest(
|
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
|
||||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
combineLatest(
|
||||||
(raw, holds) => {
|
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
||||||
const result = [...raw];
|
(raw, holds) => {
|
||||||
const resultIds = new Set(result.map((p) => p.identity));
|
const result = [...raw];
|
||||||
|
const resultIds = new Set(result.map((p) => p.identity));
|
||||||
|
|
||||||
// Incorporate the held participants into the list
|
// Incorporate the held participants into the list
|
||||||
for (const hold of holds) {
|
for (const hold of holds) {
|
||||||
for (const p of hold) {
|
for (const p of hold) {
|
||||||
if (!resultIds.has(p.identity)) {
|
if (!resultIds.has(p.identity)) {
|
||||||
result.push(p);
|
result.push(p);
|
||||||
resultIds.add(p.identity);
|
resultIds.add(p.identity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly mediaItems = state(
|
private readonly mediaItems: StateObservable<MediaItem[]> = state(
|
||||||
combineLatest([
|
combineLatest([
|
||||||
this.remoteParticipants,
|
this.remoteParticipants,
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
@@ -362,63 +364,59 @@ export class CallViewModel extends ViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly userMedia = this.mediaItems.pipe(
|
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||||
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly screenShares = this.mediaItems.pipe(
|
private readonly screenShares: Observable<ScreenShare[]> =
|
||||||
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
this.mediaItems.pipe(
|
||||||
);
|
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly spotlightSpeaker = this.userMedia.pipe(
|
private readonly spotlightSpeaker: Observable<UserMedia | null> =
|
||||||
switchMap((ms) =>
|
this.userMedia.pipe(
|
||||||
ms.length === 0
|
switchMap((ms) =>
|
||||||
? of([])
|
ms.length === 0
|
||||||
: combineLatest(
|
? of([])
|
||||||
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
: combineLatest(
|
||||||
),
|
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
|
||||||
),
|
),
|
||||||
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
),
|
||||||
(prev, ms) =>
|
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
|
||||||
// Decide who to spotlight:
|
(prev, ms) =>
|
||||||
// If the previous speaker is still speaking, stick with them rather
|
// Decide who to spotlight:
|
||||||
// than switching eagerly to someone else
|
// If the previous speaker is still speaking, stick with them rather
|
||||||
ms.find(([m, s]) => m === prev && s)?.[0] ??
|
// than switching eagerly to someone else
|
||||||
// Otherwise, select anyone who is speaking
|
ms.find(([m, s]) => m === prev && s)?.[0] ??
|
||||||
ms.find(([, s]) => s)?.[0] ??
|
// Otherwise, select anyone who is speaking
|
||||||
// Otherwise, stick with the person who was last speaking
|
ms.find(([, s]) => s)?.[0] ??
|
||||||
prev ??
|
// Otherwise, stick with the person who was last speaking
|
||||||
// Otherwise, spotlight the local user
|
prev ??
|
||||||
ms.find(([m]) => m.vm.local)?.[0] ??
|
// Otherwise, spotlight the local user
|
||||||
|
ms.find(([m]) => m.vm.local)?.[0] ??
|
||||||
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
),
|
||||||
),
|
distinctUntilChanged(),
|
||||||
distinctUntilChanged(),
|
throttleTime(800, undefined, { leading: true, trailing: true }),
|
||||||
throttleTime(800, undefined, { leading: true, trailing: true }),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
private readonly grid = this.userMedia.pipe(
|
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
|
||||||
switchMap((ms) => {
|
switchMap((ms) => {
|
||||||
const bins = ms.map((m) =>
|
const bins = ms.map((m) =>
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
|
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
|
||||||
(speaker, presenter, audio, video) =>
|
(speaker, presenter, audio, video) => {
|
||||||
[
|
let bin: SortingBin;
|
||||||
m,
|
if (m.vm.local) bin = SortingBin.SelfStart;
|
||||||
m.vm.local
|
else if (presenter) bin = SortingBin.Presenters;
|
||||||
? GridBin.SelfStart
|
else if (speaker) bin = SortingBin.Speakers;
|
||||||
: presenter
|
else if (video)
|
||||||
? GridBin.Presenters
|
bin = audio ? SortingBin.VideoAndAudio : SortingBin.Video;
|
||||||
: speaker
|
else bin = audio ? SortingBin.Audio : SortingBin.NoMedia;
|
||||||
? GridBin.Speakers
|
|
||||||
: video
|
return [m, bin] as const;
|
||||||
? audio
|
},
|
||||||
? GridBin.VideoAndAudio
|
|
||||||
: GridBin.Video
|
|
||||||
: audio
|
|
||||||
? GridBin.Audio
|
|
||||||
: GridBin.NoMedia,
|
|
||||||
] as const,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Sort the media by bin order and generate a tile for each one
|
// Sort the media by bin order and generate a tile for each one
|
||||||
@@ -430,7 +428,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly spotlight = combineLatest(
|
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
|
||||||
[this.screenShares, this.spotlightSpeaker],
|
[this.screenShares, this.spotlightSpeaker],
|
||||||
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
(screenShares, spotlightSpeaker): MediaViewModel[] =>
|
||||||
screenShares.length > 0
|
screenShares.length > 0
|
||||||
@@ -440,6 +438,10 @@ export class CallViewModel extends ViewModel {
|
|||||||
: [spotlightSpeaker.vm],
|
: [spotlightSpeaker.vm],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: Make this react to changes in window dimensions and screen
|
||||||
|
// orientation
|
||||||
|
private readonly windowMode = of<WindowMode>("normal");
|
||||||
|
|
||||||
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
|
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
|
||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* The layout mode of the media tile grid.
|
||||||
@@ -450,10 +452,6 @@ export class CallViewModel extends ViewModel {
|
|||||||
this._gridMode.next(value);
|
this._gridMode.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Make this react to changes in window dimensions and screen
|
|
||||||
// orientation
|
|
||||||
private readonly windowMode = of<WindowMode>("normal");
|
|
||||||
|
|
||||||
public readonly layout: StateObservable<Layout> = state(
|
public readonly layout: StateObservable<Layout> = state(
|
||||||
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
||||||
switch (windowMode) {
|
switch (windowMode) {
|
||||||
@@ -492,90 +490,91 @@ export class CallViewModel extends ViewModel {
|
|||||||
*/
|
*/
|
||||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
||||||
// which keeps more details of the layout order internal to the view model
|
// which keeps more details of the layout order internal to the view model
|
||||||
public readonly tiles = state(
|
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
|
||||||
combineLatest([
|
state(
|
||||||
this.remoteParticipants,
|
combineLatest([
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
this.remoteParticipants,
|
||||||
]).pipe(
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
]).pipe(
|
||||||
const ps = [localParticipant, ...remoteParticipants];
|
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
const ps = [localParticipant, ...remoteParticipants];
|
||||||
const now = Date.now();
|
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||||
let allGhosts = true;
|
const now = Date.now();
|
||||||
|
let allGhosts = true;
|
||||||
|
|
||||||
const newTiles = ps.flatMap((p) => {
|
const newTiles = ps.flatMap((p) => {
|
||||||
const userMediaId = p.identity;
|
const userMediaId = p.identity;
|
||||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||||
allGhosts &&= member === undefined;
|
allGhosts &&= member === undefined;
|
||||||
const spokeRecently =
|
const spokeRecently =
|
||||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||||
|
|
||||||
// We always start with a local participant with the empty string as
|
// We always start with a local participant with the empty string as
|
||||||
// their ID before we're connected, this is fine and we'll be in
|
// their ID before we're connected, this is fine and we'll be in
|
||||||
// "all ghosts" mode.
|
// "all ghosts" mode.
|
||||||
if (userMediaId !== "" && member === undefined) {
|
if (userMediaId !== "" && member === undefined) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMediaVm =
|
|
||||||
tilesById.get(userMediaId)?.data ??
|
|
||||||
new UserMediaViewModel(userMediaId, member, p, this.encrypted);
|
|
||||||
tilesById.delete(userMediaId);
|
|
||||||
|
|
||||||
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
|
||||||
id: userMediaId,
|
|
||||||
focused: false,
|
|
||||||
isPresenter: p.isScreenShareEnabled,
|
|
||||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
|
||||||
hasVideo: p.isCameraEnabled,
|
|
||||||
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,
|
||||||
finalizeValue((ts) => {
|
isSpeaker: false,
|
||||||
for (const t of ts) t.data.destroy();
|
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) => {
|
||||||
|
for (const t of ts) t.data.destroy();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user