mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
WIP: Add app bar and earpiece toggle button
This commit is contained in:
@@ -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`. |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
69
src/App.tsx
69
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.
|
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
23
src/AppBar.module.css
Normal 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
117
src/AppBar.tsx
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user