From f509c06cc6e0a619b95fc8548671ece674a56678 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 26 Jun 2025 05:08:57 -0400 Subject: [PATCH] Earpiece switcher and overlay (#3347) * Add a global control for toggling earpiece mode This will be used by Element X to show an earpiece toggle button in the header. * Add an earpiece overlay * Fix header The header needs to be passed forward as a string to some components and as a bool (hideHeader) to others. Also use a enum instead of string options. * fix top clipping with header * hide app bar in pip * revert android overlay app_bar * Modernize AppBarContext * Style header icon color as desired and switch earpice/speaker icon * fix initial selection when using controlled media * Add "Back to video" button * fix tests * remove dead code * add snapshot test * fix back to video button * Request capability to learn the room name We now need the room name in order to implement the mobile (widget-based) designs with the app bar. * Test the CallViewModel output switcher directly --------- Co-authored-by: Timo --- docs/url-params.md | 2 +- locales/en/app.json | 6 + src/App.tsx | 73 +++--- src/AppBar.module.css | 23 ++ src/AppBar.test.tsx | 25 ++ src/AppBar.tsx | 134 +++++++++++ src/FullScreenView.tsx | 4 +- src/UrlParams.test.ts | 12 + src/UrlParams.ts | 24 +- src/__snapshots__/AppBar.test.tsx.snap | 50 ++++ src/controls.ts | 26 ++- src/home/RegisteredView.tsx | 20 +- src/home/UnauthenticatedView.tsx | 20 +- src/index.css | 3 + src/room/CallEndedView.tsx | 12 +- src/room/EarpieceOverlay.module.css | 63 ++++++ src/room/EarpieceOverlay.tsx | 42 ++++ src/room/GroupCallView.test.tsx | 3 +- src/room/GroupCallView.tsx | 13 +- src/room/InCallView.module.css | 4 +- src/room/InCallView.test.tsx | 5 +- src/room/InCallView.tsx | 135 ++++++++--- src/room/RoomAuthView.tsx | 20 +- src/room/RoomPage.tsx | 14 +- .../__snapshots__/InCallView.test.tsx.snap | 213 +++++++++++++++++- src/settings/DeviceSelection.tsx | 3 + src/state/CallViewModel.test.ts | 67 ++++++ src/state/CallViewModel.ts | 47 ++++ src/state/MediaDevices.ts | 14 +- src/tile/GridTile.module.css | 1 + src/tile/MediaView.module.css | 2 + src/utils/test-viewmodel.ts | 8 +- src/widget.ts | 1 + 33 files changed, 942 insertions(+), 147 deletions(-) create mode 100644 src/AppBar.module.css create mode 100644 src/AppBar.test.tsx create mode 100644 src/AppBar.tsx create mode 100644 src/__snapshots__/AppBar.test.tsx.snap create mode 100644 src/room/EarpieceOverlay.module.css create mode 100644 src/room/EarpieceOverlay.tsx diff --git a/docs/url-params.md b/docs/url-params.md index 27f8f579..bc3846cd 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -56,7 +56,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | | `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | | `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | -| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. | +| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | | `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | | `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | | `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. | diff --git a/locales/en/app.json b/locales/en/app.json index e8a86fcc..428cba58 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -79,6 +79,11 @@ "use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key" }, "disconnected_banner": "Connectivity to the server has been lost.", + "earpiece": { + "overlay_back_button": "Back to video", + "overlay_description": "Only works while using app", + "overlay_title": "Earpiece only mode" + }, "error": { "call_is_not_supported": "Call is not supported", "call_not_found": "Call not found", @@ -177,6 +182,7 @@ "default": "Default", "default_named": "Default <2>({{name}})", "earpiece": "Earpiece", + "loudspeaker": "Loudspeaker", "microphone": "Microphone", "microphone_numbered": "Microphone {{n}}", "speaker": "Speaker", diff --git a/src/App.tsx b/src/App.tsx index 72def586..6d7d1e1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, Suspense, useEffect, useState } from "react"; +import { + type FC, + type JSX, + Suspense, + useEffect, + useMemo, + useState, +} from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -24,6 +31,8 @@ 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 { AppBar } from "./AppBar"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -67,41 +76,43 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); + // Since we are outside the router component, we cannot use useUrlParams here + const { header } = useMemo(getUrlParams, []); + + const content = loaded ? ( + + + + } + > + + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); + return ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - {loaded ? ( - - - - - ( - - )} - > - - - } /> - } /> - } - /> - } /> - - - - - - - ) : ( - - )} + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + diff --git a/src/AppBar.module.css b/src/AppBar.module.css new file mode 100644 index 00000000..d8954759 --- /dev/null +++ b/src/AppBar.module.css @@ -0,0 +1,23 @@ +.bar { + block-size: 64px; + flex-shrink: 0; +} + +.bar > header { + position: absolute; + inset-inline: 0; + inset-block-start: 0; + block-size: 64px; + z-index: var(--call-view-header-footer-layer); +} + +.bar svg path { + fill: var(--cpd-color-icon-primary); +} + +.bar > header > h1 { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/AppBar.test.tsx b/src/AppBar.test.tsx new file mode 100644 index 00000000..a2cce683 --- /dev/null +++ b/src/AppBar.test.tsx @@ -0,0 +1,25 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { AppBar } from "./AppBar"; + +describe("AppBar", () => { + it("renders", () => { + const { container } = render( + + +

This is the content.

+
+
, + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/AppBar.tsx b/src/AppBar.tsx new file mode 100644 index 00000000..337c31c5 --- /dev/null +++ b/src/AppBar.tsx @@ -0,0 +1,134 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + createContext, + type FC, + type MouseEvent, + type ReactNode, + use, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Heading, IconButton, Tooltip } from "@vector-im/compound-web"; +import { + ArrowLeftIcon, + CollapseIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useTranslation } from "react-i18next"; + +import { Header, LeftNav, RightNav } from "./Header"; +import { platform } from "./Platform"; +import styles from "./AppBar.module.css"; + +interface AppBarContext { + setTitle: (value: string) => void; + setSecondaryButton: (value: ReactNode) => void; + setHidden: (value: boolean) => void; +} + +const AppBarContext = createContext(null); + +interface Props { + children: ReactNode; +} + +/** + * A "top app bar" featuring a back button, title and possibly a secondary + * button, similar to what you might see in mobile apps. + */ +export const AppBar: FC = ({ children }) => { + const { t } = useTranslation(); + const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon; + const onBackClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + window.controls.onBackButtonPressed?.(); + }, []); + + const [title, setTitle] = useState(""); + const [hidden, setHidden] = useState(false); + const [secondaryButton, setSecondaryButton] = useState(null); + const context = useMemo( + () => ({ setTitle, setSecondaryButton, setHidden }), + [setTitle, setHidden, setSecondaryButton], + ); + + return ( + <> + + {children} + + ); +}; + +/** + * React hook which sets the title to be shown in the app bar, if present. It is + * an error to call this hook from multiple sites in the same component tree. + */ +export function useAppBarTitle(title: string): void { + const setTitle = use(AppBarContext)?.setTitle; + useEffect(() => { + if (setTitle !== undefined) { + setTitle(title); + return (): void => setTitle(""); + } + }, [title, setTitle]); +} + +/** + * React hook which sets the title to be shown in the app bar, if present. It is + * an error to call this hook from multiple sites in the same component tree. + */ +export function useAppBarHidden(hidden: boolean): void { + const setHidden = use(AppBarContext)?.setHidden; + useEffect(() => { + if (setHidden !== undefined) { + setHidden(hidden); + return (): void => setHidden(false); + } + }, [setHidden, hidden]); +} + +/** + * React hook which sets the secondary button to be shown in the app bar, if + * present. It is an error to call this hook from multiple sites in the same + * component tree. + */ +export function useAppBarSecondaryButton(button: ReactNode): void { + const setSecondaryButton = use(AppBarContext)?.setSecondaryButton; + useEffect(() => { + if (setSecondaryButton !== undefined) { + setSecondaryButton(button); + return (): void => setSecondaryButton(""); + } + }, [button, setSecondaryButton]); +} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 6e840cc6..41e6cb16 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -28,10 +28,10 @@ export const FullScreenView: FC = ({ className, children, }) => { - const { hideHeader } = useUrlParams(); + const { header } = useUrlParams(); return (
- {!hideHeader && ( + {header === "standard" && (
diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index dce46754..b65638e0 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -243,4 +243,16 @@ describe("UrlParams", () => { expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false); }); }); + describe("header", () => { + it("uses header if provided", () => { + expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe( + "app_bar", + ); + expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none"); + }); + it("converts hideHeader to the correct header value", () => { + expect(getUrlParams("?hideHeader=true").header).toBe("none"); + expect(getUrlParams("?hideHeader=false").header).toBe("standard"); + }); + }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 17e169d9..9f89fd47 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -25,6 +25,12 @@ export enum UserIntent { Unknown = "unknown", } +export enum HeaderStyle { + None = "none", + Standard = "standard", + AppBar = "app_bar", +} + // If you need to add a new flag to this interface, prefer a name that describes // a specific behavior (such as 'confineToRoom'), rather than one that describes // the situations that call for this behavior ('isEmbedded'). This makes it @@ -59,9 +65,12 @@ export interface UrlParams { */ preload: boolean; /** - * Whether to hide the room header when in a call. + * The style of headers to show. "standard" is the default arrangement, "none" + * hides the header entirely, and "app_bar" produces a header with a back + * button like you might see in mobile apps. The callback for the back button + * is window.controls.onBackButtonPressed. */ - hideHeader: boolean; + header: HeaderStyle; /** * Whether the controls should be shown. For screen recording no controls can be desired. */ @@ -257,6 +266,15 @@ export const getUrlParams = ( if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) { intent = UserIntent.Unknown; } + + // Check hideHeader for backwards compatibility. If header is set, hideHeader + // is ignored. + const header = + parser.getParam("header") ?? + (parser.getFlagParam("hideHeader") + ? HeaderStyle.None + : HeaderStyle.Standard); + const widgetId = parser.getParam("widgetId"); const parentUrl = parser.getParam("parentUrl"); const isWidget = !!widgetId && !!parentUrl; @@ -275,7 +293,7 @@ export const getUrlParams = ( parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"), appPrompt: parser.getFlagParam("appPrompt", true), preload: isWidget ? parser.getFlagParam("preload") : false, - hideHeader: parser.getFlagParam("hideHeader"), + header: header as HeaderStyle, showControls: parser.getFlagParam("showControls", true), hideScreensharing: parser.getFlagParam("hideScreensharing"), e2eEnabled: parser.getFlagParam("enableE2EE", true), diff --git a/src/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap new file mode 100644 index 00000000..e7dc1c46 --- /dev/null +++ b/src/__snapshots__/AppBar.test.tsx.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AppBar > renders 1`] = ` +
+
+
+ +
+
+

+ This is the content. +

+
+`; diff --git a/src/controls.ts b/src/controls.ts index 41cf5852..03a12e5a 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. @@ -14,24 +14,25 @@ export interface Controls { canEnterPip(): boolean; enablePip(): void; disablePip(): void; - /** @deprecated use setAvailableAudioDevices instead*/ - setAvailableOutputDevices(devices: OutputDevice[]): void; + setAvailableAudioDevices(devices: OutputDevice[]): void; - /** @deprecated use setAudioDevice instead*/ - setOutputDevice(id: string): void; setAudioDevice(id: string): void; - /** @deprecated use onAudioDeviceSelect instead*/ - onOutputDeviceSelect?: (id: string) => void; onAudioDeviceSelect?: (id: string) => void; onAudioPlaybackStarted?: () => void; + setAudioEnabled(enabled: boolean): void; + showNativeAudioDevicePicker?: () => void; + onBackButtonPressed?: () => void; + + /** @deprecated use setAvailableAudioDevices instead*/ + setAvailableOutputDevices(devices: OutputDevice[]): void; + /** @deprecated use setAudioDevice instead*/ + setOutputDevice(id: string): void; + /** @deprecated use onAudioDeviceSelect instead*/ + onOutputDeviceSelect?: (id: string) => void; /** @deprecated use setAudioEnabled instead*/ setOutputEnabled(enabled: boolean): void; - setAudioEnabled(enabled: boolean): void; /** @deprecated use showNativeAudioDevicePicker instead*/ showNativeOutputDevicePicker?: () => void; - showNativeAudioDevicePicker?: () => void; - - onBackButtonPressed?: () => void; } export interface OutputDevice { @@ -59,6 +60,7 @@ export const outputDevice$ = new Subject(); * This should also be used to display a darkened overlay screen letting the user know that audio is muted. */ export const setAudioEnabled$ = new Subject(); + let playbackStartedEmitted = false; export const setPlaybackStarted = (): void => { if (!playbackStartedEmitted) { @@ -66,6 +68,7 @@ export const setPlaybackStarted = (): void => { window.controls.onAudioPlaybackStarted?.(); } }; + window.controls = { canEnterPip(): boolean { return setPipEnabled$.observed; @@ -78,6 +81,7 @@ window.controls = { if (!setPipEnabled$.observed) throw new Error("No call is running"); setPipEnabled$.next(false); }, + setAvailableAudioDevices(devices: OutputDevice[]): void { logger.info("setAvailableAudioDevices called from native:", devices); availableOutputDevices$.next(devices); diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index af2d5f26..361160c5 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -37,12 +37,14 @@ import { Form } from "../form/Form"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; +import { useUrlParams } from "../UrlParams"; interface Props { client: MatrixClient; } export const RegisteredView: FC = ({ client }) => { + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -114,14 +116,16 @@ export const RegisteredView: FC = ({ client }) => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index e23c637b..6e05bc34 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -34,9 +34,11 @@ import { Config } from "../config/Config"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; import { ExternalLink, Link } from "../button/Link"; +import { useUrlParams } from "../UrlParams"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/index.css b/src/index.css index 46c9fbc6..dc914452 100644 --- a/src/index.css +++ b/src/index.css @@ -45,6 +45,9 @@ layer(compound); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --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; } :root, diff --git a/src/room/CallEndedView.tsx b/src/room/CallEndedView.tsx index 43aa96e2..4df3f297 100644 --- a/src/room/CallEndedView.tsx +++ b/src/room/CallEndedView.tsx @@ -25,6 +25,7 @@ import { LinkButton } from "../button"; interface Props { client: MatrixClient; isPasswordlessUser: boolean; + hideHeader: boolean; confineToRoom: boolean; endedCallId: string; } @@ -32,6 +33,7 @@ interface Props { export const CallEndedView: FC = ({ client, isPasswordlessUser, + hideHeader, confineToRoom, endedCallId, }) => { @@ -133,10 +135,12 @@ export const CallEndedView: FC = ({ return ( <> -
- {!confineToRoom && } - -
+ {!hideHeader && ( +
+ {!confineToRoom && } + +
+ )}
diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css new file mode 100644 index 00000000..cb65b693 --- /dev/null +++ b/src/room/EarpieceOverlay.module.css @@ -0,0 +1,63 @@ +.overlay { + position: fixed; + z-index: var(--call-view-overlay-layer); + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--cpd-space-2x); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.overlay[data-show="true"] { + animation: fade-in 200ms; +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + display: none; + } +} + +.overlay[data-show="false"] { + animation: fade-out 130ms forwards; + content-visibility: hidden; + pointer-events: none; +} + +.overlay::before { + content: ""; + position: absolute; + z-index: -1; + inset: 0; + background: var(--cpd-color-bg-canvas-default); + opacity: 0.75; +} + +.icon { + margin-block-end: var(--cpd-space-4x); + background: var(--cpd-color-alpha-gray-600); + color: var(--cpd-color-icon-primary); +} + +.overlay > h2 { + text-align: center; + margin: 0; +} + +.overlay > p { + text-align: center; +} diff --git a/src/room/EarpieceOverlay.tsx b/src/room/EarpieceOverlay.tsx new file mode 100644 index 00000000..054e3083 --- /dev/null +++ b/src/room/EarpieceOverlay.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2025 New Vector 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 { BigIcon, Button, Heading, Text } from "@vector-im/compound-web"; +import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useTranslation } from "react-i18next"; + +import styles from "./EarpieceOverlay.module.css"; + +interface Props { + show: boolean; + onBackToVideoPressed?: (() => void) | null; +} + +export const EarpieceOverlay: FC = ({ show, onBackToVideoPressed }) => { + const { t } = useTranslation(); + return ( +
+ + + + + {t("earpiece.overlay_title")} + + {t("earpiece.overlay_description")} + +
+ ); +}; diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index bf73c15d..3a290cc7 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -42,6 +42,7 @@ import { LazyEventEmitter } from "../LazyEventEmitter"; import { MatrixRTCFocusMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; +import { HeaderStyle } from "../UrlParams"; vi.mock("../soundUtils"); vi.mock("../useAudioContext"); @@ -157,7 +158,7 @@ function createGroupCallView( confineToRoom={false} preload={false} skipLobby={false} - hideHeader={true} + header={HeaderStyle.Standard} rtcSession={rtcSession as unknown as MatrixRTCSession} isJoined={joined} muteStates={muteState} diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index c64ba1fd..0ae6a96b 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -51,7 +51,7 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; -import { useUrlParams } from "../UrlParams"; +import { HeaderStyle, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds } from "./CallEventAudioRenderer"; @@ -72,6 +72,7 @@ import { } from "../settings/settings"; import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; +import { useAppBarTitle } from "../AppBar.tsx"; declare global { interface Window { @@ -85,7 +86,7 @@ interface Props { confineToRoom: boolean; preload: boolean; skipLobby: boolean; - hideHeader: boolean; + header: HeaderStyle; rtcSession: MatrixRTCSession; isJoined: boolean; muteStates: MuteStates; @@ -98,7 +99,7 @@ export const GroupCallView: FC = ({ confineToRoom, preload, skipLobby, - hideHeader, + header, rtcSession, isJoined, muteStates, @@ -187,6 +188,7 @@ export const GroupCallView: FC = ({ }, [passwordFromUrl, room.roomId]); usePageTitle(roomName); + useAppBarTitle(roomName); const matrixInfo = useMemo((): MatrixInfo => { return { @@ -431,7 +433,7 @@ export const GroupCallView: FC = ({ muteStates={muteStates} onEnter={() => void enterRTCSessionOrError(rtcSession)} confineToRoom={confineToRoom} - hideHeader={hideHeader} + hideHeader={header === HeaderStyle.None} participantCount={participantCount} onShareClick={onShareClick} /> @@ -457,7 +459,7 @@ export const GroupCallView: FC = ({ rtcSession={rtcSession as MatrixRTCSession} participantCount={participantCount} onLeave={onLeave} - hideHeader={hideHeader} + header={header} muteStates={muteStates} e2eeSystem={e2eeSystem} //otelGroupCallMembership={otelGroupCallMembership} @@ -483,6 +485,7 @@ export const GroupCallView: FC = ({ endedCallId={rtcSession.room.roomId} client={client} isPasswordlessUser={isPasswordlessUser} + hideHeader={header === HeaderStyle.None} confineToRoom={confineToRoom} /> ); diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index bb9cc052..24dfbe5c 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -18,7 +18,7 @@ Please see LICENSE in the repository root for full details. position: sticky; flex-shrink: 0; inset-block-start: 0; - z-index: 1; + z-index: var(--call-view-header-footer-layer); background: linear-gradient( 0deg, rgba(0, 0, 0, 0) 0%, @@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details. .footer { position: sticky; inset-block-end: 0; - z-index: 1; + z-index: var(--call-view-header-footer-layer); display: grid; grid-template-columns: minmax(0, var(--inline-content-inset)) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index e5a789e7..b88aaad7 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -47,6 +47,7 @@ import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { MediaDevicesContext } from "../MediaDevicesContext"; +import { HeaderStyle } from "../UrlParams"; // vi.hoisted(() => { // localStorage = {} as unknown as Storage; @@ -158,7 +159,7 @@ function createInCallView(): RenderResult & { { queryByText("using to Device key transport"), ).not.toBeInTheDocument(); }); + it("is not shown if setting is disabled", () => { useExperimentalToDeviceTransportSetting.setValue(false); developerModeSetting.setValue(true); @@ -254,6 +256,7 @@ describe("InCallView", () => { queryByText("using to Device key transport"), ).not.toBeInTheDocument(); }); + it("is not shown if developer mode is disabled", () => { useExperimentalToDeviceTransportSetting.setValue(true); developerModeSetting.setValue(false); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5ef3165d..452e8572 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { RoomContext, useLocalParticipant } from "@livekit/components-react"; -import { Text } from "@vector-im/compound-web"; +import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; import { ConnectionState, type Room } from "livekit-client"; import { type MatrixClient } from "matrix-js-sdk"; import { @@ -28,6 +28,11 @@ import { BehaviorSubject, map } from "rxjs"; import { useObservable, useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; +import { + EarpieceIcon, + VolumeOnSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useTranslation } from "react-i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -42,7 +47,7 @@ import { SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; -import { useUrlParams } from "../UrlParams"; +import { type HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; @@ -104,6 +109,9 @@ import { useTypedEventEmitter } from "../useEvents.ts"; import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts"; +import { useMediaDevices } from "../MediaDevicesContext.ts"; +import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; +import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -115,6 +123,7 @@ export interface ActiveCallProps } export const ActiveCall: FC = (props) => { + const mediaDevices = useMediaDevices(); const sfuConfig = useOpenIDSFU(props.client, props.rtcSession); const { livekitRoom, connState } = useLivekit( props.rtcSession, @@ -155,6 +164,7 @@ export const ActiveCall: FC = (props) => { const vm = new CallViewModel( props.rtcSession, livekitRoom, + mediaDevices, props.e2eeSystem, connStateObservable$, reactionsReader.raisedHands$, @@ -166,7 +176,13 @@ export const ActiveCall: FC = (props) => { reactionsReader.destroy(); }; } - }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]); + }, [ + props.rtcSession, + livekitRoom, + mediaDevices, + props.e2eeSystem, + connStateObservable$, + ]); if (livekitRoom === undefined || vm === null) return null; @@ -194,7 +210,7 @@ export interface InCallViewProps { participantCount: number; /** Function to call when the user explicitly ends the call */ onLeave: () => void; - hideHeader: boolean; + header: HeaderStyle; otelGroupCallMembership?: OTelGroupCallMembership; connState: ECConnectionState; onShareClick: (() => void) | null; @@ -209,10 +225,11 @@ export const InCallView: FC = ({ muteStates, participantCount, onLeave, - hideHeader, + header: headerStyle, connState, onShareClick, }) => { + const { t } = useTranslation(); const { supportsReactions, sendReaction, toggleRaisedHand } = useReactionsSender(); @@ -292,6 +309,8 @@ export const InCallView: FC = ({ const gridMode = useObservableEagerState(vm.gridMode$); const showHeader = useObservableEagerState(vm.showHeader$); const showFooter = useObservableEagerState(vm.showFooter$); + const earpieceMode = useObservableEagerState(vm.earpieceMode$); + const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking @@ -434,6 +453,70 @@ export const InCallView: FC = ({ } }, [setGridMode]); + useAppBarSecondaryButton( + useMemo(() => { + if (audioOutputSwitcher === null) return null; + const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece"; + const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon; + const label = isEarpieceTarget + ? t("settings.devices.earpiece") + : t("settings.devices.loudspeaker"); + + return ( + + { + e.preventDefault(); + audioOutputSwitcher.switch(); + }} + > + + + + ); + }, [t, audioOutputSwitcher]), + ); + + useAppBarHidden(!showHeader); + + let header: ReactNode = null; + if (showHeader) { + switch (headerStyle) { + case "none": + // Cosmetic header to fill out space while still affecting the bounds of + // the grid + header = ( +
+ ); + break; + case "standard": + header = ( +
+ + + + + {showControls && onShareClick !== null && ( + + )} + +
+ ); + } + } + const Tile = useMemo( () => function Tile({ @@ -521,7 +604,8 @@ export const InCallView: FC = ({ key="fixed" className={styles.fixedGrid} style={{ - insetBlockStart: headerBounds.bottom, + insetBlockStart: + headerBounds.height > 0 ? headerBounds.bottom : bounds.top, height: gridBounds.height, }} model={layout} @@ -644,10 +728,11 @@ export const InCallView: FC = ({ ref={footerRef} className={classNames(styles.footer, { [styles.overlay]: windowMode === "flat", - [styles.hidden]: !showFooter || (!showControls && hideHeader), + [styles.hidden]: + !showFooter || (!showControls && headerStyle === "none"), })} > - {!hideHeader && ( + {headerStyle !== "none" && (
= ({ onPointerMove={onPointerMove} onPointerOut={onPointerOut} > - {showHeader && - (hideHeader ? ( - // Cosmetic header to fill out space while still affecting the bounds - // of the grid -
- ) : ( -
- - - - - {showControls && onShareClick !== null && ( - - )} - -
- ))} + {header} { // TODO: remove this once we remove the developer flag gets removed and we have shipped to // device transport as the default. @@ -728,6 +785,10 @@ export const InCallView: FC = ({ {renderContent()} + {footer} {layout.type !== "pip" && ( diff --git a/src/room/RoomAuthView.tsx b/src/room/RoomAuthView.tsx index 509460e9..cfc99e82 100644 --- a/src/room/RoomAuthView.tsx +++ b/src/room/RoomAuthView.tsx @@ -19,8 +19,10 @@ import { UserMenuContainer } from "../UserMenuContainer"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { Config } from "../config/Config"; import { ExternalLink, Link } from "../button/Link"; +import { useUrlParams } from "../UrlParams"; export const RoomAuthView: FC = () => { + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => { return ( <> -
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index f502407c..b424c511 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -43,14 +43,8 @@ import { ErrorView } from "../ErrorView"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; export const RoomPage: FC = () => { - const { - confineToRoom, - appPrompt, - preload, - hideHeader, - displayName, - skipLobby, - } = useUrlParams(); + const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } = + useUrlParams(); const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -120,7 +114,7 @@ export const RoomPage: FC = () => { confineToRoom={confineToRoom} preload={preload} skipLobby={skipLobby || wasInWaitForInviteState.current} - hideHeader={hideHeader} + header={header} muteStates={muteStates} /> ); @@ -161,7 +155,7 @@ export const RoomPage: FC = () => { enterLabel={label} waitingForInvite={groupCallState.kind === "waitForInvite"} confineToRoom={confineToRoom} - hideHeader={hideHeader} + hideHeader={header !== "standard"} participantCount={null} muteStates={muteStates} onShareClick={null} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 8ea66705..634e9753 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -5,9 +5,56 @@ exports[`InCallView > rendering > renders 1`] = `
-
+
+ +
mocked: MatrixAudioRenderer
@@ -35,18 +82,166 @@ exports[`InCallView > rendering > renders 1`] = ` >
+
+
+ +
+

+ Earpiece only mode +

+

+ Only works while using app +

+ +