diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index a6b509fab..f95751094 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css"; import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; -import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; +import { constant } from "../state/Behavior"; +import { type LayoutMode } from "../state/LayoutSwitchViewModel"; + // consts for tests const reactionIdentifier = "@user:example.com:DEVICE"; const reactionData = { @@ -32,6 +34,7 @@ const mediaDevices = new MediaDevices(globalScope); /** * A wrapper component that is used for: * - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm) + * - constructing the layout switch view model * - Add additional react context * The paraeters are all params from the FooterSnapshot, * the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter. @@ -40,11 +43,18 @@ const mediaDevices = new MediaDevices(globalScope); */ function CallFooterStoryWrapper({ children, + layout, + setLayout, ...vmSnapshot -}: FooterSnapshot & { +}: Omit & { children?: false | JSX.Element | JSX.Element[] | undefined; + layout: LayoutMode | null; + setLayout: (value: LayoutMode) => void; }): ReactNode { - const vm = useStaticViewModel(vmSnapshot); + const vm = useStaticViewModel({ + ...vmSnapshot, + layoutSwitchVm: layout && { layout$: constant(layout), setLayout }, + }); return (
@@ -62,28 +72,50 @@ function CallFooterStoryWrapper({ ); } -const meta = { - component: CallFooterStoryWrapper, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - const fnArgType = { control: { type: "select" as const }, options: ["MockedCallback", "undefined"], mapping: { MockedCallback: fn(), undefined: undefined }, }; +const meta = { + component: CallFooterStoryWrapper, + argTypes: { + layout: { + control: "radio", + options: ["grid", "spotlight"] satisfies LayoutMode[], + }, + audioOutputSwitcher: { + control: "select", + options: ["NoOutputCallback", "speaker", "earpiece"], + table: { defaultValue: { summary: "NoOutputCallback" } }, + mapping: { + NoOutputCallback: undefined, + // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. + speaker: { targetOutput: "earpiece", switch: fn() }, + earpiece: { targetOutput: "speaker", switch: fn() }, + }, + }, + toggleScreenSharing: fnArgType, + openSettings: fnArgType, + toggleAudio: fnArgType, + toggleVideo: fnArgType, + hangup: fnArgType, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + export const Default: Story = { args: { showLogo: false, - layoutMode: "grid", + layout: "grid", + setLayout: fn(), audioEnabled: true, audioBusy: false, videoEnabled: true, videoBusy: false, - setLayoutMode: fn(), openSettings: fn(), toggleAudio: fn(), toggleVideo: fn(), @@ -111,29 +143,6 @@ export const Default: Story = { parameters: { layout: "fullscreen", }, - argTypes: { - layoutMode: { - control: "radio", - options: ["grid", "spotlight"] satisfies GridMode[], - }, - audioOutputSwitcher: { - control: "select", - options: ["NoOutputCallback", "speaker", "earpiece"], - table: { defaultValue: { summary: "NoOutputCallback" } }, - mapping: { - NoOutputCallback: undefined, - // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. - speaker: { targetOutput: "earpiece", switch: fn() }, - earpiece: { targetOutput: "speaker", switch: fn() }, - }, - }, - toggleScreenSharing: fnArgType, - setLayoutMode: fnArgType, - openSettings: fnArgType, - toggleAudio: fnArgType, - toggleVideo: fnArgType, - hangup: fnArgType, - }, }; export const WithAudioAndVideoOptions: Story = { @@ -194,7 +203,7 @@ export const AudioVideoEnabled: Story = { const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" }); await userEvent.click(spotlightRadio); - await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight"); + await expect(args.setLayout).toHaveBeenCalledWith("spotlight"); const micButtonMute = canvas.getByRole("switch", { name: "Mute microphone", @@ -225,14 +234,14 @@ export const SpotlightMode: Story = { ...Default, args: { ...Default.args, - layoutMode: "spotlight", + layout: "spotlight", }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const spotlightRadio = canvas.getByRole("radio", { name: "Grid" }); await userEvent.click(spotlightRadio); - await expect(args.setLayoutMode).toHaveBeenCalledWith("grid"); + await expect(args.setLayout).toHaveBeenCalledWith("grid"); }, }; @@ -264,7 +273,7 @@ export const Pip: Story = { args: { ...Default.args, buttonSize: "md", - layoutMode: undefined, + layout: null, }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); @@ -348,7 +357,7 @@ export const Lobby: Story = { ...Default.args, showLogo: false, openSettings: undefined, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, parameters: { @@ -362,7 +371,7 @@ export const LobbyMobile: Story = { ...Default.args, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, globals: { @@ -379,7 +388,7 @@ export const LobbyRecentButton: Story = { ...Default.args, children: Back To Recents, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, parameters: { @@ -393,7 +402,7 @@ export const LobbyRecentButtonMobile: Story = { ...Default.args, children: Back To Recents, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, globals: { diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index f2aa5168b..f5c54ee38 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,12 +7,6 @@ Please see LICENSE in the repository root for full details. import { type FC, type JSX, type Ref, useMemo } from "react"; import classNames from "classnames"; -import { - SpotlightViewIcon, - GridIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Switch } from "@vector-im/compound-web"; -import { t } from "i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -28,13 +22,14 @@ import { type ReactionData, } from "../button"; import styles from "./CallFooter.module.css"; -import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; import { type ViewModel } from "../state/ViewModel"; import { useBehavior } from "../useBehavior"; +import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; +import { LayoutSwitch } from "../room/LayoutSwitch"; export interface AudioOutputSwitcher { targetOutput: string; @@ -61,8 +56,6 @@ export interface FooterActions { /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; toggleBlur: (() => void) | undefined; - /** Also controls if the layout button is visible */ - setLayoutMode: ((mode: GridMode) => void) | undefined; toggleScreenSharing: (() => void) | undefined; /** Also controls if the settings button is visible */ openSettings: (() => void) | undefined; @@ -87,7 +80,8 @@ export interface FooterState { buttonSize: "md" | "lg"; showLogo: boolean; - layoutMode: GridMode | undefined; + /** Also controls if the layout switch is visible */ + layoutSwitchVm: LayoutSwitchViewModel | null; sharingScreen: boolean; @@ -126,8 +120,7 @@ export const CallFooter: FC = ({ const asOverlay = useBehavior(vm.asOverlay$); const showFooter = useBehavior(vm.showFooter$); const hideControls = useBehavior(vm.hideControls$); - const layoutMode = useBehavior(vm.layoutMode$); - const setLayoutMode = useBehavior(vm.setLayoutMode$); + const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$); const openSettings = useBehavior(vm.openSettings$); const audioEnabled = useBehavior(vm.audioEnabled$); const audioBusy = useBehavior(vm.audioBusy$); @@ -317,20 +310,8 @@ export const CallFooter: FC = ({ {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls &&
{buttons}
} - {!hideControls && setLayoutMode && layoutMode && ( - - name="layoutMode" - aria-label={t("layout_switch_label")} - leftLabel={t("layout_spotlight_label")} - leftValue="spotlight" - leftIcon={SpotlightViewIcon} - rightLabel={t("layout_grid_label")} - rightValue="grid" - rightIcon={GridIcon} - className={styles.layout} - value={layoutMode} - onChange={setLayoutMode} - /> + {!hideControls && layoutSwitchVm && ( + )} ); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 2f64cc83b..a374a0258 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -182,14 +182,7 @@ export function createCallFooterViewModel( showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), - layoutMode$: callModel.gridMode$, - setLayoutMode$: scope.behavior( - isPip$.pipe( - map((isPip) => - !isPip && showControls ? callModel.setGridMode : undefined, - ), - ), - ), + layoutSwitchVm$: callModel.layoutSwitchVm$, sharingScreen$: callModel.sharingScreen$, toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), @@ -247,20 +240,18 @@ export function createLobbyFooterViewModel( hideControls: false, asOverlay: false, buttonSize: "lg", - showLayoutSwitcher: false, openSettings, hangup, debugTileLayout: false, showFooter: true, toggleAudio: undefined, toggleVideo: undefined, - setLayoutMode: undefined, toggleScreenSharing: undefined, audioEnabled: undefined, audioBusy: false, videoEnabled: undefined, videoBusy: false, - layoutMode: undefined, + layoutSwitchVm: null, sharingScreen: false, audioOutputSwitcher: undefined, reactionIdentifier: undefined, diff --git a/src/room/LayoutSwitch.tsx b/src/room/LayoutSwitch.tsx new file mode 100644 index 000000000..80995a35c --- /dev/null +++ b/src/room/LayoutSwitch.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type FC } from "react"; +import { + SpotlightViewIcon, + GridIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; +import { useBehavior } from "../useBehavior"; +import { useTranslation } from "react-i18next"; +import { Switch } from "@vector-im/compound-web"; + +interface Props { + vm: LayoutSwitchViewModel; + className?: string; +} + +export const LayoutSwitch: FC = ({ vm, className }) => { + const { t } = useTranslation(); + const layout = useBehavior(vm.layout$); + + return ( + + name="layout" + aria-label={t("layout_switch_label")} + leftLabel={t("layout_spotlight_label")} + leftValue="spotlight" + leftIcon={SpotlightViewIcon} + rightLabel={t("layout_grid_label")} + rightValue="grid" + rightIcon={GridIcon} + className={className} + value={layout} + onChange={vm.setLayout} + /> + ); +}; diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 5499623d1..8a805668d 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -433,7 +433,7 @@ exports[`InCallView > rendering > renders 1`] = ` > @@ -454,7 +454,7 @@ exports[`InCallView > rendering > renders 1`] = ` diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 73b657cb0..0f77a2db9 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -327,8 +327,8 @@ describe.each([ }, (vm) => { schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), - g: () => vm.setGridMode("grid"), + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), + g: () => vm.layoutSwitchVm$.value!.setLayout("grid"), }); expectObservable(summarizeLayout$(vm.layout$)).toBe( @@ -815,7 +815,9 @@ describe.each([ ]), }, (vm) => { - schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); + schedule(modeInputMarbles, { + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), + }); expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, @@ -1021,7 +1023,7 @@ describe.each([ }, (vm) => { schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), }); schedule(expandInputMarbles, { a: () => vm.toggleSpotlightExpanded$.value!(), @@ -1091,7 +1093,7 @@ describe.each([ }, (vm) => { schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), }); schedule(expandInputMarbles, { a: () => vm.toggleSpotlightExpanded$.value!(), @@ -1131,7 +1133,7 @@ describe.each([ }, (vm) => { schedule("s", { - s: () => vm.setGridMode("spotlight"), + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), }); schedule("a", { a: () => vm.toggleSpotlightExpanded$.value!(), @@ -1168,8 +1170,8 @@ describe.each([ }, (vm) => { schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), - g: () => vm.setGridMode("grid"), + s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"), + g: () => vm.layoutSwitchVm$.value!.setLayout("grid"), }); schedule(expandInputMarbles, { a: () => vm.toggleSpotlightExpanded$.value!(), @@ -1235,7 +1237,7 @@ describe.each([ ]), }, (vm) => { - vm.setGridMode("grid"); + vm.layoutSwitchVm$.value!.setLayout("grid"); expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { @@ -1278,7 +1280,7 @@ describe.each([ }), }, (vm) => { - vm.setGridMode("grid"); + vm.layoutSwitchVm$.value!.setLayout("grid"); expectObservable(summarizeLayout$(vm.layout$)).toBe( expectedLayoutMarbles, { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 7b94e9ff2..5a066453e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -141,7 +141,10 @@ import { } from "./remoteMembers/MatrixMemberMetadata.ts"; import { Publisher } from "./localMember/Publisher.ts"; import { type Connection } from "./remoteMembers/Connection.ts"; -import { createLayoutModeSwitch } from "./LayoutSwitch.ts"; +import { + type LayoutSwitchViewModel, + createLayoutSwitchViewModel, +} from "../LayoutSwitchViewModel.ts"; import { createWrappedUserMedia, type WrappedUserMediaViewModel, @@ -201,8 +204,6 @@ const smallMobileCallThreshold = 3; // with the interface const showFooterMs = 4000; -export type GridMode = "grid" | "spotlight"; - export type WindowMode = "normal" | "narrow" | "flat" | "pip"; interface LayoutScanState { @@ -349,8 +350,7 @@ export interface CallViewModel { showNameTags$: Behavior; spotlightExpanded$: Behavior; toggleSpotlightExpanded$: Behavior<(() => void) | null>; - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; + layoutSwitchVm$: Behavior; // header/footer visibility showHeader$: Behavior; @@ -1056,7 +1056,7 @@ export function createCallViewModel$( spotlightExpandedToggle$, ); - const { setGridMode, gridMode$ } = createLayoutModeSwitch( + const layoutSwitchVm = createLayoutSwitchViewModel( scope, windowMode$, hasRemoteScreenShares$, @@ -1223,9 +1223,9 @@ export function createCallViewModel$( switchMap((windowMode) => { switch (windowMode) { case "normal": - return gridMode$.pipe( - switchMap((gridMode) => { - switch (gridMode) { + return layoutSwitchVm.layout$.pipe( + switchMap((layout) => { + switch (layout) { case "grid": return oneOnOneDesktopLayoutMedia$.pipe( switchMap((oneOnOne) => @@ -1260,9 +1260,9 @@ export function createCallViewModel$( return oneOnOneMobileLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null - ? gridMode$.pipe( - switchMap((gridMode) => { - switch (gridMode) { + ? layoutSwitchVm.layout$.pipe( + switchMap((layout) => { + switch (layout) { case "grid": // Yes, grid mode actually gets you a "spotlight" layout in // this window mode. @@ -1775,8 +1775,7 @@ export function createCallViewModel$( spotlightExpanded$: spotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$, - gridMode$: gridMode$, - setGridMode: setGridMode, + layoutSwitchVm$: constant(layoutSwitchVm), layout$: layout$, localMatrixLivekitMember$, remoteMatrixLivekitMembers$: scope.behavior( diff --git a/src/state/CallViewModel/LayoutSwitch.test.ts b/src/state/LayoutSwitchViewModel.test.ts similarity index 65% rename from src/state/CallViewModel/LayoutSwitch.test.ts rename to src/state/LayoutSwitchViewModel.test.ts index ea533ec7e..d230408c3 100644 --- a/src/state/CallViewModel/LayoutSwitch.test.ts +++ b/src/state/LayoutSwitchViewModel.test.ts @@ -7,31 +7,31 @@ Please see LICENSE in the repository root for full details. import { describe, test } from "vitest"; -import { createLayoutModeSwitch } from "./LayoutSwitch"; -import { testScope, withTestScheduler } from "../../utils/test"; +import { createLayoutSwitchViewModel } from "./LayoutSwitchViewModel"; +import { testScope, withTestScheduler } from "../utils/test"; function testLayoutSwitch({ windowMode = "n", hasScreenShares = "n", userSelection = "", - expectedGridMode, + expectedLayout, }: { windowMode?: string; hasScreenShares?: string; userSelection?: string; - expectedGridMode: string; + expectedLayout: string; }): void { withTestScheduler(({ behavior, schedule, expectObservable }) => { - const { gridMode$, setGridMode } = createLayoutModeSwitch( + const { layout$, setLayout } = createLayoutSwitchViewModel( testScope(), behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }), behavior(hasScreenShares, { y: true, n: false }), ); schedule(userSelection, { - g: () => setGridMode("grid"), - s: () => setGridMode("spotlight"), + g: () => setLayout("grid"), + s: () => setLayout("spotlight"), }); - expectObservable(gridMode$).toBe(expectedGridMode, { + expectObservable(layout$).toBe(expectedLayout, { g: "grid", s: "spotlight", }); @@ -41,86 +41,86 @@ function testLayoutSwitch({ describe("default mode", () => { test("uses grid layout in normal window", () => testLayoutSwitch({ - windowMode: " n", - expectedGridMode: "g", + windowMode: " n", + expectedLayout: "g", })); test("uses grid layout in flat window", () => testLayoutSwitch({ - windowMode: " f", - expectedGridMode: "g", + windowMode: " f", + expectedLayout: "g", })); }); test("allows switching modes manually", () => testLayoutSwitch({ - userSelection: " --sgs", - expectedGridMode: "g-sgs", + userSelection: " --sgs", + expectedLayout: "g-sgs", })); test("switches to spotlight mode when there is a remote screen share", () => testLayoutSwitch({ - hasScreenShares: " n--y", - expectedGridMode: "g--s", + hasScreenShares: "n--y", + expectedLayout: " g--s", })); test("can manually switch to grid when there is a screenshare", () => testLayoutSwitch({ - hasScreenShares: " n-y", - userSelection: " ---g", - expectedGridMode: "g-sg", + hasScreenShares: "n-y", + userSelection: " ---g", + expectedLayout: " g-sg", })); test("auto-switches after manually selecting grid", () => testLayoutSwitch({ // Two screenshares will happen in sequence. There is a screen share that // forces spotlight, then the user manually switches back to grid. - hasScreenShares: " n-y-ny", - userSelection: " ---g", - expectedGridMode: "g-sg-s", + hasScreenShares: "n-y-ny", + userSelection: " ---g", + expectedLayout: " g-sg-s", // If we did want to respect manual selection, the expectation would be: g-sg })); test("switches back to grid mode when the remote screen share ends", () => testLayoutSwitch({ - hasScreenShares: " n--y--n", - expectedGridMode: "g--s--g", + hasScreenShares: "n--y--n", + expectedLayout: " g--s--g", })); test("auto-switches to spotlight again after first screen share ends", () => testLayoutSwitch({ - hasScreenShares: " nyny", - expectedGridMode: "gsgs", + hasScreenShares: "nyny", + expectedLayout: " gsgs", })); test("switches manually to grid after screen share while manually in spotlight", () => testLayoutSwitch({ // Initially, no one is sharing. Then the user manually switches to spotlight. // After a screen share starts, the user manually switches to grid. - hasScreenShares: " n-y", - userSelection: " -s-g", - expectedGridMode: "gs-g", + hasScreenShares: "n-y", + userSelection: " -s-g", + expectedLayout: " gs-g", })); test("allows switching modes manually when in flat window mode", () => testLayoutSwitch({ // Window becomes flat, then user switches to spotlight and back. // Finally the window returns to a normal shape. - windowMode: " nf--n", - userSelection: " --sg", - expectedGridMode: "g-sg", + windowMode: " nf--n", + userSelection: " --sg", + expectedLayout: "g-sg", })); test("switches to grid when in flat window mode even when there are screen shares", () => testLayoutSwitch({ - windowMode: " nf", - hasScreenShares: " y", - expectedGridMode: "sg", + windowMode: " nf", + hasScreenShares: "y", + expectedLayout: " sg", })); test("ignores screen share until window mode returns to normal", () => testLayoutSwitch({ - windowMode: " f-n", - hasScreenShares: " ny-n", - expectedGridMode: "g-sg", + windowMode: " f-n", + hasScreenShares: "ny-n", + expectedLayout: " g-sg", })); diff --git a/src/state/CallViewModel/LayoutSwitch.ts b/src/state/LayoutSwitchViewModel.ts similarity index 52% rename from src/state/CallViewModel/LayoutSwitch.ts rename to src/state/LayoutSwitchViewModel.ts index 6ad200f15..6e61abaaa 100644 --- a/src/state/CallViewModel/LayoutSwitch.ts +++ b/src/state/LayoutSwitchViewModel.ts @@ -11,15 +11,25 @@ import { Subject, startWith, skipWhile, - switchMap, + switchAll, } from "rxjs"; -import { type GridMode, type WindowMode } from "./CallViewModel.ts"; -import { constant, type Behavior } from "../Behavior.ts"; -import { type ObservableScope } from "../ObservableScope.ts"; +import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; +import { constant, type Behavior } from "./Behavior.ts"; +import { type ObservableScope } from "./ObservableScope.ts"; + +export type LayoutMode = "spotlight" | "grid"; + +export interface LayoutSwitchViewModel { + /** + * The layout mode of the call's media tiles. + */ + layout$: Behavior; + setLayout: (value: LayoutMode) => void; +} /** - * Creates a layout mode switch that allows switching between grid and spotlight modes. + * Creates a layout mode switch that allows switching between grid and spotlight layouts. * The actual layout mode might switch automatically to spotlight if there is a * remote screen share active or if the window mode is flat. * @@ -27,31 +37,28 @@ import { type ObservableScope } from "../ObservableScope.ts"; * @param windowMode$ - The current window mode. * @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active. */ -export function createLayoutModeSwitch( +export function createLayoutSwitchViewModel( scope: ObservableScope, windowMode$: Behavior, hasRemoteScreenShares$: Behavior, -): { - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; -} { - const userSelection$ = new Subject(); - // Callback to set the grid mode desired by the user. - // Notice that this is only a preference, the actual grid mode can be overridden +): LayoutSwitchViewModel { + const userSelection$ = new Subject(); + // Callback to set the layout desired by the user. + // Notice that this is only a preference, the actual layout can be overridden // if there is a remote screen share active. - const setGridMode = (value: GridMode): void => userSelection$.next(value); + const setLayout = (value: LayoutMode): void => userSelection$.next(value); /** - * The natural grid mode - the mode that the grid would prefer to be in, + * The natural layout - the layout that the interface would prefer to be in, * not accounting for the user's manual selections. */ - const naturalGridMode$ = scope.behavior( + const naturalLayout$ = scope.behavior( combineLatest( [hasRemoteScreenShares$, windowMode$], (hasRemoteScreenShares, windowMode) => { // When the window is flat (as with a phone in landscape orientation), - // grid mode is preferable as there's usually more than enough - // horizontal space to fit in some grid tiles on the side. + // grid is preferable as there's usually more than enough horizontal + // space to fit in some grid tiles on the side. if (windowMode === "flat") return "grid"; // When there are screen shares, spotlight is a better experience. We // want them to be big and readable. @@ -61,35 +68,32 @@ export function createLayoutModeSwitch( ); /** - * The layout mode of the media tile grid. + * The layout mode of the call's media tiles. */ - const gridMode$ = scope.behavior( + const layout$ = scope.behavior( // Whenever the user makes a selection, we enter a new mode of behavior: userSelection$.pipe( map((selection) => { if (selection === "grid") - // The user has selected grid mode. Start by respecting their choice, - // but then follow the natural mode again as soon as it matches. - return naturalGridMode$.pipe( + // The user has selected grid. Start by respecting their choice, but + // then follow the natural mode again as soon as it matches. + return naturalLayout$.pipe( skipWhile((naturalMode) => naturalMode !== selection), startWith(selection), ); - // The user has selected spotlight mode. If this matches the natural - // mode, then follow the natural mode going forward. - return selection === naturalGridMode$.value - ? naturalGridMode$ + // The user has selected spotlight. If this matches the natural layout, + // then follow the natural layout going forward. + return selection === naturalLayout$.value + ? naturalLayout$ : constant(selection); }), - // Initially the mode of behavior is to just follow the natural grid mode. - startWith(naturalGridMode$), + // Initially the mode of behavior is to just follow the natural layout. + startWith(naturalLayout$), // Switch between each mode of behavior. - switchMap((mode$) => mode$), + switchAll(), ), ); - return { - gridMode$, - setGridMode, - }; + return { layout$, setLayout }; }