Merge branch 'new-call-layouts' into one-on-one-layout

This commit is contained in:
Robin
2024-07-18 10:28:17 -04:00
4 changed files with 94 additions and 27 deletions

View File

@@ -132,6 +132,7 @@
"developer_settings_label": "Developer Settings", "developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.", "developer_settings_label_description": "Expose developer settings in the settings window.",
"developer_tab_title": "Developer", "developer_tab_title": "Developer",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"feedback_tab_description_label": "Your feedback", "feedback_tab_description_label": "Your feedback",
"feedback_tab_h4": "Submit feedback", "feedback_tab_h4": "Submit feedback",

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ChangeEvent, FC, Key, ReactNode } from "react"; import { ChangeEvent, FC, Key, ReactNode, useCallback } from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk"; import { MatrixClient } from "matrix-js-sdk";
@@ -44,6 +44,7 @@ import {
useSetting, useSetting,
optInAnalytics as optInAnalyticsSetting, optInAnalytics as optInAnalyticsSetting,
developerSettingsTab as developerSettingsTabSetting, developerSettingsTab as developerSettingsTabSetting,
duplicateTiles as duplicateTilesSetting,
} from "./settings"; } from "./settings";
import { isFirefox } from "../Platform"; import { isFirefox } from "../Platform";
@@ -80,6 +81,7 @@ export const SettingsModal: FC<Props> = ({
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting( const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
developerSettingsTabSetting, developerSettingsTabSetting,
); );
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
// Generate a `SelectInput` with a list of devices for a given device kind. // Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = ( const generateDeviceSelection = (
@@ -244,6 +246,20 @@ export const SettingsModal: FC<Props> = ({
})} })}
</Body> </Body>
</FieldRow> </FieldRow>
<FieldRow>
<InputField
id="duplicateTiles"
type="number"
label={t("settings.duplicate_tiles_label")}
value={duplicateTiles.toString()}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setDuplicateTiles(event.target.valueAsNumber);
},
[setDuplicateTiles],
)}
/>
</FieldRow>
</TabItem> </TabItem>
); );

View File

