Merge branch 'new-call-layouts' into rest-of-the-layouts
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user