Skip to content

Commit f509c06

Browse files
robintowntoger5
andauthored
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 <toger5@hotmail.de>
1 parent c012aec commit f509c06

33 files changed

+940
-145
lines changed

docs/url-params.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
5656
| `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. |
5757
| `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. |
5858
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
59-
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
59+
| `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`. |
6060
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
6161
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
6262
| `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`. |

locales/en/app.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
"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"
8080
},
8181
"disconnected_banner": "Connectivity to the server has been lost.",
82+
"earpiece": {
83+
"overlay_back_button": "Back to video",
84+
"overlay_description": "Only works while using app",
85+
"overlay_title": "Earpiece only mode"
86+
},
8287
"error": {
8388
"call_is_not_supported": "Call is not supported",
8489
"call_not_found": "Call not found",
@@ -177,6 +182,7 @@
177182
"default": "Default",
178183
"default_named": "Default <2>({{name}})</2>",
179184
"earpiece": "Earpiece",
185+
"loudspeaker": "Loudspeaker",
180186
"microphone": "Microphone",
181187
"microphone_numbered": "Microphone {{n}}",
182188
"speaker": "Speaker",

src/App.tsx

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
8+
import {
9+
type FC,
10+
type JSX,
11+
Suspense,
12+
useEffect,
13+
useMemo,
14+
useState,
15+
} from "react";
916
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
1017
import * as Sentry from "@sentry/react";
1118
import { TooltipProvider } from "@vector-im/compound-web";
@@ -24,6 +31,8 @@ import { useTheme } from "./useTheme";
2431
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
2532
import { type AppViewModel } from "./state/AppViewModel";
2633
import { MediaDevicesContext } from "./MediaDevicesContext";
34+
import { getUrlParams, HeaderStyle } from "./UrlParams";
35+
import { AppBar } from "./AppBar";
2736

2837
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
2938

@@ -67,41 +76,43 @@ export const App: FC<Props> = ({ vm }) => {
6776
.catch(logger.error);
6877
});
6978

