Merge pull request #3775 from element-hq/toger5/new-pip-layout

Implement new Pip Layout (with control buttons)
This commit is contained in:
Timo
2026-03-11 23:07:47 +08:00
committed by GitHub
8 changed files with 127 additions and 28 deletions

View File

@@ -0,0 +1,68 @@
/*
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 { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom";
await TestHelpers.createRoom("CallRoom", valere.page);
await TestHelpers.createRoom("OtherRoom", valere.page);
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
// Start the call as Valere
await TestHelpers.startCallInCurrentRoom(valere.page, false);
await expect(
valere.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(valere.page);
// wait a bit so that the PIP has rendered
await valere.page.waitForTimeout(600);
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
// We should see the PIP overlay
const iFrame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
{
// Check for a bug where the video had the wrong fit in PIP
const hangupBtn = iFrame.getByRole("button", { name: "End call" });
const audioBtn = iFrame.getByTestId("incall_mute");
const videoBtn = iFrame.getByTestId("incall_videomute");
await expect(hangupBtn).toBeVisible();
await expect(audioBtn).toBeVisible();
await expect(videoBtn).toBeVisible();
await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/);
await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/);
await videoBtn.click();
await audioBtn.click();
// stop hovering on any of the buttons
await iFrame.getByTestId("videoTile").hover();
await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/);
await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/);
}
});

View File

@@ -55,7 +55,7 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
// We should see the PIP overlay // We should see the PIP overlay
await expect(valere.page.locator(".mx_WidgetPip_overlay")).toBeVisible(); await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible();
{ {
// wait a bit so that the PIP has rendered the video // wait a bit so that the PIP has rendered the video

View File

@@ -23,6 +23,7 @@ import styles from "./Button.module.css";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean; muted: boolean;
size?: "sm" | "lg";
} }
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => { export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
@@ -47,6 +48,7 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean; muted: boolean;
size?: "sm" | "lg";
} }
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => { export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
@@ -71,6 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean; enabled: boolean;
size: "sm" | "lg";
} }
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
@@ -94,7 +97,11 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
); );
}; };
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({ interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
}
export const EndCallButton: FC<EndCallButtonProps> = ({
className, className,
...props ...props
}) => { }) => {
@@ -114,9 +121,10 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
); );
}; };
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = ( interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
props, size?: "sm" | "lg";
) => { }
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View File

@@ -166,6 +166,7 @@ export function ReactionPopupMenu({
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
identifier: string; identifier: string;
vm: CallViewModel; vm: CallViewModel;
size?: "sm" | "lg";
} }
export function ReactionToggleButton({ export function ReactionToggleButton({

View File

@@ -108,22 +108,9 @@ Please see LICENSE in the repository root for full details.
} }
} }
@media (max-width: 370px) { @media (max-height: 800px) {
.shareScreen {
display: none;
}
@media (max-height: 400px) {
.footer { .footer {
display: none; padding-block: var(--cpd-space-8x);
}
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
display: none;
} }
} }
@@ -133,9 +120,27 @@ Please see LICENSE in the repository root for full details.
} }
} }
@media (max-height: 800px) { @media (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
.shareScreen {
display: flex;
}
.footer { .footer {
padding-block: var(--cpd-space-8x); padding-block-start: var(--cpd-space-3x);
padding-block-end: var(--cpd-space-2x);
}
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
display: none;
} }
} }

View File

@@ -640,8 +640,10 @@ export const InCallView: FC<InCallViewProps> = ({
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
const buttonSize = layout.type === "pip" ? "sm" : "lg";
buttons.push( buttons.push(
<MicButton <MicButton
size={buttonSize}
key="audio" key="audio"
muted={!audioEnabled} muted={!audioEnabled}
onClick={toggleAudio ?? undefined} onClick={toggleAudio ?? undefined}
@@ -649,6 +651,7 @@ export const InCallView: FC<InCallViewProps> = ({
data-testid="incall_mute" data-testid="incall_mute"
/>, />,
<VideoButton <VideoButton
size={buttonSize}
key="video" key="video"
muted={!videoEnabled} muted={!videoEnabled}
onClick={toggleVideo ?? undefined} onClick={toggleVideo ?? undefined}
@@ -659,6 +662,7 @@ export const InCallView: FC<InCallViewProps> = ({
if (vm.toggleScreenSharing !== null) { if (vm.toggleScreenSharing !== null) {
buttons.push( buttons.push(
<ShareScreenButton <ShareScreenButton
size={buttonSize}
key="share_screen" key="share_screen"
className={styles.shareScreen} className={styles.shareScreen}
enabled={sharingScreen} enabled={sharingScreen}
@@ -670,6 +674,7 @@ export const InCallView: FC<InCallViewProps> = ({
if (supportsReactions) { if (supportsReactions) {
buttons.push( buttons.push(
<ReactionToggleButton <ReactionToggleButton
size={buttonSize}
vm={vm} vm={vm}
key="raise_hand" key="raise_hand"
className={styles.raiseHand} className={styles.raiseHand}
@@ -678,10 +683,17 @@ export const InCallView: FC<InCallViewProps> = ({
); );
} }
if (layout.type !== "pip") if (layout.type !== "pip")
buttons.push(<SettingsButton key="settings" onClick={openSettings} />); buttons.push(
<SettingsButton
size={buttonSize}
key="settings"
onClick={openSettings}
/>,
);
buttons.push( buttons.push(
<EndCallButton <EndCallButton
size={buttonSize}
key="end_call" key="end_call"
onClick={function (): void { onClick={function (): void {
vm.hangup(); vm.hangup();

View File

@@ -63,7 +63,7 @@ import {
playReactionsSound, playReactionsSound,
showReactions, showReactions,
} from "../../settings/settings"; } from "../../settings/settings";
import { isFirefox } from "../../Platform"; import { isFirefox, platform } from "../../Platform";
import { setPipEnabled$ } from "../../controls"; import { setPipEnabled$ } from "../../controls";
import { TileStore } from "../TileStore"; import { TileStore } from "../TileStore";
import { gridLikeLayout } from "../GridLikeLayout"; import { gridLikeLayout } from "../GridLikeLayout";
@@ -1271,7 +1271,7 @@ export function createCallViewModel$(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":
return of(false); return of(platform === "desktop" ? true : false);
case "normal": case "normal":
case "narrow": case "narrow":
return of(true); return of(true);

View File

@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { platform } from "../Platform.ts";
import { type PipLayout, type PipLayoutMedia } from "./layout-types.ts"; import { type PipLayout, type PipLayoutMedia } from "./layout-types.ts";
import { type TileStore } from "./TileStore"; import { type TileStore } from "./TileStore";
@@ -16,7 +17,11 @@ export function pipLayout(
prevTiles: TileStore, prevTiles: TileStore,
): [PipLayout, TileStore] { ): [PipLayout, TileStore] {
const update = prevTiles.from(0); const update = prevTiles.from(0);
update.registerSpotlight(media.spotlight, true); // Dont maximise in pip on EW since we want the rounded corners and the footer
update.registerSpotlight(
media.spotlight,
platform === "desktop" ? false : true,
);
const tiles = update.build(); const tiles = update.build();
return [ return [
{ {