diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index b373e9ba..aec239b2 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -15,7 +15,12 @@ import { type MockedFunction, vi, } from "vitest"; -import { act, render, type RenderResult } from "@testing-library/react"; +import { + render, + type RenderResult, + getByRole, + screen, +} from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; @@ -186,6 +191,7 @@ describe("InCallView", () => { expect(container).toMatchSnapshot(); }); }); + describe("settings button with AppBar header", () => { beforeEach(() => { // getUrlParams() reads window.location directly rather than from the @@ -198,67 +204,33 @@ describe("InCallView", () => { window.history.pushState({}, "", "/"); }); - it("mobile landscape, is accessible when showHeader is false", () => { - // windowSize with height <= 600 results in "flat" windowMode, - // which means showHeader$ emits false. - const { getAllByRole, getByRole, getByTestId, vm } = createInCallView({ + it("mobile portrait, is visible in the header", () => { + createInCallView({ withAppBar: true, callViewModelOptions: { - // Set windowMode$ to "flat" (height <= 600) - windowSize$: constant({ width: 1000, height: 500 }), + // Narrow like a mobile phone in portrait orientation + windowSize$: constant({ width: 400, height: 700 }), }, }); - // In flat (landscape) mode the footer starts hidden until the user - // taps the screen, so no settings button should be accessible yet. - - expect(getByTestId("footer-container")).not.toBeVisible(); - const buttons = getAllByRole("button", { name: "Settings" }); - for (const b of buttons) { - expect(b).not.toBeVisible(); - } - - // Simulate a touch tap on the call view to reveal the footer. - // (PointerEvent is not available in JSDOM, so we call tapScreen() directly, - // which is exactly what the onClick handler does for touch events.) - act(() => vm.tapScreen()); - - // When showHeader is false, hideSettingsButton is false, - // so the settings button is visible in the footer. - const settingsBtn = getByRole("button", { name: "Settings" }); - // There are two buttons in the bottom bar. One for the - // the narrow layout and another one for the wide layout. - // Their visibility uses @media css queries, which we can test JSDOM (see `test.css.include` vitest config). - expect(settingsBtn).toHaveAttribute( - "data-testid", - "settings-bottom-left", - ); - expect(settingsBtn).toBeVisible(); + getByRole(screen.getByRole("banner", { name: "" }), "button", { + name: "Settings", + }); }); - it("mobile portrait, is accessible when showHeader is true", () => { - // windowSize with height > 600 and width > 600 results in "normal" windowMode, - // which means showHeader$ emits true. - const { getAllByRole } = createInCallView({ + it("mobile landscape, is not visible anywhere", () => { + const { queryByRole } = createInCallView({ withAppBar: true, callViewModelOptions: { - // Set windowMode$ to "normal" (height >= 600) - windowSize$: constant({ width: 1000, height: 800 }), + // Flat like a mobile phone in landscape orientation + windowSize$: constant({ width: 700, height: 400 }), }, }); - // When showHeader is true and headerStyle is AppBar, - // showSettingsButton is false in the footer, but the settings - // button is rendered in the AppBar via useAppBarSecondaryButton. - const settingsBtns = getAllByRole("button", { name: "Settings" }); - expect(settingsBtns.length).toBe(1); - expect(settingsBtns[0]).toHaveAttribute( - "data-testid", - "settings-app-bar", - ); - expect(settingsBtns[0]).toBeVisible(); + expect(queryByRole("button", { name: "Settings" })).toBe(null); }); }); + describe("audioOutputSwitcher", () => { it("is visible and can be clicked", async () => { const user = userEvent.setup(); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e19fe047..22799bdb 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -533,12 +533,19 @@ export const InCallView: FC = ({ // If edge-to-edge, compute new safe area insets that account for the // header and footer. "--call-view-safe-area-inset-top": - edgeToEdge && header && showHeader - ? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)` + edgeToEdge && headerStyle !== HeaderStyle.None && showHeader + ? // Header has two relevant cases: if it's an app bar, it lives + // outside the InCallView and consumes the safe area insets + // itself. Otherwise account for the safe area and header size + // as part of the InCallView. + headerStyle === HeaderStyle.AppBar + ? `${bounds.top}px` + : `calc(env(safe-area-inset-top) + ${headerBounds.height}px)` : undefined, "--call-view-safe-area-inset-bottom": edgeToEdge && showFooter - ? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)` + ? // Footer always lives inside the InCallView. + `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)` : undefined, }} model={layout} diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 02a0a351..fc25df48 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -85,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider"); const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); + vi.mock( "../state/CallViewModel/localMember/localTransport", async (importOriginal) => ({ @@ -838,6 +846,59 @@ describe.each([ }); }); + // Test cases for footer visibility in PIP mode across different platforms + const PIP_FOOTER_VISIBILITY_TEST_CASES: Array<{ + platform: "ios" | "android" | "desktop"; + expectedMarbles: string; + description: string; + }> = [ + { + platform: "ios", + expectedMarbles: "tf", + description: "hidden on iOS", + }, + { + platform: "android", + expectedMarbles: "tf", + description: "hidden on Android", + }, + { + platform: "desktop", + expectedMarbles: "t", + description: "visible on desktop", + }, + ]; + + it.each(PIP_FOOTER_VISIBILITY_TEST_CASES)( + "footer is $description in PIP mode", + ({ platform: testPlatform, expectedMarbles }) => { + withTestScheduler(({ schedule, expectObservable }) => { + // Set platform for this test case + getPlatform.mockReturnValue(testPlatform); + + // Enable PIP mode after initial render + const pipControlInputMarbles = "-e"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + }, + (vm) => { + schedule(pipControlInputMarbles, { + e: () => window.controls.enablePip(), + }); + + expectObservable(vm.showFooter$).toBe(expectedMarbles, { + t: true, + f: false, + }); + }, + ); + }); + }, + ); + test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Switch to spotlight immediately diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 2b0d9cfa..b07c560e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1344,6 +1344,10 @@ export function createCallViewModel$( // Layout is edge-to-edge; show/hide the footer in response to interactions return windowMode$.pipe( switchMap((mode) => { + if (mode === "pip" && platform !== "desktop") { + // No controls are shown in mobile pip as interactions are disabled + return of(false); + } const showInitially = mode !== "flat"; const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER; diff --git a/vitest.config.ts b/vitest.config.ts index fa1b6c1a..2dc9382c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig((configEnv) => vitePluginsConfig(configEnv), defineConfig({ test: { - fileParallelism: false, + fileParallelism: true, projects: [ { extends: true,