79+
// Since we are outside the router component, we cannot use useUrlParams here
80+
const { header } = useMemo(getUrlParams, []);
81+
82+
const content = loaded ? (
83+
<ClientProvider>
84+
<MediaDevicesContext value={vm.mediaDevices}>
85+
<ProcessorProvider>
86+
<Sentry.ErrorBoundary
87+
fallback={(error) => <ErrorPage error={error} widget={widget} />}
88+
>
89+
<DisconnectedBanner />
90+
<Routes>
91+
<SentryRoute path="/" element={<HomePage />} />
92+
<SentryRoute path="/login" element={<LoginPage />} />
93+
<SentryRoute path="/register" element={<RegisterPage />} />
94+
<SentryRoute path="*" element={<RoomPage />} />
95+
</Routes>
96+
</Sentry.ErrorBoundary>
97+
</ProcessorProvider>
98+
</MediaDevicesContext>
99+
</ClientProvider>
100+
) : (
101+
<LoadingPage />
102+
);
103+
70104
return (
71-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
72-
// @ts-ignore
73105
<BrowserRouter>
74106
<BackgroundProvider>
75107
<ThemeProvider>
76108
<TooltipProvider>
77-
{loaded ? (
78-
<Suspense fallback={null}>
79-
<ClientProvider>
80-
<MediaDevicesContext value={vm.mediaDevices}>
81-
<ProcessorProvider>
82-
<Sentry.ErrorBoundary
83-
fallback={(error) => (
84-
<ErrorPage error={error} widget={widget} />
85-
)}
86-
>
87-
<DisconnectedBanner />
88-
<Routes>
89-
<SentryRoute path="/" element={<HomePage />} />
90-
<SentryRoute path="/login" element={<LoginPage />} />
91-
<SentryRoute
92-
path="/register"
93-
element={<RegisterPage />}
94-
/>
95-
<SentryRoute path="*" element={<RoomPage />} />
96-
</Routes>
97-
</Sentry.ErrorBoundary>
98-
</ProcessorProvider>
99-
</MediaDevicesContext>
100-
</ClientProvider>
101-
</Suspense>
102-
) : (
103-
<LoadingPage />
104-
)}
109+
<Suspense fallback={null}>
110+
{header === HeaderStyle.AppBar ? (
111+
<AppBar>{content}</AppBar>
112+
) : (
113+
content
114+
)}
115+
</Suspense>
105116
</TooltipProvider>
106117
</ThemeProvider>
107118
</BackgroundProvider>

src/AppBar.module.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.bar {
2+
block-size: 64px;
3+
flex-shrink: 0;
4+
}
5+
6+
.bar > header {
7+
position: absolute;
8+
inset-inline: 0;
9+
inset-block-start: 0;
10+
block-size: 64px;
11+
z-index: var(--call-view-header-footer-layer);
12+
}
13+
14+
.bar svg path {
15+
fill: var(--cpd-color-icon-primary);
16+
}
17+
18+
.bar > header > h1 {
19+
margin: 0;
20+
overflow: hidden;
21+
text-overflow: ellipsis;
22+
white-space: nowrap;
23+
}

src/AppBar.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { render } from "@testing-library/react";
9+
import { describe, expect, it } from "vitest";
10+
import { TooltipProvider } from "@vector-im/compound-web";
11+
12+
import { AppBar } from "./AppBar";
13+
14+
describe("AppBar", () => {
15+
it("renders", () => {
16+
const { container } = render(
17+
<TooltipProvider>
18+
<AppBar>
19+
<p>This is the content.</p>
20+
</AppBar>
21+
</TooltipProvider>,
22+
);
23+
expect(container).toMatchSnapshot();
24+
});
25+
});

src/AppBar.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import {
9+
createContext,
10+
type FC,
11+
type MouseEvent,
12+
type ReactNode,
13+
use,
14+
useCallback,
15+
useEffect,
16+
useMemo,
17+
useState,
18+
} from "react";
19+
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
20+
import {
21+
ArrowLeftIcon,
22+
CollapseIcon,
23+
} from "@vector-im/compound-design-tokens/assets/web/icons";
24+
import { useTranslation } from "react-i18next";
25+
26+
import { Header, LeftNav, RightNav } from "./Header";
27+
import { platform } from "./Platform";
28+
import styles from "./AppBar.module.css";
29+
30+
interface AppBarContext {
31+
setTitle: (value: string) => void;
32+
setSecondaryButton: (value: ReactNode) => void;
33+
setHidden: (value: boolean) => void;
34+
}
35+
36+
const AppBarContext = createContext<AppBarContext | null>(null);
37+
38+
interface Props {
39+
children: ReactNode;
40+
}
41+
42+
/**
43+
* A "top app bar" featuring a back button, title and possibly a secondary
44+
* button, similar to what you might see in mobile apps.
45+
*/
46+
export const AppBar: FC<Props> = ({ children }) => {
47+
const { t } = useTranslation();
48+
const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
49+
const onBackClick = useCallback((e: MouseEvent) => {
50+
e.preventDefault();
51+
window.controls.onBackButtonPressed?.();
52+
}, []);
53+
54+
const [title, setTitle] = useState<string>("");
55+
const [hidden, setHidden] = useState<boolean>(false);
56+
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
57+
const context = useMemo(
58+
() => ({ setTitle, setSecondaryButton, setHidden }),
59+
[setTitle, setHidden, setSecondaryButton],
60+
);
61+
62+
return (
63+
<>
64+
<div
65+
style={{ display: hidden ? "none" : "block" }}
66+
className={styles.bar}
67+
>
68+
<Header>
69+
<LeftNav>
70+
<Tooltip label={t("common.back")}>
71+
<IconButton onClick={onBackClick}>
72+
<BackIcon />
73+
</IconButton>
74+
</Tooltip>
75+
</LeftNav>
76+
{title && (
77+
<Heading
78+
type="body"
79+
size="lg"
80+
weight={platform === "android" ? "medium" : "semibold"}
81+
>
82+
{title}
83+
</Heading>
84+
)}
85+
<RightNav>{secondaryButton}</RightNav>
86+
</Header>
87+
</div>
88+
<AppBarContext value={context}>{children}</AppBarContext>
89+
</>
90+
);
91+
};
92+
93+
/**
94+
* React hook which sets the title to be shown in the app bar, if present. It is
95+
* an error to call this hook from multiple sites in the same component tree.
96+
*/
97+
export function useAppBarTitle(title: string): void {
98+
const setTitle = use(AppBarContext)?.setTitle;
99+
useEffect(() => {
100+
if (setTitle !== undefined) {
101+
setTitle(title);
102+
return (): void => setTitle("");
103+
}
104+
}, [title, setTitle]);
105+
}
106+
107+
/**
108+
* React hook which sets the title to be shown in the app bar, if present. It is
109+
* an error to call this hook from multiple sites in the same component tree.
110+
*/
111+
export function useAppBarHidden(hidden: boolean): void {
112+
const setHidden = use(AppBarContext)?.setHidden;
113+
useEffect(() => {
114+
if (setHidden !== undefined) {
115+
setHidden(hidden);
116+
return (): void => setHidden(false);
117+
}
118+
}, [setHidden, hidden]);
119+
}
120+
121+
/**
122+
* React hook which sets the secondary button to be shown in the app bar, if
123+
* present. It is an error to call this hook from multiple sites in the same
124+
* component tree.
125+
*/
126+
export function useAppBarSecondaryButton(button: ReactNode): void {
127+
const setSecondaryButton = use(AppBarContext)?.setSecondaryButton;
128+
useEffect(() => {
129+
if (setSecondaryButton !== undefined) {
130+
setSecondaryButton(button);
131+
return (): void => setSecondaryButton("");
132+
}
133+
}, [button, setSecondaryButton]);
134+
}

src/FullScreenView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
2828
className,
2929
children,
3030
}) => {
31-
const { hideHeader } = useUrlParams();
31+
const { header } = useUrlParams();
3232
return (
3333
<div className={classNames(styles.page, className)}>
34-
{!hideHeader && (
34+
{header === "standard" && (
3535
<Header>
3636
<LeftNav>
3737
<HeaderLogo />

src/UrlParams.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,16 @@ describe("UrlParams", () => {
243243
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
244244
});
245245
});
246+
describe("header", () => {
247+
it("uses header if provided", () => {
248+
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
249+
"app_bar",
250+
);
251+
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
252+
});
253+
it("converts hideHeader to the correct header value", () => {
254+
expect(getUrlParams("?hideHeader=true").header).toBe("none");
255+
expect(getUrlParams("?hideHeader=false").header).toBe("standard");
256+
});
257+
});
246258
});

src/UrlParams.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export enum UserIntent {
2525
Unknown = "unknown",
2626
}
2727

28+
export enum HeaderStyle {
29+
None = "none",
30+
Standard = "standard",
31+
AppBar = "app_bar",
32+
}
33+
2834
// If you need to add a new flag to this interface, prefer a name that describes
2935
// a specific behavior (such as 'confineToRoom'), rather than one that describes
3036
// the situations that call for this behavior ('isEmbedded'). This makes it
@@ -59,9 +65,12 @@ export interface UrlParams {
5965
*/
6066
preload: boolean;
6167
/**
62-
* Whether to hide the room header when in a call.
68+
* The style of headers to show. "standard" is the default arrangement, "none"
69+
* hides the header entirely, and "app_bar" produces a header with a back
70+
* button like you might see in mobile apps. The callback for the back button
71+
* is window.controls.onBackButtonPressed.
6372
*/
64-
hideHeader: boolean;
73+
header: HeaderStyle;
6574
/**
6675
* Whether the controls should be shown. For screen recording no controls can be desired.
6776
*/
@@ -257,6 +266,15 @@ export const getUrlParams = (
257266
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
258267
intent = UserIntent.Unknown;
259268
}
269+
270+
// Check hideHeader for backwards compatibility. If header is set, hideHeader
271+
// is ignored.
272+
const header =
273+
parser.getParam("header") ??
274+
(parser.getFlagParam("hideHeader")
275+
? HeaderStyle.None
276+
: HeaderStyle.Standard);
277+
260278
const widgetId = parser.getParam("widgetId");
261279
const parentUrl = parser.getParam("parentUrl");
262280
const isWidget = !!widgetId && !!parentUrl;
@@ -275,7 +293,7 @@ export const getUrlParams = (
275293
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
276294
appPrompt: parser.getFlagParam("appPrompt", true),
277295
preload: isWidget ? parser.getFlagParam("preload") : false,
278-
hideHeader: parser.getFlagParam("hideHeader"),
296+
header: header as HeaderStyle,
279297
showControls: parser.getFlagParam("showControls", true),
280298
hideScreensharing: parser.getFlagParam("hideScreensharing"),
281299
e2eEnabled: parser.getFlagParam("enableE2EE", true),

0 commit comments

Comments
 (0)