Merge branch 'new-call-layouts' into rest-of-the-layouts

This commit is contained in:
Robin
2024-07-18 11:21:56 -04:00
6 changed files with 112 additions and 31 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

@@ -96,6 +96,7 @@ export interface GridArrangement {
const tileMinHeight = 130; const tileMinHeight = 130;
const tileMaxAspectRatio = 17 / 9; const tileMaxAspectRatio = 17 / 9;
const tileMinAspectRatio = 4 / 3; const tileMinAspectRatio = 4 / 3;
const tileMobileMinAspectRatio = 2 / 3;
/** /**
* Determine the ideal arrangement of tiles into a grid of a particular size. * Determine the ideal arrangement of tiles into a grid of a particular size.
@@ -140,12 +141,18 @@ export function arrangeTiles(
tileHeight = (minHeight - (rows - 1) * gap) / rows; tileHeight = (minHeight - (rows - 1) * gap) / rows;
} }
if (tileHeight < tileMinHeight) tileHeight = tileMinHeight; if (tileHeight < tileMinHeight) tileHeight = tileMinHeight;
// Impose a minimum and maximum aspect ratio on the tiles // Impose a minimum and maximum aspect ratio on the tiles
const tileAspectRatio = tileWidth / tileHeight; const tileAspectRatio = tileWidth / tileHeight;
// We enforce a different min aspect ratio in 1:1s on mobile
const minAspectRatio =
tileCount === 1 && width < 600
? tileMobileMinAspectRatio
: tileMinAspectRatio;
if (tileAspectRatio > tileMaxAspectRatio) if (tileAspectRatio > tileMaxAspectRatio)
tileWidth = tileHeight * tileMaxAspectRatio; tileWidth = tileHeight * tileMaxAspectRatio;
else if (tileAspectRatio < tileMinAspectRatio) else if (tileAspectRatio < minAspectRatio)
tileHeight = tileWidth / tileMinAspectRatio; tileHeight = tileWidth / minAspectRatio;
// TODO: We might now be hitting the minimum height or width limit again // TODO: We might now be hitting the minimum height or width limit again
return { tileWidth, tileHeight, gap, columns }; return { tileWidth, tileHeight, gap, columns };

View File

@@ -26,11 +26,18 @@ limitations under the License.
.local { .local {
position: absolute; position: absolute;
inline-size: 180px; inline-size: 135px;
block-size: 135px; block-size: 160px;
inset: var(--cpd-space-4x); inset: var(--cpd-space-4x);
} }
@media (min-width: 600px) {
.local {
inline-size: 170px;
block-size: 110px;
}
}
.spotlight { .spotlight {
position: absolute; position: absolute;
inline-size: 404px; inline-size: 404px;

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 { accumulate, finalizeValue } from "../observable-utils"; import { accumulate, 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[];
@@ -129,13 +137,37 @@ export type WindowMode = "normal" | "narrow" | "flat" | "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,
} }
@@ -308,9 +340,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]) {
@@ -321,19 +357,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)(),
@@ -344,7 +385,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();
}), }),
@@ -352,7 +393,9 @@ 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 localUserMedia: Observable<LocalUserMediaViewModel> = private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
@@ -362,39 +405,43 @@ export class CallViewModel extends ViewModel {
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<UserMediaViewModel> = private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
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>( scan<(readonly [UserMedia, boolean])[], UserMedia, 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,
), ),
distinctUntilChanged(), distinctUntilChanged(),
@@ -404,8 +451,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,
@@ -572,7 +619,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,