diff --git a/docs/url-params.md b/docs/url-params.md index 27f8f579..bc3846cd 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -56,7 +56,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | | `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | | `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | -| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. | +| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | | `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | | `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | | `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. | diff --git a/locales/en/app.json b/locales/en/app.json index 685b3c4a..8ebba69e 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -181,6 +181,7 @@ "default": "Default", "default_named": "Default <2>({{name}})", "earpiece": "Earpiece", + "loudspeaker": "Loudspeaker", "microphone": "Microphone", "microphone_numbered": "Microphone {{n}}", "speaker": "Speaker", diff --git a/src/App.tsx b/src/App.tsx index 72def586..5d88e2d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, Suspense, useEffect, useState } from "react"; +import { + type FC, + type JSX, + Suspense, + useEffect, + useMemo, + useState, +} from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -24,6 +31,8 @@ import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; +import { getUrlParams } from "./UrlParams"; +import { AppBar } from "./AppBar"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -67,41 +76,39 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); + // Since we are outside the router component, we cannot use useUrlParams here + const { header } = useMemo(getUrlParams, []); + + const content = loaded ? ( + + + + } + > + + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); + return ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - {loaded ? ( - - - - - ( - - )} - > - - - } /> - } /> - } - /> - } /> - - - - - - - ) : ( - - )} + + {header === "app_bar" ? {content} : content} + diff --git a/src/AppBar.module.css b/src/AppBar.module.css new file mode 100644 index 00000000..805b7802 --- /dev/null +++ b/src/AppBar.module.css @@ -0,0 +1,23 @@ +.bar { + block-size: 64px; + flex-shrink: 0; +} + +.bar > header { + position: absolute; + inset-inline: 0; + inset-block-start: 0; + block-size: 64px; + z-index: var(--call-view-header-footer-layer); +} + +.bar svg { + color: var(--cpd-color-icon-primary); +} + +.bar > header > h1 { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/AppBar.tsx b/src/AppBar.tsx new file mode 100644 index 00000000..e17f9e17 --- /dev/null +++ b/src/AppBar.tsx @@ -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(null); + +interface Props { + children: ReactNode; +} + +/** + * A "top app bar" featuring a back button, title and possibly a secondary + * button, similar to what you might see in mobile apps. + */ +export const AppBar: FC = ({ children }) => { + const { t } = useTranslation(); + const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon; + const onBackClick = useCallback((e: MouseEvent) => { + e.preventDefault(); + window.controls.onBackButtonPressed?.(); + }, []); + + const [title, setTitle] = useState(""); + const [secondaryButton, setSecondaryButton] = useState(null); + const context = useMemo( + () => ({ setTitle, setSecondaryButton }), + [setTitle, setSecondaryButton], + ); + + return ( + <> +
+
+ + + + + + + + {title && ( + + {title} + + )} + {secondaryButton} +
+
+ + {children} + + + ); +}; + +/** + * React hook which sets the title to be shown in the app bar, if present. It is + * an error to call this hook from multiple sites in the same component tree. + */ +export function useAppBarTitle(title: string): void { + const setTitle = 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]); +} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 6e840cc6..41e6cb16 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -28,10 +28,10 @@ export const FullScreenView: FC = ({ className, children, }) => { - const { hideHeader } = useUrlParams(); + const { header } = useUrlParams(); return (
- {!hideHeader && ( + {header === "standard" && (
diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 17e169d9..acda491c 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -59,9 +59,12 @@ export interface UrlParams { */ preload: boolean; /** - * Whether to hide the room header when in a call. + * The style of headers to show. "standard" is the default arrangement, "none" + * hides the header entirely, and "app_bar" produces a header with a back + * button like you might see in mobile apps. The callback for the back button + * is window.controls.onBackButtonPressed. */ - hideHeader: boolean; + header: "none" | "standard" | "app_bar"; /** * 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)) { 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 parentUrl = parser.getParam("parentUrl"); const isWidget = !!widgetId && !!parentUrl; @@ -275,7 +285,7 @@ export const getUrlParams = ( parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"), appPrompt: parser.getFlagParam("appPrompt", true), preload: isWidget ? parser.getFlagParam("preload") : false, - hideHeader: parser.getFlagParam("hideHeader"), + header: header as "none" | "standard" | "app_bar", showControls: parser.getFlagParam("showControls", true), hideScreensharing: parser.getFlagParam("hideScreensharing"), e2eEnabled: parser.getFlagParam("enableE2EE", true), diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index af2d5f26..361160c5 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -37,12 +37,14 @@ import { Form } from "../form/Form"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; +import { useUrlParams } from "../UrlParams"; interface Props { client: MatrixClient; } export const RegisteredView: FC = ({ client }) => { + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -114,14 +116,16 @@ export const RegisteredView: FC = ({ client }) => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index e23c637b..6e05bc34 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -34,9 +34,11 @@ import { Config } from "../config/Config"; import { E2eeType } from "../e2ee/e2eeType"; import { useOptInAnalytics } from "../settings/settings"; import { ExternalLink, Link } from "../button/Link"; +import { useUrlParams } from "../UrlParams"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); @@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => { return ( <>
-
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/index.css b/src/index.css index 883481b1..46162c9e 100644 --- a/src/index.css +++ b/src/index.css @@ -45,6 +45,9 @@ layer(compound); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); --background-gradient: url("graphics/backgroundGradient.svg"); + + --call-view-overlay-layer: 1; + --call-view-header-footer-layer: 2; } :root, diff --git a/src/room/CallEndedView.tsx b/src/room/CallEndedView.tsx index 43aa96e2..4df3f297 100644 --- a/src/room/CallEndedView.tsx +++ b/src/room/CallEndedView.tsx @@ -25,6 +25,7 @@ import { LinkButton } from "../button"; interface Props { client: MatrixClient; isPasswordlessUser: boolean; + hideHeader: boolean; confineToRoom: boolean; endedCallId: string; } @@ -32,6 +33,7 @@ interface Props { export const CallEndedView: FC = ({ client, isPasswordlessUser, + hideHeader, confineToRoom, endedCallId, }) => { @@ -133,10 +135,12 @@ export const CallEndedView: FC = ({ return ( <> -
- {!confineToRoom && } - -
+ {!hideHeader && ( +
+ {!confineToRoom && } + +
+ )}
diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css index f6b8464a..6122a22b 100644 --- a/src/room/EarpieceOverlay.module.css +++ b/src/room/EarpieceOverlay.module.css @@ -1,6 +1,6 @@ .overlay { position: fixed; - z-index: var(--overlay-layer); + z-index: var(--call-view-overlay-layer); inset: 0; display: flex; flex-direction: column; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index b981bdd6..e4db30a4 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -72,6 +72,7 @@ import { } from "../settings/settings"; import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; +import { useAppBarTitle } from "../AppBar.tsx"; declare global { interface Window { @@ -177,6 +178,7 @@ export const GroupCallView: FC = ({ }, [passwordFromUrl, room.roomId]); usePageTitle(roomName); + useAppBarTitle(roomName); const matrixInfo = useMemo((): MatrixInfo => { return { @@ -473,6 +475,7 @@ export const GroupCallView: FC = ({ endedCallId={rtcSession.room.roomId} client={client} isPasswordlessUser={isPasswordlessUser} + hideHeader={hideHeader} confineToRoom={confineToRoom} /> ); diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 8275e094..24dfbe5c 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -12,15 +12,13 @@ Please see LICENSE in the repository root for full details. width: 100%; overflow-x: hidden; overflow-y: auto; - --overlay-layer: 1; - --header-footer-layer: 2; } .header { position: sticky; flex-shrink: 0; inset-block-start: 0; - z-index: var(--header-footer-layer); + z-index: var(--call-view-header-footer-layer); background: linear-gradient( 0deg, rgba(0, 0, 0, 0) 0%, @@ -36,7 +34,7 @@ Please see LICENSE in the repository root for full details. .footer { position: sticky; inset-block-end: 0; - z-index: var(--header-footer-layer); + z-index: var(--call-view-header-footer-layer); display: grid; grid-template-columns: minmax(0, var(--inline-content-inset)) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a9492930..56c0bbc2 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { RoomContext, useLocalParticipant } from "@livekit/components-react"; -import { Text } from "@vector-im/compound-web"; +import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; import { ConnectionState, type Room } from "livekit-client"; import { type MatrixClient } from "matrix-js-sdk"; import { @@ -21,6 +21,7 @@ import { useRef, useState, type JSX, + type ReactNode, } from "react"; import useMeasure from "react-use-measure"; 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 { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; +import { + EarpieceIcon, + VolumeOnSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import LogoMark from "../icons/LogoMark.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 { useMediaDevices } from "../MediaDevicesContext.ts"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; +import { useAppBarSecondaryButton } from "../AppBar.tsx"; +import { useTranslation } from "react-i18next"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -205,7 +212,7 @@ export interface InCallViewProps { participantCount: number; /** Function to call when the user explicitly ends the call */ onLeave: () => void; - hideHeader: boolean; + header: "none" | "standard" | "app_bar"; otelGroupCallMembership?: OTelGroupCallMembership; connState: ECConnectionState; onShareClick: (() => void) | null; @@ -220,10 +227,11 @@ export const InCallView: FC = ({ muteStates, participantCount, onLeave, - hideHeader, + header: headerStyle, connState, onShareClick, }) => { + const { t } = useTranslation(); const { supportsReactions, sendReaction, toggleRaisedHand } = useReactionsSender(); @@ -304,6 +312,7 @@ export const InCallView: FC = ({ const showHeader = useObservableEagerState(vm.showHeader$); const showFooter = useObservableEagerState(vm.showFooter$); const earpieceMode = useObservableEagerState(vm.earpieceMode$); + const toggleEarpieceMode = useObservableEagerState(vm.toggleEarpieceMode$); const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking @@ -446,6 +455,69 @@ export const InCallView: FC = ({ } }, [setGridMode]); + useAppBarSecondaryButton( + useMemo(() => { + if (toggleEarpieceMode === null) return null; + const Icon = earpieceMode ? EarpieceIcon : VolumeOnSolidIcon; + return ( + + { + e.preventDefault(); + toggleEarpieceMode(); + }} + > + + + + ); + }, [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 = ( +
+ ); + break; + case "standard": + header = ( +
+ + + + + {showControls && onShareClick !== null && ( + + )} + +
+ ); + } + } + const Tile = useMemo( () => forwardRef< @@ -532,7 +604,8 @@ export const InCallView: FC = ({ key="fixed" className={styles.fixedGrid} style={{ - insetBlockStart: headerBounds.bottom, + insetBlockStart: + headerBounds.height > 0 ? headerBounds.bottom : bounds.top, height: gridBounds.height, }} model={layout} @@ -655,10 +728,11 @@ export const InCallView: FC = ({ ref={footerRef} className={classNames(styles.footer, { [styles.overlay]: windowMode === "flat", - [styles.hidden]: !showFooter || (!showControls && hideHeader), + [styles.hidden]: + !showFooter || (!showControls && headerStyle === "none"), })} > - {!hideHeader && ( + {headerStyle !== "none" && (
= ({ onPointerMove={onPointerMove} onPointerOut={onPointerOut} > - {showHeader && - (hideHeader ? ( - // Cosmetic header to fill out space while still affecting the bounds - // of the grid -
- ) : ( -
- - - - - {showControls && onShareClick !== null && ( - - )} - -
- ))} + {header} { // TODO: remove this once we remove the developer flag gets removed and we have shipped to // device transport as the default. diff --git a/src/room/RoomAuthView.tsx b/src/room/RoomAuthView.tsx index 509460e9..cfc99e82 100644 --- a/src/room/RoomAuthView.tsx +++ b/src/room/RoomAuthView.tsx @@ -19,8 +19,10 @@ import { UserMenuContainer } from "../UserMenuContainer"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { Config } from "../config/Config"; import { ExternalLink, Link } from "../button/Link"; +import { useUrlParams } from "../UrlParams"; export const RoomAuthView: FC = () => { + const { header } = useUrlParams(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => { return ( <> -
- - - - - - -
+ {header === "standard" && ( +
+ + + + + + +
+ )}
diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index f502407c..31a5995a 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -43,14 +43,8 @@ import { ErrorView } from "../ErrorView"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; export const RoomPage: FC = () => { - const { - confineToRoom, - appPrompt, - preload, - hideHeader, - displayName, - skipLobby, - } = useUrlParams(); + const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } = + useUrlParams(); const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -120,7 +114,7 @@ export const RoomPage: FC = () => { confineToRoom={confineToRoom} preload={preload} skipLobby={skipLobby || wasInWaitForInviteState.current} - hideHeader={hideHeader} + hideHeader={header !== "standard"} muteStates={muteStates} /> ); @@ -161,7 +155,7 @@ export const RoomPage: FC = () => { enterLabel={label} waitingForInvite={groupCallState.kind === "waitForInvite"} confineToRoom={confineToRoom} - hideHeader={hideHeader} + hideHeader={header !== "standard"} participantCount={null} muteStates={muteStates} onShareClick={null} diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 50972326..314a2e63 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -94,6 +94,9 @@ export const DeviceSelection: FC = ({ ); break; + case "speaker": + labelText = t("settings.devices.loudspeaker"); + break; case "earpiece": labelText = t("settings.devices.earpiece"); break; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 9b784f16..9caa0925 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -1250,7 +1250,40 @@ export class CallViewModel extends ViewModel { /** * Whether audio is currently being output through the earpiece. */ - public readonly earpieceMode$ = this.mediaDevices.earpieceMode$; + public readonly earpieceMode$: Observable = 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( map((v) => diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 7604ff77..66224bab 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -15,7 +15,6 @@ import { startWith, Subject, switchMap, - withLatestFrom, type Observable, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; @@ -31,7 +30,6 @@ import { type ObservableScope } from "./ObservableScope"; import { outputDevice$ as controlledOutputSelection$, availableOutputDevices$ as controlledAvailableOutputDevices$, - earpieceModeToggle$, } from "../controls"; import { getUrlParams } from "../UrlParams"; @@ -41,10 +39,13 @@ const EARPIECE_CONFIG_ID = "earpiece-id"; export type DeviceLabel = | { type: "name"; name: string } - | { type: "number"; number: number } - | { type: "default"; name: string | null }; + | { type: "number"; number: number }; -export type AudioOutputDeviceLabel = DeviceLabel | { type: "earpiece" }; +export type AudioOutputDeviceLabel = + | DeviceLabel + | { type: "speaker" } + | { type: "earpiece" } + | { type: "default"; name: string | null }; export interface SelectedDevice { id: string; @@ -195,7 +196,8 @@ class AudioOutput this.scope, ).pipe( map((availableRaw) => { - const available = buildDeviceMap(availableRaw); + const available: Map = + buildDeviceMap(availableRaw); // 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 // recognizes. @@ -249,7 +251,7 @@ class ControlledAudioOutput let deviceLabel: AudioOutputDeviceLabel; // if (isExternalHeadset) // Do we want this? if (isEarpiece) deviceLabel = { type: "earpiece" }; - else if (isSpeaker) deviceLabel = { type: "default", name }; + else if (isSpeaker) deviceLabel = { type: "speaker" }; else deviceLabel = { type: "name", name }; return [id, deviceLabel]; }, @@ -364,31 +366,5 @@ export class MediaDevices { public readonly videoInput: MediaDevice = new VideoInput(this.usingNames$, this.scope); - /** - * Whether audio is currently being output through the earpiece. - */ - public readonly earpieceMode$: Observable = 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); - }); - } + public constructor(private readonly scope: ObservableScope) {} }