Merge branch 'new-call-layouts' into observable-hooks

This commit is contained in:
Robin
2024-07-17 15:55:50 -04:00
4 changed files with 84 additions and 28 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

@@ -68,6 +68,7 @@ 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
@@ -130,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,
} }
@@ -311,9 +336,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],
) => {
let allGhosts = true; let allGhosts = true;
const newItems = new Map( const newItems = new Map(
@@ -330,34 +359,36 @@ export class CallViewModel extends ViewModel {
); );
} }
const userMediaId = p.identity; // Create as many tiles for this participant as called for by
yield [ // the duplicateTiles option
userMediaId, for (let i = 0; i < 1 + duplicateTiles; i++) {
prevItems.get(userMediaId) ?? const userMediaId = `${p.identity}:${i}`;
new UserMedia(userMediaId, member, p, this.encrypted),
];
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)(),
); );
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
// If every item is a ghost, that probably means we're still connecting // If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet // and shouldn't bother showing anything yet
return allGhosts ? new Map() : newItems; return allGhosts ? new Map() : newItems;
}, },
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();
}), }),
@@ -365,35 +396,41 @@ 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),
),
); );
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 is still speaking, stick with them rather // If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else // than switching eagerly to someone else
ms.find(([m, s]) => m === prev && s)?.[0] ?? mediaItems.find(([m, s]) => m === prev && s)?.[0] ??
// Otherwise, select anyone who is speaking // Otherwise, select anyone who is speaking
ms.find(([, s]) => s)?.[0] ?? mediaItems.find(([, s]) => 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,
), ),
@@ -402,8 +439,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,