@@ -76,6 +76,8 @@ export const developerSettingsTab = new Setting(
false, false,
); );
export const duplicateTiles = new Setting("duplicate-tiles", 0);
export const audioInput = new Setting<string | undefined>( export const audioInput = new Setting<string | undefined>(
"audio-input", "audio-input",
undefined, undefined,

View File

@@ -69,11 +69,19 @@ import {
} from "./MediaViewModel"; } from "./MediaViewModel";
import { finalizeValue } from "../observable-utils"; import { finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
// list again // list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
// This is the number of participants that we think constitutes a "large" grid.
// The hypothesis is that, after this many participants there's enough cognitive
// load that it makes sense to show the speaker in an easy-to-locate spotlight
// tile. We might change this to a scroll-based condition or do something else
// entirely with the spotlight tile, if we workshop this further.
const largeGridThreshold = 20;
export interface GridLayout { export interface GridLayout {
type: "grid"; type: "grid";
spotlight?: MediaViewModel[]; spotlight?: MediaViewModel[];
@@ -123,13 +131,37 @@ export type WindowMode = "normal" | "full screen" | "pip";
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
*/ */
enum SortingBin { enum SortingBin {
/**
* Yourself, when the "always show self" option is on.
*/
SelfAlwaysShown, SelfAlwaysShown,
/**
* Participants that are sharing their screen.
*/
Presenters, Presenters,
/**
* Participants that have been speaking recently.
*/
Speakers, Speakers,
/**
* Participants with both video and audio.
*/
VideoAndAudio, VideoAndAudio,
/**
* Participants with video but no audio.
*/
Video, Video,
/**
* Participants with audio but no video.
*/
Audio, Audio,
/**
* Participants not sharing any media.
*/
NoMedia, NoMedia,
/**
* Yourself, when the "always show self" option is off.
*/
SelfNotAlwaysShown, SelfNotAlwaysShown,
} }
@@ -305,9 +337,13 @@ export class CallViewModel extends ViewModel {
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
]).pipe( ]).pipe(
scan( scan(
(prevItems, [remoteParticipants, { participant: localParticipant }]) => { (
prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
) => {
const newItems = new Map( const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { for (const p of [localParticipant, ...remoteParticipants]) {
@@ -318,19 +354,24 @@ export class CallViewModel extends ViewModel {
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, `Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
); );
yield [ // Create as many tiles for this participant as called for by
userMediaId, // the duplicateTiles option
prevItems.get(userMediaId) ?? for (let i = 0; i < 1 + duplicateTiles; i++) {
new UserMedia(userMediaId, member, p, this.encrypted), const userMediaId = `${p.identity}:${i}`;
];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [ yield [
screenShareId, userMediaId,
prevItems.get(screenShareId) ?? prevItems.get(userMediaId) ??
new ScreenShare(screenShareId, member, p, this.encrypted), new UserMedia(userMediaId, member, p, this.encrypted),
]; ];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(screenShareId, member, p, this.encrypted),
];
}
} }
} }
}.bind(this)(), }.bind(this)(),
@@ -341,7 +382,7 @@ export class CallViewModel extends ViewModel {
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
), ),
map((ms) => [...ms.values()]), map((mediaItems) => [...mediaItems.values()]),
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
@@ -349,44 +390,50 @@ export class CallViewModel extends ViewModel {
); );
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe( private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)), map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
); );
private readonly screenShares: Observable<ScreenShare[]> = private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe( this.mediaItems.pipe(
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)), map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
),
shareReplay(1), shareReplay(1),
); );
private readonly hasRemoteScreenShares: Observable<boolean> = private readonly hasRemoteScreenShares: Observable<boolean> =
this.screenShares.pipe( this.screenShares.pipe(
map((ms) => ms.find((m) => !m.vm.local) !== undefined), map((ms) => ms.some((m) => !m.vm.local)),
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly spotlightSpeaker: Observable<UserMedia | null> = private readonly spotlightSpeaker: Observable<UserMedia | null> =
this.userMedia.pipe( this.userMedia.pipe(
switchMap((ms) => switchMap((mediaItems) =>
ms.length === 0 mediaItems.length === 0
? of([]) ? of([])
: combineLatest( : combineLatest(
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))), mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)),
),
), ),
), ),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>( scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, ms) => (prev, mediaItems) =>
// Decide who to spotlight: // Decide who to spotlight:
// If the previous speaker (not the local user) is still speaking, // If the previous speaker (not the local user) is still speaking,
// stick with them rather than switching eagerly to someone else // stick with them rather than switching eagerly to someone else
(prev === null || prev.vm.local (prev === null || prev.vm.local
? null ? null
: ms.find(([m, s]) => m === prev && s)?.[0]) ?? : mediaItems.find(([m, s]) => m === prev && s)?.[0]) ??
// Otherwise, select any remote user who is speaking // Otherwise, select any remote user who is speaking
ms.find(([m, s]) => !m.vm.local && s)?.[0] ?? mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking // Otherwise, stick with the person who was last speaking
prev ?? prev ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ?? mediaItems.find(([m]) => m.vm.local)?.[0] ??
null, null,
null, null,
), ),
@@ -396,8 +443,8 @@ export class CallViewModel extends ViewModel {
); );
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe( private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((ms) => { switchMap((mediaItems) => {
const bins = ms.map((m) => const bins = mediaItems.map((m) =>
combineLatest( combineLatest(
[ [
m.speaker, m.speaker,
@@ -500,7 +547,8 @@ export class CallViewModel extends ViewModel {
: { : {
type: "grid", type: "grid",
spotlight: spotlight:
screenShares.length > 0 || grid.length > 20 screenShares.length > 0 ||
grid.length > largeGridThreshold
? spotlight ? spotlight
: undefined, : undefined,
grid, grid,