diff --git a/docs/url_params.md b/docs/url_params.md index 7b80690a..789e3f3c 100644 --- a/docs/url_params.md +++ b/docs/url_params.md @@ -67,6 +67,7 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st | `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | | `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | +| `background` | One of: `solid`, `gradient` | No, defaults to `gradient` | No, defaults to `gradient` | Visual style of the page background. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | | `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | | `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | diff --git a/playwright/spa-helpers.ts b/playwright/spa-helpers.ts index 46c414c9..24869141 100644 --- a/playwright/spa-helpers.ts +++ b/playwright/spa-helpers.ts @@ -125,21 +125,10 @@ async function expectVideoTilesCount(page: Page, count: number): Promise { }); // There should be `count` video elements, visible and autoplaying - await expect(page.locator("video")).toHaveCount(count); - - await expect(async () => { - const videoBlockCount = await page - .locator("video") - .evaluateAll( - (videos: Element[]) => - videos.filter( - (v: Element) => window.getComputedStyle(v).display === "block", - ).length, - ); - expect(videoBlockCount).toBe(count); - }).toPass({ - timeout: 10000, - }); + await expect(page.locator("video").filter({ visible: true })).toHaveCount( + count, + { timeout: 10000 }, + ); } export const SpaHelpers = { diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index b2a238f8..3f0bbeed 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -385,25 +385,9 @@ export class TestHelpers { frame: FrameLocator, count: number, ): Promise { - // XXX we need to be better at our HTML markup and accessibility, it would make - // this kind of stuff way easier to test if we could look out for aria attributes. - await expect - .poll( - async () => { - return await frame - .locator("video") - .evaluateAll( - (videos: Element[]) => - videos.filter( - (v: Element) => - window.getComputedStyle(v).display === "block", - ).length, - ); - }, - { - timeout: 10000, - }, - ) - .toBe(count); + await expect(frame.locator("video").filter({ visible: true })).toHaveCount( + count, + { timeout: 10000 }, + ); } } diff --git a/src/App.tsx b/src/App.tsx index b87f587c..8f6ef21a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,7 @@ import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; -import { getUrlParams, HeaderStyle } from "./UrlParams"; +import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -41,19 +41,17 @@ interface SimpleProviderProps { const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); + const { background } = useUrlParams(); useEffect(() => { - let backgroundImage = ""; - if (!["/login", "/register"].includes(pathname) && !widget) { - backgroundImage = "var(--background-gradient)"; - } + document + .getElementsByTagName("body")[0] + .setAttribute("data-background", background); + }, [pathname, background]); - document.getElementsByTagName("body")[0].style.backgroundImage = - backgroundImage; - }, [pathname]); - - return <>{children}; + return children; }; + const ThemeProvider: FC = ({ children }) => { useTheme(); return children; diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 773aa412..805cab71 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -45,6 +45,11 @@ export enum HeaderStyle { AppBar = "app_bar", } +export enum BackgroundStyle { + Solid = "solid", + Gradient = "gradient", +} + /** * The UrlProperties are used to pass required data to the widget. * Those are different in different rooms, users, devices. They do not configure the behavior of the @@ -145,6 +150,10 @@ export interface UrlProperties { * can be "light", "dark", "light-high-contrast" or "dark-high-contrast". */ theme: string | null; + /** + * The visual style of the page background. + */ + background: BackgroundStyle; } /** @@ -452,6 +461,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { fonts: parser.getAllParams("font"), fontScale: Number.isNaN(fontScale) ? null : fontScale, theme: parser.getParam("theme"), + background: + parser.getEnumParam("background", BackgroundStyle) ?? + BackgroundStyle.Gradient, viaServers: !isWidget ? parser.getParam("viaServers") : null, homeserver: !isWidget ? parser.getParam("homeserver") : null, posthogApiHost: parser.getParam("posthogApiHost"), diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index adff99d5..d919b33e 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details. padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x)); padding-block: var(--cpd-space-10x) calc(env(safe-area-inset-bottom) + var(--cpd-space-10x)); - background: linear-gradient( - 180deg, - rgba(0, 0, 0, 0) 0%, - var(--cpd-color-bg-canvas-default) 100% - ); } .footer.hidden { diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index f952601d..f983d809 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -112,11 +112,17 @@ export interface FooterState { } export interface FooterProps { + className?: string; ref?: Ref; children?: JSX.Element | JSX.Element[] | false; vm: ViewModel; } -export const CallFooter: FC = ({ ref, children, vm }) => { +export const CallFooter: FC = ({ + className, + ref, + children, + vm, +}) => { const asOverlay = useBehavior(vm.asOverlay$); const showFooter = useBehavior(vm.showFooter$); const hideControls = useBehavior(vm.hideControls$); @@ -292,7 +298,7 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/graphics/mobile-gradient.svg b/src/graphics/mobile-gradient.svg new file mode 100644 index 00000000..e401846e --- /dev/null +++ b/src/graphics/mobile-gradient.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/index.css b/src/index.css index 039b2a42..ad18e25d 100644 --- a/src/index.css +++ b/src/index.css @@ -55,7 +55,6 @@ layer(compound); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --big-drop-shadow: 0px 0px 24px 0px #1b1d221a; --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); - --background-gradient: url("graphics/backgroundGradient.svg"); --call-view-overlay-layer: 1; --call-view-header-footer-layer: 2; @@ -74,9 +73,6 @@ layer(compound); body { background-color: var(--cpd-color-bg-canvas-default); - background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh)); - background-repeat: no-repeat; - background-position: center; color: var(--cpd-color-text-primary); color-scheme: dark; margin: 0; @@ -85,6 +81,24 @@ body { -webkit-tap-highlight-color: transparent; } +@media (min-height: 330px) { + body[data-background="gradient"]::before { + content: ""; + position: fixed; + inset: 0; + background-image: url("graphics/mobile-gradient.svg"); + background-size: auto; + background-position: bottom; + background-repeat: no-repeat; + } + + body[data-background="gradient"][data-platform="desktop"]::before { + background-image: url("graphics/desktop-gradient.svg"); + background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh)); + background-position: center; + } +} + /* This prohibits the view to scroll for pages smaller than 122px in width we use this for mobile pip webviews */ .no-scroll-body { diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index fcf1a492..736a915a 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details. overflow-y: auto; } +/* Normally the footer uses a transparent background to allow our expressive +page gradients to shine through. However, we sometimes need to visually separate +it from the content underneath. If the call layout is overflowing, or if the +spotlight tile is maximised and displaying video, apply a gradient background. */ +.overflowing > .footer, +.fixedGrid:has( + > .tile[data-maximised="true"] + .spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"] + ) + ~ .footer { + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + var(--cpd-color-bg-canvas-default) 100% + ); +} + .header { position: sticky; flex-shrink: 0; @@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details. /* Disable pointer events so the overlay doesn't block interaction with elements behind it */ pointer-events: none; -} -.fixedGrid > :not(:first-child), -.scrollingGrid > :not(:first-child) { - pointer-events: initial; + > :not(:first-child) { + pointer-events: initial; + } + + .tile { + position: absolute; + inset-block-start: 0; + } } .tile { - position: absolute; - inset-block-start: 0; -} - -.tile.maximised { position: relative; flex-grow: 1; } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index bf591872..58b378ae 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -258,6 +258,7 @@ export const InCallView: FC = ({ const reconnecting = useBehavior(vm.reconnecting$); const layout = useBehavior(vm.layout$); const edgeToEdge = useBehavior(vm.edgeToEdge$); + const overflowing = useBehavior(vm.overflowing$); const showNameTags = useBehavior(vm.showNameTags$); const showHeader = useBehavior(vm.showHeader$); const settingsOpen = useBehavior(vm.settingsOpen$); @@ -467,6 +468,7 @@ export const InCallView: FC = ({ showRingingStatus={showRingingStatus} focusable={!contentObscured} className={classNames(className, styles.tile)} + itemClassName={styles.spotlightItem} style={style} /> ); @@ -491,7 +493,9 @@ export const InCallView: FC = ({ if (layout.type === "pip") { return ( = ({ // Only hide the settings button if we have an AppBar header and we are showing the header const footer = footerVm !== null && ( - + ); const allConnections = useBehavior(vm.allConnections$); @@ -592,7 +596,9 @@ export const InCallView: FC = ({ // and the footer is also viewable by moving focus into it, so this is fine. // eslint-disable-next-line jsx-a11y/no-static-element-interactions
rendering > renders 1`] = ` class="_container_8084b5" />