WIP: Add app bar and earpiece toggle button

This commit is contained in:
Robin
2025-06-23 21:54:48 -04:00
committed by Timo
parent 6383c94f2f
commit e112b527a8
20 changed files with 380 additions and 150 deletions

View File

@@ -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. | | `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. | | `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...`. | | `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. | | `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. | | `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`. | | `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`. |

View File

@@ -181,6 +181,7 @@
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>", "default_named": "Default <2>({{name}})</2>",
"earpiece": "Earpiece", "earpiece": "Earpiece",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}", "microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker", "speaker": "Speaker",

View File

@@ -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. 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 { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -24,6 +31,8 @@ import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel"; import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext"; import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -67,41 +76,39 @@ export const App: FC<Props> = ({ vm }) => {
.catch(logger.error); .catch(logger.error);
}); });
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return ( return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<BrowserRouter> <BrowserRouter>
<BackgroundProvider> <BackgroundProvider>
<ThemeProvider> <ThemeProvider>
<TooltipProvider> <TooltipProvider>
{loaded ? ( <Suspense fallback={null}>
<Suspense fallback={null}> {header === "app_bar" ? <AppBar>{content}</AppBar> : content}
<ClientProvider> </Suspense>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => (
<ErrorPage error={error} widget={widget} />
)}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute
path="/register"
element={<RegisterPage />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</Suspense>
) : (
<LoadingPage />
)}
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
</BackgroundProvider> </BackgroundProvider>

23
src/AppBar.module.css Normal file
View File

@@ -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 {
color: var(--cpd-color-icon-primary);
}
.bar > header > h1 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

117
src/AppBar.tsx Normal file
View File

@@ -0,0 +1,117 @@
/*
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,
useCallback,
useContext,
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;
}
const AppBarContext = createContext<AppBarContext | null>(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<Props> = ({ children }) => {
const { t } = useTranslation();
const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
const onBackClick = useCallback((e: MouseEvent) => {
e.preventDefault();
window.controls.onBackButtonPressed?.();
}, []);
const [title, setTitle] = useState<string>("");
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const context = useMemo(
() => ({ setTitle, setSecondaryButton }),
[setTitle, setSecondaryButton],
);
return (
<>
<div className={styles.bar}>
<Header>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>
<BackIcon />
</IconButton>
</Tooltip>
</LeftNav>
{title && (
<Heading
type="body"
size="lg"
weight={platform === "android" ? "medium" : "semibold"}
>
{title}
</Heading>
)}
<RightNav>{secondaryButton}</RightNav>
</Header>
</div>
<AppBarContext.Provider value={context}>
{children}
</AppBarContext.Provider>
</>
);
};
/**
* 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 = useContext(AppBarContext)?.setTitle;
useEffect(() => {
if (setTitle !== undefined) {
setTitle(title);
return (): void => setTitle("");
}
}, [title, setTitle]);
}
/**
* 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 = useContext(AppBarContext)?.setSecondaryButton;
useEffect(() => {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
}
}, [button, setSecondaryButton]);
}

View File

@@ -28,10 +28,10 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
className, className,
children, children,
}) => { }) => {
const { hideHeader } = useUrlParams(); const { header } = useUrlParams();
return ( return (
<div className={classNames(styles.page, className)}> <div className={classNames(styles.page, className)}>
{!hideHeader && ( {header === "standard" && (
<Header> <Header>
<LeftNav> <LeftNav>
<HeaderLogo /> <HeaderLogo />

View File

@@ -59,9 +59,12 @@ export interface UrlParams {
*/ */
preload: boolean; 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: "none" | "standard" | "app_bar";
/** /**
* Whether the controls should be shown. For screen recording no controls can be desired. * Whether the controls should be shown. For screen recording no controls can be desired.
*/ */
@@ -257,6 +260,13 @@ export const getUrlParams = (
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) { if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
intent = UserIntent.Unknown; intent = UserIntent.Unknown;
} }
// Check hideHeader for backwards compatibility
let header = parser.getFlagParam("hideHeader")
? "none"
: parser.getParam("header");
if (header !== "none" && header !== "app_bar") header = "standard";
const widgetId = parser.getParam("widgetId"); const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl"); const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl; const isWidget = !!widgetId && !!parentUrl;
@@ -275,7 +285,7 @@ export const getUrlParams = (
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"), parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true), appPrompt: parser.getFlagParam("appPrompt", true),
preload: isWidget ? parser.getFlagParam("preload") : false, preload: isWidget ? parser.getFlagParam("preload") : false,
hideHeader: parser.getFlagParam("hideHeader"), header: header as "none" | "standard" | "app_bar",
showControls: parser.getFlagParam("showControls", true), showControls: parser.getFlagParam("showControls", true),
hideScreensharing: parser.getFlagParam("hideScreensharing"), hideScreensharing: parser.getFlagParam("hideScreensharing"),
e2eEnabled: parser.getFlagParam("enableE2EE", true), e2eEnabled: parser.getFlagParam("enableE2EE", true),

View File

@@ -37,12 +37,14 @@ import { Form } from "../form/Form";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { useUrlParams } from "../UrlParams";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
} }
export const RegisteredView: FC<Props> = ({ client }) => { export const RegisteredView: FC<Props> = ({ client }) => {
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
@@ -114,14 +116,16 @@ export const RegisteredView: FC<Props> = ({ client }) => {
return ( return (
<> <>
<div className={commonStyles.container}> <div className={commonStyles.container}>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav> </LeftNav>
<UserMenuContainer /> <RightNav>
</RightNav> <UserMenuContainer />
</Header> </RightNav>
</Header>
)}
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Heading size="lg" weight="semibold"> <Heading size="lg" weight="semibold">

View File

@@ -34,9 +34,11 @@ import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { ExternalLink, Link } from "../button/Link"; import { ExternalLink, Link } from "../button/Link";
import { useUrlParams } from "../UrlParams";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
@@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => {
return ( return (
<> <>
<div className={commonStyles.container}> <div className={commonStyles.container}>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav hideMobile> </LeftNav>
<UserMenuContainer /> <RightNav hideMobile>
</RightNav> <UserMenuContainer />
</Header> </RightNav>
</Header>
)}
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Heading size="lg" weight="semibold"> <Heading size="lg" weight="semibold">

View File

@@ -45,6 +45,9 @@ layer(compound);
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --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); --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--background-gradient: url("graphics/backgroundGradient.svg"); --background-gradient: url("graphics/backgroundGradient.svg");
--call-view-overlay-layer: 1;
--call-view-header-footer-layer: 2;
} }
:root, :root,

View File

@@ -25,6 +25,7 @@ import { LinkButton } from "../button";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
hideHeader: boolean;
confineToRoom: boolean; confineToRoom: boolean;
endedCallId: string; endedCallId: string;
} }
@@ -32,6 +33,7 @@ interface Props {
export const CallEndedView: FC<Props> = ({ export const CallEndedView: FC<Props> = ({
client, client,
isPasswordlessUser, isPasswordlessUser,
hideHeader,
confineToRoom, confineToRoom,
endedCallId, endedCallId,
}) => { }) => {
@@ -133,10 +135,12 @@ export const CallEndedView: FC<Props> = ({
return ( return (
<> <>
<Header> {!hideHeader && (
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav> <Header>
<RightNav /> <LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
</Header> <RightNav />
</Header>
)}
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}> <Heading size="xl" weight="semibold" className={styles.headline}>

View File

@@ -1,6 +1,6 @@
.overlay { .overlay {
position: fixed; position: fixed;
z-index: var(--overlay-layer); z-index: var(--call-view-overlay-layer);
inset: 0; inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -72,6 +72,7 @@ import {
} from "../settings/settings"; } from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
declare global { declare global {
interface Window { interface Window {
@@ -177,6 +178,7 @@ export const GroupCallView: FC<Props> = ({
}, [passwordFromUrl, room.roomId]); }, [passwordFromUrl, room.roomId]);
usePageTitle(roomName); usePageTitle(roomName);
useAppBarTitle(roomName);
const matrixInfo = useMemo((): MatrixInfo => { const matrixInfo = useMemo((): MatrixInfo => {
return { return {
@@ -473,6 +475,7 @@ export const GroupCallView: FC<Props> = ({
endedCallId={rtcSession.room.roomId} endedCallId={rtcSession.room.roomId}
client={client} client={client}
isPasswordlessUser={isPasswordlessUser} isPasswordlessUser={isPasswordlessUser}
hideHeader={hideHeader}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
/> />
); );

View File

@@ -12,15 +12,13 @@ Please see LICENSE in the repository root for full details.
width: 100%; width: 100%;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
--overlay-layer: 1;
--header-footer-layer: 2;
} }
.header { .header {
position: sticky; position: sticky;
flex-shrink: 0; flex-shrink: 0;
inset-block-start: 0; inset-block-start: 0;
z-index: var(--header-footer-layer); z-index: var(--call-view-header-footer-layer);
background: linear-gradient( background: linear-gradient(
0deg, 0deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
@@ -36,7 +34,7 @@ Please see LICENSE in the repository root for full details.
.footer { .footer {
position: sticky; position: sticky;
inset-block-end: 0; inset-block-end: 0;
z-index: var(--header-footer-layer); z-index: var(--call-view-header-footer-layer);
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(0, var(--inline-content-inset)) minmax(0, var(--inline-content-inset))

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { RoomContext, useLocalParticipant } from "@livekit/components-react"; 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 { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { import {
@@ -21,6 +21,7 @@ import {
useRef, useRef,
useState, useState,
type JSX, type JSX,
type ReactNode,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
@@ -29,6 +30,10 @@ import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
EarpieceIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -107,6 +112,8 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarSecondaryButton } from "../AppBar.tsx";
import { useTranslation } from "react-i18next";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -205,7 +212,7 @@ export interface InCallViewProps {
participantCount: number; participantCount: number;
/** Function to call when the user explicitly ends the call */ /** Function to call when the user explicitly ends the call */
onLeave: () => void; onLeave: () => void;
hideHeader: boolean; header: "none" | "standard" | "app_bar";
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState; connState: ECConnectionState;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
@@ -220,10 +227,11 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates, muteStates,
participantCount, participantCount,
onLeave, onLeave,
hideHeader, header: headerStyle,
connState, connState,
onShareClick, onShareClick,
}) => { }) => {
const { t } = useTranslation();
const { supportsReactions, sendReaction, toggleRaisedHand } = const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender(); useReactionsSender();
@@ -304,6 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showHeader = useObservableEagerState(vm.showHeader$); const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$); const showFooter = useObservableEagerState(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$); const earpieceMode = useObservableEagerState(vm.earpieceMode$);
const toggleEarpieceMode = useObservableEagerState(vm.toggleEarpieceMode$);
const switchCamera = useSwitchCamera(vm.localVideo$); const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking // Ideally we could detect taps by listening for click events and checking
@@ -446,6 +455,69 @@ export const InCallView: FC<InCallViewProps> = ({
} }
}, [setGridMode]); }, [setGridMode]);
useAppBarSecondaryButton(
useMemo(() => {
if (toggleEarpieceMode === null) return null;
const Icon = earpieceMode ? EarpieceIcon : VolumeOnSolidIcon;
return (
<Tooltip
label={
earpieceMode
? t("settings.devices.earpiece")
: t("settings.devices.loudspeaker")
}
>
<IconButton
onClick={(e) => {
e.preventDefault();
toggleEarpieceMode();
}}
>
<Icon />
</IconButton>
</Tooltip>
);
}, [t, earpieceMode, toggleEarpieceMode]),
);
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 = (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case "standard":
header = (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
);
}
}
const Tile = useMemo( const Tile = useMemo(
() => () =>
forwardRef< forwardRef<
@@ -532,7 +604,8 @@ export const InCallView: FC<InCallViewProps> = ({
key="fixed" key="fixed"
className={styles.fixedGrid} className={styles.fixedGrid}
style={{ style={{
insetBlockStart: headerBounds.bottom, insetBlockStart:
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
height: gridBounds.height, height: gridBounds.height,
}} }}
model={layout} model={layout}
@@ -655,10 +728,11 @@ export const InCallView: FC<InCallViewProps> = ({
ref={footerRef} ref={footerRef}
className={classNames(styles.footer, { className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat", [styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader), [styles.hidden]:
!showFooter || (!showControls && headerStyle === "none"),
})} })}
> >
{!hideHeader && ( {headerStyle !== "none" && (
<div className={styles.logo}> <div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden /> <LogoMark width={24} height={24} aria-hidden />
<LogoType <LogoType
@@ -694,35 +768,7 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerOut={onPointerOut} onPointerOut={onPointerOut}
> >
{showHeader && {header}
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
) : (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
))}
{ {
// TODO: remove this once we remove the developer flag gets removed and we have shipped to // TODO: remove this once we remove the developer flag gets removed and we have shipped to
// device transport as the default. // device transport as the default.

View File

@@ -19,8 +19,10 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { ExternalLink, Link } from "../button/Link"; import { ExternalLink, Link } from "../button/Link";
import { useUrlParams } from "../UrlParams";
export const RoomAuthView: FC = () => { export const RoomAuthView: FC = () => {
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
@@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => {
return ( return (
<> <>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav> </LeftNav>
<UserMenuContainer preventNavigation /> <RightNav>
</RightNav> <UserMenuContainer preventNavigation />
</Header> </RightNav>
</Header>
)}
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}> <Heading size="xl" weight="semibold" className={styles.headline}>

View File

@@ -43,14 +43,8 @@ import { ErrorView } from "../ErrorView";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
confineToRoom, useUrlParams();
appPrompt,
preload,
hideHeader,
displayName,
skipLobby,
} = useUrlParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier(); const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -120,7 +114,7 @@ export const RoomPage: FC = () => {
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
preload={preload} preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current} skipLobby={skipLobby || wasInWaitForInviteState.current}
hideHeader={hideHeader} hideHeader={header !== "standard"}
muteStates={muteStates} muteStates={muteStates}
/> />
); );
@@ -161,7 +155,7 @@ export const RoomPage: FC = () => {
enterLabel={label} enterLabel={label}
waitingForInvite={groupCallState.kind === "waitForInvite"} waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={header !== "standard"}
participantCount={null} participantCount={null}
muteStates={muteStates} muteStates={muteStates}
onShareClick={null} onShareClick={null}

View File

@@ -94,6 +94,9 @@ export const DeviceSelection: FC<Props> = ({
</Trans> </Trans>
); );
break; break;
case "speaker":
labelText = t("settings.devices.loudspeaker");
break;
case "earpiece": case "earpiece":
labelText = t("settings.devices.earpiece"); labelText = t("settings.devices.earpiece");
break; break;

View File

@@ -1250,7 +1250,40 @@ export class CallViewModel extends ViewModel {
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
*/ */
public readonly earpieceMode$ = this.mediaDevices.earpieceMode$; public readonly earpieceMode$: Observable<boolean> = combineLatest(
[
this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$,
],
(available, selected) =>
selected !== undefined && available.get(selected.id)?.type === "earpiece",
).pipe(this.scope.state());
/**
* Callback to toggle between the earpiece and the loudspeaker.
*/
public readonly toggleEarpieceMode$: Observable<(() => void) | null> =
combineLatest(
[
this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$,
],
(available, selected) => {
const selectionType = selected && available.get(selected.id)?.type;
if (!(selectionType === "speaker" || selectionType === "earpiece"))
return null;
const newSelectionType =
selectionType === "speaker" ? "earpiece" : "speaker";
const newSelection = [...available].find(
([, d]) => d.type === newSelectionType,
);
if (newSelection === undefined) return null;
const [id] = newSelection;
return () => this.mediaDevices.audioOutput.select(id);
},
);
public readonly reactions$ = this.reactionsSubject$.pipe( public readonly reactions$ = this.reactionsSubject$.pipe(
map((v) => map((v) =>

View File

@@ -15,7 +15,6 @@ import {
startWith, startWith,
Subject, Subject,
switchMap, switchMap,
withLatestFrom,
type Observable, type Observable,
} from "rxjs"; } from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core"; import { createMediaDeviceObserver } from "@livekit/components-core";
@@ -31,7 +30,6 @@ import { type ObservableScope } from "./ObservableScope";
import { import {
outputDevice$ as controlledOutputSelection$, outputDevice$ as controlledOutputSelection$,
availableOutputDevices$ as controlledAvailableOutputDevices$, availableOutputDevices$ as controlledAvailableOutputDevices$,
earpieceModeToggle$,
} from "../controls"; } from "../controls";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
@@ -41,10 +39,13 @@ const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel = export type DeviceLabel =
| { type: "name"; name: string } | { type: "name"; name: string }
| { type: "number"; number: number } | { type: "number"; number: number };
| { type: "default"; name: string | null };
export type AudioOutputDeviceLabel = DeviceLabel | { type: "earpiece" }; export type AudioOutputDeviceLabel =
| DeviceLabel
| { type: "speaker" }
| { type: "earpiece" }
| { type: "default"; name: string | null };
export interface SelectedDevice { export interface SelectedDevice {
id: string; id: string;
@@ -195,7 +196,8 @@ class AudioOutput
this.scope, this.scope,
).pipe( ).pipe(
map((availableRaw) => { map((availableRaw) => {
const available = buildDeviceMap(availableRaw); const available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one. // Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId // Its device ID must be the empty string because that's what setSinkId
// recognizes. // recognizes.
@@ -249,7 +251,7 @@ class ControlledAudioOutput
let deviceLabel: AudioOutputDeviceLabel; let deviceLabel: AudioOutputDeviceLabel;
// if (isExternalHeadset) // Do we want this? // if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" }; if (isEarpiece) deviceLabel = { type: "earpiece" };
else if (isSpeaker) deviceLabel = { type: "default", name }; else if (isSpeaker) deviceLabel = { type: "speaker" };
else deviceLabel = { type: "name", name }; else deviceLabel = { type: "name", name };
return [id, deviceLabel]; return [id, deviceLabel];
}, },
@@ -364,31 +366,5 @@ export class MediaDevices {
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> = public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope); new VideoInput(this.usingNames$, this.scope);
/** public constructor(private readonly scope: ObservableScope) {}
* Whether audio is currently being output through the earpiece.
*/
public readonly earpieceMode$: Observable<boolean> = combineLatest(
[this.audioOutput.available$, this.audioOutput.selected$],
(available, selected) =>
selected !== undefined && available.get(selected.id)?.type === "earpiece",
).pipe(this.scope.state());
public constructor(private readonly scope: ObservableScope) {
earpieceModeToggle$
.pipe(
withLatestFrom(
this.audioOutput.available$,
this.earpieceMode$,
(_toggle, available, earpieceMode) =>
// Determine the new device ID to switch to
[...available].find(
([, d]) => (d.type === "earpiece") !== earpieceMode,
)?.[0],
),
this.scope.bind(),
)
.subscribe((newSelection) => {
if (newSelection !== undefined) this.audioOutput.select(newSelection);
});
}
} }