Build the layout media from one snapshot of its inputs

This commit is contained in:
Johannes Marbach
2026-09-28 17:34:22 +02:00
parent 5a8714805c
commit 33cfd2d8ad
+157 -145
View File
@@ -30,7 +30,6 @@ import {
scan, scan,
startWith, startWith,
Subject, Subject,
switchAll,
switchMap, switchMap,
switchScan, switchScan,
take, take,
@@ -148,6 +147,7 @@ import {
import { Publisher } from "./localMember/Publisher.ts"; import { Publisher } from "./localMember/Publisher.ts";
import { type Connection } from "./remoteMembers/Connection.ts"; import { type Connection } from "./remoteMembers/Connection.ts";
import { import {
type LayoutMode,
type LayoutSwitchViewModel, type LayoutSwitchViewModel,
createLayoutSwitchViewModel, createLayoutSwitchViewModel,
} from "../LayoutSwitchViewModel.ts"; } from "../LayoutSwitchViewModel.ts";
@@ -1092,52 +1092,7 @@ export function createCallViewModel$(
hasRemoteScreenShares$, hasRemoteScreenShares$,
); );
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest( const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
[grid$, spotlight$],
(grid, spotlight) => ({
type: "grid",
edgeToEdge: false,
spotlight: spotlight.some((vm) => vm.type === "screen share")
? spotlight
: undefined,
grid,
}),
);
const spotlightLandscapeLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightLandscapeLayoutMedia> =>
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
edgeToEdge,
spotlight,
grid,
}));
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait",
edgeToEdge: false,
spotlight,
grid,
}));
const spotlightExpandedLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightExpandedLayoutMedia> =>
spotlightAndPip$.pipe(
map(({ spotlight, pip }) => ({
type: "spotlight-expanded" as const,
edgeToEdge,
spotlight,
pip,
})),
);
const oneOnOneLayoutMedia$: Behavior<{
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
} | null> = scope.behavior(
combineLatest([userMedia$, screenShares$]).pipe( combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => { switchMap(([userMedia, screenShares]) => {
// One-on-one layout only supports 2 user media, no screen shares // One-on-one layout only supports 2 user media, no screen shares
@@ -1175,47 +1130,12 @@ export function createCallViewModel$(
), ),
); );
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> = const localVideoEnabled$ = scope.behavior<boolean>(
oneOnOneLayoutMedia$.pipe( oneOnOneLayoutMedia$.pipe(
map((media) => { switchMap((media) =>
if (media === null) return null; media === null ? of(false) : media.local.videoEnabled$,
return media.remote.type === "ringing" ),
? { ),
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}),
);
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) => {
if (media === null) return of(null);
return media.local.videoEnabled$.pipe(
map((videoEnabled) => ({
type: "one-on-one-mobile" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: videoEnabled ? media.local : undefined,
})),
);
}),
);
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
map((spotlight) => ({
type: "pip",
edgeToEdge: platform !== "desktop",
spotlight,
})),
); );
spotlight$ spotlight$
@@ -1250,66 +1170,28 @@ export function createCallViewModel$(
* The media to be used to produce a layout. * The media to be used to produce a layout.
*/ */
const layoutMedia$ = scope.behavior<LayoutMedia>( const layoutMedia$ = scope.behavior<LayoutMedia>(
windowMode$.pipe( merge(
switchMap((windowMode) => { windowMode$,
switch (windowMode) { layoutSwitchVm.layout$,
case "normal": spotlightExpanded$,
return layoutSwitchVm.layout$.pipe( oneOnOneLayoutMedia$,
switchMap((layout) => { localVideoEnabled$,
switch (layout) { spotlightAndPip$,
case "grid": grid$,
return oneOnOneDesktopLayoutMedia$.pipe( ).pipe(
switchMap((oneOnOne) => map(() =>
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), computeLayoutMedia({
), windowMode: windowMode$.value,
); layoutMode: layoutSwitchVm.layout$.value,
case "spotlight": spotlightExpanded: spotlightExpanded$.value,
return spotlightExpanded$.pipe( oneOnOne: oneOnOneLayoutMedia$.value,
switchMap((expanded) => localVideoEnabled: localVideoEnabled$.value,
expanded ...spotlightAndPip$.value,
? spotlightExpandedLayoutMedia$(false) grid: grid$.value,
: spotlightLandscapeLayoutMedia$(false), desktop: platform === "desktop",
),
);
}
}), }),
);
case "narrow":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm.type === "screen share")
? spotlightPortraitLayoutMedia$
: gridLayoutMedia$,
).pipe(switchAll())
: of(oneOnOne),
), ),
); distinctUntilChanged(layoutShallowEquals),
case "flat":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia$(true);
case "spotlight":
return spotlightExpandedLayoutMedia$(true);
}
}),
)
: of(oneOnOne),
),
);
case "pip":
return pipLayoutMedia$;
}
}),
), ),
); );
@@ -1891,3 +1773,133 @@ function getE2eeKeyProvider(
return keyProvider; return keyProvider;
} }
} }
interface OneOnOneMedia {
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
}
interface LayoutMediaInputs {
windowMode: WindowMode;
/** The layout chosen with the layout switch. */
layoutMode: LayoutMode;
spotlightExpanded: boolean;
/** Set when the call qualifies for a one-on-one layout. */
oneOnOne: OneOnOneMedia | null;
localVideoEnabled: boolean;
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
pip: UserMediaViewModel | undefined;
desktop: boolean;
}
/**
* Decides which layout to use and which media goes where in it.
*/
function computeLayoutMedia({
windowMode,
layoutMode,
spotlightExpanded,
oneOnOne,
localVideoEnabled,
spotlight,
grid,
pip,
desktop,
}: LayoutMediaInputs): LayoutMedia {
switch (windowMode) {
case "normal":
if (layoutMode === "grid")
return oneOnOne === null
? gridLayoutMedia(spotlight, grid)
: oneOnOneDesktopLayoutMedia(oneOnOne);
return spotlightExpanded
? spotlightExpandedLayoutMedia(false, spotlight, pip)
: spotlightLandscapeLayoutMedia(false, spotlight, grid);
case "narrow":
if (oneOnOne !== null)
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight)
? spotlightPortraitLayoutMedia(spotlight, grid)
: gridLayoutMedia(spotlight, grid);
case "flat":
if (oneOnOne !== null)
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
// Yes, grid mode actually gets you a "spotlight" layout in this window
// mode.
return layoutMode === "grid"
? spotlightLandscapeLayoutMedia(true, spotlight, grid)
: spotlightExpandedLayoutMedia(true, spotlight, pip);
case "pip":
return { type: "pip", edgeToEdge: !desktop, spotlight };
}
}
function hasScreenShare(spotlight: MediaViewModel[]): boolean {
return spotlight.some((vm) => vm.type === "screen share");
}
function gridLayoutMedia(
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): GridLayoutMedia {
return {
type: "grid",
edgeToEdge: false,
spotlight: hasScreenShare(spotlight) ? spotlight : undefined,
grid,
};
}
function oneOnOneDesktopLayoutMedia(
media: OneOnOneMedia,
): OneOnOneDesktopLayoutMedia {
return media.remote.type === "ringing"
? {
type: "one-on-one-desktop",
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-desktop",
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}
function spotlightExpandedLayoutMedia(
edgeToEdge: boolean,
spotlight: MediaViewModel[],
pip: UserMediaViewModel | undefined,
): SpotlightExpandedLayoutMedia {
return { type: "spotlight-expanded", edgeToEdge, spotlight, pip };
}
function spotlightLandscapeLayoutMedia(
edgeToEdge: boolean,
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): SpotlightLandscapeLayoutMedia {
return { type: "spotlight-landscape", edgeToEdge, spotlight, grid };
}
function oneOnOneMobileLayoutMedia(
media: OneOnOneMedia,
localVideoEnabled: boolean,
): OneOnOneMobileLayoutMedia {
return {
type: "one-on-one-mobile",
edgeToEdge: true,
spotlight: media.remote,
pip: localVideoEnabled ? media.local : undefined,
};
}
function spotlightPortraitLayoutMedia(
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): SpotlightPortraitLayoutMedia {
return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid };
}