Compare commits

..

10 Commits

Author SHA1 Message Date
Timo
c3eb253059 Fix bluetooth iOS issue (device auto switches from Bluetooth to speaker) (#3383)
* no onAudioDeviceSelect

Signed-off-by: Timo K <toger5@hotmail.de>

* dont restart audio track on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* skip syncing audio input on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* dont pass audio capture defaults when muting unmuting

Signed-off-by: Timo K <toger5@hotmail.de>

* logging on set sink id

Signed-off-by: Timo K <toger5@hotmail.de>

* dont use controlled on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* always show native button on ios

Signed-off-by: Timo K <toger5@hotmail.de>

* revert "dont use controlled on ios"

Signed-off-by: Timo K <toger5@hotmail.de>

* use "" instead of undefined for audio output default and on syncDevice + logging

Signed-off-by: Timo K <toger5@hotmail.de>

* use logger warn

Signed-off-by: Timo K <toger5@hotmail.de>

* dont ignore all sync logic

Signed-off-by: Timo K <toger5@hotmail.de>

* use "default" instead of ""

Signed-off-by: Timo K <toger5@hotmail.de>

* use non controlled audio output again

Signed-off-by: Timo K <toger5@hotmail.de>

* controlled with syncing but no output syncing

Signed-off-by: Timo K <toger5@hotmail.de>

* subscribe to available raw output in controlled case

Signed-off-by: Timo K <toger5@hotmail.de>

* simplification but same solution

Signed-off-by: Timo K <toger5@hotmail.de>

* try "mute with audio capture defaults" again

Signed-off-by: Timo K <toger5@hotmail.de>

* use current device for mute unmute

Signed-off-by: Timo K <toger5@hotmail.de>

* add back restart

Signed-off-by: Timo K <toger5@hotmail.de>

* use default device for unmute again

Signed-off-by: Timo K <toger5@hotmail.de>

* no defaults at all

Signed-off-by: Timo K <toger5@hotmail.de>

* device names request after unmute

Signed-off-by: Timo K <toger5@hotmail.de>

* another approach for mute state request

Signed-off-by: Timo K <toger5@hotmail.de>

* more comment

Signed-off-by: Timo K <toger5@hotmail.de>

* reduce changes and switch input device on sync device

Signed-off-by: Timo K <toger5@hotmail.de>

* more cleanup

Signed-off-by: Timo K <toger5@hotmail.de>

* fix lints

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-07-03 17:14:09 +02:00
Timo
ca06aa82b2 Backport: Update earpiece overlay button text for clarity (#3370)
* Update earpiece overlay button text for clarity

* update test snapshots
2025-07-01 17:06:54 +02:00
Timo
20433e6935 Backport: Increase opacity of the EarpieceOverlay background for better visibility (#3369) 2025-07-01 17:06:21 +02:00
Timo
0e5a79e896 Update dependency livekit-client to v2.14.0 (#3371) (#3374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 12:28:38 +02:00
ElementRobot
a80c7aa0e2 Merge pull request #3367 from element-hq/actions/localazy-download
Localazy Download
2025-06-26 22:01:48 +02:00
fkwp
42d905df7f Translations updates 2025-06-26 20:01:42 +00:00
Robin
f509c06cc6 Earpiece switcher and overlay (#3347)
* Add a global control for toggling earpiece mode

This will be used by Element X to show an earpiece toggle button in the header.

* Add an earpiece overlay


* Fix header
The header needs to be passed forward as a string to some components and as a bool (hideHeader) to others.
Also use a enum instead of string options.

* fix top clipping with header


* hide app bar in pip

* revert android overlay app_bar

* Modernize AppBarContext

* Style header icon color as desired and switch earpice/speaker icon

* fix initial selection when using controlled media

* Add "Back to video" button

* fix tests

* remove dead code

* add snapshot test

* fix back to video button

* Request capability to learn the room name

We now need the room name in order to implement the mobile (widget-based) designs with the app bar.

* Test the CallViewModel output switcher directly

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-06-26 11:08:57 +02:00
Robin
c012aec909 Merge pull request #3353 from element-hq/toger5/device-permissions-request-possible-fix
Skip unnecassary media devices permissions requests (video feed flicker when opening settings)
2025-06-25 15:56:10 -04:00
Robin
db948cb718 Merge pull request #3364 from element-hq/renovate/livekit-components
Update LiveKit components
2025-06-25 15:49:25 -04:00
renovate[bot]
93cb7955c9 Update LiveKit components 2025-06-25 18:41:41 +00:00
43 changed files with 1042 additions and 174 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. |
| `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`. |

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
"crypto_version": "Kryptografická verze: {{version}}",
"debug_tile_layout_label": "Ladění rozložení dlaždic",
"device_id": "ID zařízení: {{id}}",
@@ -173,8 +174,10 @@
"devices": {
"camera": "Fotoaparát",
"camera_numbered": "Fotoaparát {{n}}",
"change_device_button": "Změnit zvukové zařízení",
"default": "Výchozí",
"default_named": "Výchozí <2> ({{name}}) </2>",
"earpiece": "Sluchátka",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
"crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
"show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre",
@@ -164,12 +166,17 @@
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter"
},
"background_blur_header": "Baggrund",
"background_blur_label": "Gør videoens baggrund sløret",
"blur_not_supported_by_browser": "(Baggrundssløring understøttes ikke af denne enhed.)",
"developer_tab_title": "Udvikler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Skift lydenhed",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"earpiece": "Høretelefon",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler",

View File

@@ -79,10 +79,15 @@
"use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen."
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"earpiece": {
"overlay_back_button": "Zurück zum Lautsprechermodus",
"overlay_description": "Nur bei App im Vordergrund nutzbar",
"overlay_title": "Ohrhörer Modus"
},
"error": {
"call_is_not_supported": "Anrufe werden nicht unterstützt",
"call_not_found": "Anruf nicht gefunden",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen</1>. </0>",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <2> erstellen Sie einen neuen</2>. </0>",
"connection_lost": "Verbindung verloren",
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser",
@@ -173,9 +178,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Audiogerät wechseln",
"default": "Standard",
"default_named": "Standard<2> ({{name}} )</2>",
"earpiece": "Ohrhörer",
"loudspeaker": "Lautsprecher",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon{{n}}",
"speaker": "Lautsprecher",

View File

@@ -79,10 +79,15 @@
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"earpiece": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Earpiece Mode"
},
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
"connection_lost": "Connection lost",
"connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser",
@@ -177,6 +182,7 @@
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"earpiece": "Earpiece",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
"crypto_version": "Krüptoteekide versioon: {{version}}",
"debug_tile_layout_label": "Meediapaanide paigutus",
"device_id": "Seadme tunnus: {{id}}",
@@ -78,10 +79,15 @@
"use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust"
},
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
"earpiece": {
"overlay_back_button": "Tagasi videovaatesse",
"overlay_description": "Toimib vaid rakenduse kasutamise ajal",
"overlay_title": "Toimib vaid kuularitega"
},
"error": {
"call_is_not_supported": "Kõne pole toetatud",
"call_not_found": "Kõnet ei leidu",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus</1>.</0>",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus</2>.</0>",
"connection_lost": "Ühendus on katkenud",
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser",
@@ -172,8 +178,11 @@
"devices": {
"camera": "Kaamera",
"camera_numbered": "Kaamera {{n}}",
"change_device_button": "Muuda heliseadet",
"default": "Vaikimisi",
"default_named": "Vaikimisi <2>({{name}})</2>",
"earpiece": "Kuular",
"loudspeaker": "Valjuhääldi",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Kõlar",

View File

@@ -61,6 +61,7 @@
"video": "Видео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"url_params": "Параметры URL-адреса",
@@ -172,8 +174,10 @@
"devices": {
"camera": "Камера",
"camera_numbered": "Камера {{n}}",
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"earpiece": "Наушник",
"microphone": "Микрофон",
"microphone_numbered": "Микрофон {{n}}",
"speaker": "Динамик",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
"crypto_version": "Krypto verzia: {{version}}",
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
"device_id": "ID zariadenia: {{id}}",
@@ -78,10 +79,15 @@
"use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti"
},
"disconnected_banner": "Spojenie so serverom sa stratilo.",
"earpiece": {
"overlay_back_button": "Späť na video",
"overlay_description": "Funguje iba pri používaní aplikácie",
"overlay_title": "Režim iba so slúchadlami"
},
"error": {
"call_is_not_supported": "Hovor nie je podporovaný",
"call_not_found": "Hovor nebol nájdený",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový</1>. </0>",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový</2>.</0>",
"connection_lost": "Strata spojenia",
"connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač",
@@ -173,8 +179,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Zmeniť zvukové zariadenie",
"default": "Predvolené",
"default_named": "Predvolené <2>({{name}})</2>",
"earpiece": "Slúchadlo",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofón",
"microphone_numbered": "Mikrofón {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
"crypto_version": "Kryptoversion: {{version}}",
"debug_tile_layout_label": "Felsök panelarrangemang",
"device_id": "Enhets-ID: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit-serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
"show_connection_stats": "Visa anslutningsstatistik",
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
"url_params": "URL-parametrar",
@@ -164,12 +166,17 @@
"effect_volume_description": "Justera volymen vid vilken reaktioner och handuppräckningseffekter spelas",
"effect_volume_label": "Ljudeffektsvolym"
},
"background_blur_header": "Bakgrund",
"background_blur_label": "Gör bakgrunden i videon suddig",
"blur_not_supported_by_browser": "(Bakgrundssuddighet stöds inte av den här enheten.)",
"developer_tab_title": "Utvecklare",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Byt ljudenhet",
"default": "Förval",
"default_named": "Förval <2>({{name}})</2>",
"earpiece": "Hörsnäcka",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Högtalare",

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.
*/
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -24,6 +31,8 @@ import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -67,41 +76,43 @@ export const App: FC<Props> = ({ vm }) => {
.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 (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<BrowserRouter>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
{loaded ? (
<Suspense fallback={null}>
<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>
</Suspense>
) : (
<LoadingPage />
)}
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
</Suspense>
</TooltipProvider>
</ThemeProvider>
</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 path {
fill: var(--cpd-color-icon-primary);
}
.bar > header > h1 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

25
src/AppBar.test.tsx Normal file
View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar } from "./AppBar";
describe("AppBar", () => {
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
});

134
src/AppBar.tsx Normal file
View File

@@ -0,0 +1,134 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
createContext,
type FC,
type MouseEvent,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import {
ArrowLeftIcon,
CollapseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
import styles from "./AppBar.module.css";
interface AppBarContext {
setTitle: (value: string) => void;
setSecondaryButton: (value: ReactNode) => void;
setHidden: (value: boolean) => void;
}
const AppBarContext = createContext<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 [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const context = useMemo(
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
);
return (
<>
<div
style={{ display: hidden ? "none" : "block" }}
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 value={context}>{children}</AppBarContext>
</>
);
};
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarTitle(title: string): void {
const setTitle = use(AppBarContext)?.setTitle;
useEffect(() => {
if (setTitle !== undefined) {
setTitle(title);
return (): void => setTitle("");
}
}, [title, setTitle]);
}
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarHidden(hidden: boolean): void {
const setHidden = use(AppBarContext)?.setHidden;
useEffect(() => {
if (setHidden !== undefined) {
setHidden(hidden);
return (): void => setHidden(false);
}
}, [setHidden, hidden]);
}
/**
* React hook which sets the secondary button to be shown in the app bar, if
* present. It is an error to call this hook from multiple sites in the same
* component tree.
*/
export function useAppBarSecondaryButton(button: ReactNode): void {
const setSecondaryButton = use(AppBarContext)?.setSecondaryButton;
useEffect(() => {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
}
}, [button, setSecondaryButton]);
}

View File

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

View File

@@ -243,4 +243,16 @@ describe("UrlParams", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
});
it("converts hideHeader to the correct header value", () => {
expect(getUrlParams("?hideHeader=true").header).toBe("none");
expect(getUrlParams("?hideHeader=false").header).toBe("standard");
});
});
});

View File

@@ -25,6 +25,12 @@ export enum UserIntent {
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
@@ -59,9 +65,12 @@ export interface UrlParams {
*/
preload: boolean;
/**
* Whether to hide the room header when in a call.
* The style of headers to show. "standard" is the default arrangement, "none"
* hides the header entirely, and "app_bar" produces a header with a back
* button like you might see in mobile apps. The callback for the back button
* is window.controls.onBackButtonPressed.
*/
hideHeader: boolean;
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
@@ -257,6 +266,15 @@ export const getUrlParams = (
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
intent = UserIntent.Unknown;
}
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.
const header =
parser.getParam("header") ??
(parser.getFlagParam("hideHeader")
? HeaderStyle.None
: HeaderStyle.Standard);
const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl;
@@ -275,7 +293,7 @@ export const getUrlParams = (
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true),
preload: isWidget ? parser.getFlagParam("preload") : false,
hideHeader: parser.getFlagParam("hideHeader"),
header: header as HeaderStyle,
showControls: parser.getFlagParam("showControls", true),
hideScreensharing: parser.getFlagParam("hideScreensharing"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),

View File

@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
</button>
</div>
<div
class="nav rightNav"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
@@ -14,24 +14,25 @@ export interface Controls {
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
setAudioDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
setAudioEnabled(enabled: boolean): void;
showNativeAudioDevicePicker?: () => void;
onBackButtonPressed?: () => void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void;
showNativeAudioDevicePicker?: () => void;
onBackButtonPressed?: () => void;
}
export interface OutputDevice {
@@ -59,6 +60,7 @@ export const outputDevice$ = new Subject<string>();
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
*/
export const setAudioEnabled$ = new Subject<boolean>();
let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) {
@@ -66,6 +68,7 @@ export const setPlaybackStarted = (): void => {
window.controls.onAudioPlaybackStarted?.();
}
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -78,6 +81,7 @@ window.controls = {
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices);

View File

@@ -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<Props> = ({ client }) => {
const { header } = useUrlParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
@@ -114,14 +116,16 @@ export const RegisteredView: FC<Props> = ({ client }) => {
return (
<>
<div className={commonStyles.container}>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
{header === "standard" && (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
)}
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<Heading size="lg" weight="semibold">

View File

@@ -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<Error>();
const [optInAnalytics] = useOptInAnalytics();
@@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => {
return (
<>
<div className={commonStyles.container}>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav hideMobile>
<UserMenuContainer />
</RightNav>
</Header>
{header === "standard" && (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav hideMobile>
<UserMenuContainer />
</RightNav>
</Header>
)}
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<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);
--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,

View File

@@ -320,16 +320,18 @@ export function useLivekit(
useEffect(() => {
// Sync the requested devices with LiveKit's devices
if (
room !== undefined &&
connectionState === ConnectionState.Connected &&
!controlledAudioDevices
) {
if (room !== undefined && connectionState === ConnectionState.Connected) {
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
room.getActiveDevice(kind),
" !== ",
device?.id,
);
if (
device !== undefined &&
room.getActiveDevice(kind) !== device.id
@@ -344,7 +346,9 @@ export function useLivekit(
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("audiooutput", devices.audioOutput.selected$),
!controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this
@@ -384,7 +388,7 @@ export function useLivekit(
];
return (): void => {
for (const s of subscriptions) s.unsubscribe();
for (const s of subscriptions) s?.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -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<Props> = ({
client,
isPasswordlessUser,
hideHeader,
confineToRoom,
endedCallId,
}) => {
@@ -133,10 +135,12 @@ export const CallEndedView: FC<Props> = ({
return (
<>
<Header>
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
<RightNav />
</Header>
{!hideHeader && (
<Header>
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
<RightNav />
</Header>
)}
<div className={styles.container}>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>

View File

@@ -0,0 +1,63 @@
.overlay {
position: fixed;
z-index: var(--call-view-overlay-layer);
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay[data-show="true"] {
animation: fade-in 200ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
display: none;
}
}
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}
.overlay::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
background: var(--cpd-color-bg-canvas-default);
opacity: 0.94;
}
.icon {
margin-block-end: var(--cpd-space-4x);
background: var(--cpd-color-alpha-gray-600);
color: var(--cpd-color-icon-primary);
}
.overlay > h2 {
text-align: center;
margin: 0;
}
.overlay > p {
text-align: center;
}

View File

@@ -0,0 +1,42 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC } from "react";
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css";
interface Props {
show: boolean;
onBackToVideoPressed?: (() => void) | null;
}
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<EarpieceIcon aria-hidden />
</BigIcon>
<Heading as="h2" weight="semibold" size="md">
{t("earpiece.overlay_title")}
</Heading>
<Text>{t("earpiece.overlay_description")}</Text>
<Button
kind="primary"
size="sm"
onClick={() => {
onBackToVideoPressed?.();
}}
>
{t("earpiece.overlay_back_button")}
</Button>
</div>
);
};

View File

@@ -42,6 +42,7 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -157,7 +158,7 @@ function createGroupCallView(
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
header={HeaderStyle.Standard}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}

View File

@@ -51,7 +51,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
@@ -72,6 +72,7 @@ import {
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
declare global {
interface Window {
@@ -85,7 +86,7 @@ interface Props {
confineToRoom: boolean;
preload: boolean;
skipLobby: boolean;
hideHeader: boolean;
header: HeaderStyle;
rtcSession: MatrixRTCSession;
isJoined: boolean;
muteStates: MuteStates;
@@ -98,7 +99,7 @@ export const GroupCallView: FC<Props> = ({
confineToRoom,
preload,
skipLobby,
hideHeader,
header,
rtcSession,
isJoined,
muteStates,
@@ -187,6 +188,7 @@ export const GroupCallView: FC<Props> = ({
}, [passwordFromUrl, room.roomId]);
usePageTitle(roomName);
useAppBarTitle(roomName);
const matrixInfo = useMemo((): MatrixInfo => {
return {
@@ -431,7 +433,7 @@ export const GroupCallView: FC<Props> = ({
muteStates={muteStates}
onEnter={() => void enterRTCSessionOrError(rtcSession)}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
hideHeader={header === HeaderStyle.None}
participantCount={participantCount}
onShareClick={onShareClick}
/>
@@ -457,7 +459,7 @@ export const GroupCallView: FC<Props> = ({
rtcSession={rtcSession as MatrixRTCSession}
participantCount={participantCount}
onLeave={onLeave}
hideHeader={hideHeader}
header={header}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
@@ -483,6 +485,7 @@ export const GroupCallView: FC<Props> = ({
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom}
/>
);

View File

@@ -18,7 +18,7 @@ Please see LICENSE in the repository root for full details.
position: sticky;
flex-shrink: 0;
inset-block-start: 0;
z-index: 1;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
.footer {
position: sticky;
inset-block-end: 0;
z-index: 1;
z-index: var(--call-view-header-footer-layer);
display: grid;
grid-template-columns:
minmax(0, var(--inline-content-inset))

View File

@@ -47,6 +47,7 @@ import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
@@ -158,7 +159,7 @@ function createInCallView(): RenderResult & {
<RoomContext value={livekitRoom}>
<InCallView
client={client}
hideHeader={true}
header={HeaderStyle.Standard}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
vm={vm}
@@ -243,6 +244,7 @@ describe("InCallView", () => {
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if setting is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(false);
developerModeSetting.setValue(true);
@@ -254,6 +256,7 @@ describe("InCallView", () => {
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if developer mode is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(false);

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { Text } from "@vector-im/compound-web";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
import {
@@ -28,6 +28,11 @@ import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
EarpieceIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -42,7 +47,7 @@ import {
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useUrlParams } from "../UrlParams";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css";
@@ -104,6 +109,9 @@ import { useTypedEventEmitter } from "../useEvents.ts";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -115,6 +123,7 @@ export interface ActiveCallProps
}
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLivekit(
props.rtcSession,
@@ -155,6 +164,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const vm = new CallViewModel(
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
reactionsReader.raisedHands$,
@@ -166,7 +176,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.destroy();
};
}
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
}, [
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -194,7 +210,7 @@ export interface InCallViewProps {
participantCount: number;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
hideHeader: boolean;
header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
@@ -209,10 +225,11 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates,
participantCount,
onLeave,
hideHeader,
header: headerStyle,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender();
@@ -292,6 +309,8 @@ export const InCallView: FC<InCallViewProps> = ({
const gridMode = useObservableEagerState(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$);
const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking
@@ -434,6 +453,70 @@ export const InCallView: FC<InCallViewProps> = ({
}
}, [setGridMode]);
useAppBarSecondaryButton(
useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.earpiece")
: t("settings.devices.loudspeaker");
return (
<Tooltip label={label}>
<IconButton
onClick={(e) => {
e.preventDefault();
audioOutputSwitcher.switch();
}}
>
<Icon />
</IconButton>
</Tooltip>
);
}, [t, audioOutputSwitcher]),
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case "none":
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
<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(
() =>
function Tile({
@@ -521,7 +604,8 @@ export const InCallView: FC<InCallViewProps> = ({
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
insetBlockStart:
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
height: gridBounds.height,
}}
model={layout}
@@ -644,10 +728,11 @@ export const InCallView: FC<InCallViewProps> = ({
ref={footerRef}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader),
[styles.hidden]:
!showFooter || (!showControls && headerStyle === "none"),
})}
>
{!hideHeader && (
{headerStyle !== "none" && (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
@@ -683,35 +768,7 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
{showHeader &&
(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>
))}
{header}
{
// TODO: remove this once we remove the developer flag gets removed and we have shipped to
// device transport as the default.
@@ -728,6 +785,10 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<EarpieceOverlay
show={earpieceMode}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} />
{footer}
{layout.type !== "pip" && (

View File

@@ -86,6 +86,14 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
// This is one of the main changes that makes iOS work with bluetooth audio devices.
if (audio.enabled) {
devices.requestDeviceNames();
}
}, [audio.enabled, devices]);
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,

View File

@@ -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<Error>();
@@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => {
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
{header === "standard" && (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
)}
<div className={styles.container}>
<main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}>

View File

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

View File

@@ -5,9 +5,56 @@ exports[`InCallView > rendering > renders 1`] = `
<div
class="inRoom"
>
<div
class="header filler"
/>
<header
class="header header"
>
<div
class="nav leftNav"
>
<div
class="roomHeaderInfo"
data-size="lg"
>
<span
aria-label=""
class="_avatar_1qbcf_8 roomAvatar _avatar-imageless_1qbcf_52"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
/>
<div
class="nameLine"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
data-testid="roomHeader_roomName"
/>
<span
tabindex="0"
>
<svg
aria-labelledby="«r0»"
class="lock"
data-encrypted="false"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
/>
</svg>
</span>
</div>
</div>
</div>
<div
class="nav rightNav"
/>
</header>
<div>
mocked: MatrixAudioRenderer
</div>
@@ -35,18 +82,166 @@ exports[`InCallView > rendering > renders 1`] = `
>
<div />
</div>
<div
class="overlay"
data-show="false"
>
<div
class="_content_o77nw_8 icon"
data-size="large"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
/>
<path
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Earpiece Mode
</h2>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Only works while using app
</p>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Back to Speaker Mode
</button>
</div>
<div
class="container"
/>
<div
class="footer"
>
<div
class="logo"
>
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Mark"
>
<rect
fill="#0DBD8B"
height="48"
rx="23.93"
width="47.86"
/>
<g
id="Union"
>
<path
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
fill="white"
/>
<path
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
fill="white"
/>
<path
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
fill="white"
/>
<path
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
fill="white"
/>
</g>
</g>
</svg>
<svg
aria-label="Element Call"
fill="none"
height="11"
viewBox="0 0 160 22"
width="80"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Type"
>
<g
id="Vector"
>
<path
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
fill="currentColor"
/>
<path
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
fill="currentColor"
/>
<path
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
fill="currentColor"
/>
<path
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
fill="currentColor"
/>
<path
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
fill="currentColor"
/>
<path
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
fill="currentColor"
/>
<path
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
fill="currentColor"
/>
<path
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
fill="currentColor"
/>
<path
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
fill="currentColor"
/>
<path
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
fill="currentColor"
/>
<path
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
fill="currentColor"
/>
</g>
</g>
</svg>
</div>
<div
class="buttons"
>
<button
aria-disabled="false"
aria-labelledby="«r0»"
aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -69,7 +264,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="false"
aria-labelledby="«r5»"
aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -91,7 +286,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«ra»"
aria-labelledby="«rf»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -112,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«rf»"
aria-labelledby="«rk»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
@@ -138,7 +333,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout"
>
<input
aria-labelledby="«rk»"
aria-labelledby="«rp»"
name="layout"
type="radio"
value="spotlight"
@@ -156,7 +351,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="«rp»"
aria-labelledby="«ru»"
checked=""
name="layout"
type="radio"

View File

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

View File

@@ -42,6 +42,7 @@ import {
withTestScheduler,
mockRtcMembership,
MockRTCSession,
mockMediaDevices,
} from "../utils/test";
import {
ECAddonConnectionState,
@@ -71,6 +72,12 @@ import {
localId,
localRtcMember,
} from "../utils/test-fixtures";
import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices";
import { getValue } from "../utils/observable";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("@livekit/components-core");
@@ -209,6 +216,7 @@ function withCallViewModel(
rtcMembers$: Observable<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>,
mediaDevices: MediaDevices,
continuation: (
vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
@@ -262,6 +270,7 @@ function withCallViewModel(
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
mediaDevices,
{
kind: E2eeType.PER_PARTICIPANT,
},
@@ -301,6 +310,7 @@ test("participants are retained during a focus switch", () => {
s: ECAddonConnectionState.ECSwitchingFocus,
}),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
@@ -340,6 +350,7 @@ test("screen sharing activates spotlight layout", () => {
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
@@ -423,6 +434,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
mockMediaDevices({}),
(vm) => {
schedule(visibilityInputMarbles, {
a: () => {
@@ -479,6 +491,7 @@ test("participants adjust order when space becomes constrained", () => {
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
mockMediaDevices({}),
(vm) => {
let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout$.subscribe((layout) => {
@@ -532,6 +545,7 @@ test("spotlight speakers swap places", () => {
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]),
mockMediaDevices({}),
(vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
@@ -577,6 +591,7 @@ test("layout enters picture-in-picture mode when requested", () => {
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(),
@@ -618,6 +633,7 @@ test("spotlight remembers whether it's expanded", () => {
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
@@ -686,6 +702,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
}),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -730,6 +747,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
of([]), // No one joins the MatrixRTC session
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -777,6 +795,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
}),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -825,6 +844,7 @@ test("should disambiguate users with the same displayname", () => {
}),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
// Skip the null state.
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
@@ -875,6 +895,7 @@ test("should disambiguate users with invisible characters", () => {
}),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
// Skip the null state.
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
@@ -911,6 +932,7 @@ test("should strip RTL characters from displayname", () => {
}),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
// Skip the null state.
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
@@ -943,6 +965,7 @@ it("should rank raised hands above video feeds and below speakers and presenters
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm, { raisedHands$ }) => {
schedule("ab", {
a: () => {
@@ -991,3 +1014,47 @@ it("should rank raised hands above video feeds and below speakers and presenters
);
});
});
test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([]));
const scope = new ObservableScope();
onTestFinished(() => scope.end());
const devices = new MediaDevices(scope);
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
{ id: "earpiece", name: "Earpiece", isEarpiece: true },
{ id: "headphones", name: "Headphones" },
]);
window.controls.setAudioDevice("headphones");
const toggleInputMarbles = " -aaa";
const expectedEarpieceModeMarbles = "n-yn";
const expectedTargetStateMarbles = " sese";
withCallViewModel(
of([]),
of([]),
of(ConnectionState.Connected),
new Map(),
devices,
(vm) => {
schedule(toggleInputMarbles, {
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
});
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
n: false,
y: true,
});
expectObservable(
vm.audioOutputSwitcher$.pipe(
map((switcher) => switcher?.targetOutput),
),
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
},
);
});
});

View File

@@ -93,6 +93,7 @@ import {
import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -1246,6 +1247,51 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
/**
* Whether audio is currently being output through the earpiece.
*/
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.
*
* This will be `null` in case the target does not exist in the list
* of available audio outputs.
*/
public readonly audioOutputSwitcher$: Observable<{
targetOutput: "earpiece" | "speaker";
switch: () => void;
} | null> = combineLatest(
[
this.mediaDevices.audioOutput.available$,
this.mediaDevices.audioOutput.selected$,
],
(available, selected) => {
const selectionType = selected && available.get(selected.id)?.type;
// If we are in any output mode other than speaker switch to speaker.
const newSelectionType =
selectionType === "speaker" ? "earpiece" : "speaker";
const newSelection = [...available].find(
([, d]) => d.type === newSelectionType,
);
if (newSelection === undefined) return null;
const [id] = newSelection;
return {
targetOutput: newSelectionType,
switch: () => this.mediaDevices.audioOutput.select(id),
};
},
);
public readonly reactions$ = this.reactionsSubject$.pipe(
map((v) =>
Object.fromEntries(
@@ -1336,6 +1382,7 @@ export class CallViewModel extends ViewModel {
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<

View File

@@ -42,10 +42,13 @@ const logger = rootLogger.getChild("[MediaDevices]");
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;
@@ -211,7 +214,8 @@ class AudioOutput
this.scope,
).pipe(
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.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
@@ -260,6 +264,15 @@ class AudioOutput
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
// We need to subscribe to the raw devices so that the OS does update the input
// back to what it was before. otherwise we will switch back to the default
// whenever we allocate a new stream.
public readonly availableRaw$ = availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
);
public readonly available$ = combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => {
@@ -269,7 +282,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];
},
@@ -307,14 +320,17 @@ class ControlledAudioOutput
},
).pipe(this.scope.state());
public constructor(private readonly scope: ObservableScope) {
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
logger.info("[controlled-output] onAudioDeviceSelect called:", device);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
@@ -323,6 +339,9 @@ class ControlledAudioOutput
this.available$.subscribe((available) => {
logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
logger.info("[controlled-output] available raw devices:", availableRaw);
});
}
}
@@ -389,7 +408,7 @@ export class MediaDevices {
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.scope)
? new ControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
.tile {
--media-view-border-radius: var(--cpd-space-4x);
--hover-space-margin: var(--cpd-space-1x);
transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
}

View File

@@ -9,6 +9,8 @@ Please see LICENSE in the repository root for full details.
container-name: mediaView;
container-type: size;
border-radius: var(--media-view-border-radius);
/*Add spacing for the hover boarder. Otherwise it might get clipped.*/
margin: var(--hover-space-margin);
}
.media video {

View File

@@ -18,7 +18,12 @@ import EventEmitter from "events";
import type { RoomMember, MatrixClient } from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import { mockLivekitRoom, mockMatrixRoom, MockRTCSession } from "./test";
import {
mockLivekitRoom,
mockMatrixRoom,
mockMediaDevices,
MockRTCSession,
} from "./test";
import {
aliceRtcMember,
aliceParticipant,
@@ -132,6 +137,7 @@ export function getBasicCallViewModelEnvironment(
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
mockMediaDevices({}),
{
kind: E2eeType.PER_PARTICIPANT,
},

View File

@@ -124,6 +124,7 @@ export const widget = ((): WidgetHelpers | null => {
}));
const receiveState = [
{ eventType: EventType.RoomCreate },
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallMemberPrefix },

View File

@@ -2702,37 +2702,37 @@ __metadata:
languageName: node
linkType: hard
"@livekit/components-core@npm:0.12.7, @livekit/components-core@npm:^0.12.0":
version: 0.12.7
resolution: "@livekit/components-core@npm:0.12.7"
"@livekit/components-core@npm:0.12.8, @livekit/components-core@npm:^0.12.0":
version: 0.12.8
resolution: "@livekit/components-core@npm:0.12.8"
dependencies:
"@floating-ui/dom": "npm:1.6.13"
loglevel: "npm:1.9.1"
rxjs: "npm:7.8.2"
peerDependencies:
livekit-client: ^2.13.1
livekit-client: ^2.13.3
tslib: ^2.6.2
checksum: 10c0/11024e120f2b1b81688d507dc8441f663730956d4c79f66014a9f19a3723fcdee9517a09239f21d501067cc299bf97659655404048c415314c39bf6af5d8ab9d
checksum: 10c0/68c471b2f1319fbbfff05138b55442cc2ba575bc8506b3e71bb222fdf7026041f88c38e832227bbcc37edd4d3936f997c1b54ce7b27d1971f960794f049c9684
languageName: node
linkType: hard
"@livekit/components-react@npm:^2.0.0":
version: 2.9.10
resolution: "@livekit/components-react@npm:2.9.10"
version: 2.9.12
resolution: "@livekit/components-react@npm:2.9.12"
dependencies:
"@livekit/components-core": "npm:0.12.7"
"@livekit/components-core": "npm:0.12.8"
clsx: "npm:2.1.1"
usehooks-ts: "npm:3.1.1"
peerDependencies:
"@livekit/krisp-noise-filter": ^0.2.12
livekit-client: ^2.13.1
livekit-client: ^2.13.3
react: ">=18"
react-dom: ">=18"
tslib: ^2.6.2
peerDependenciesMeta:
"@livekit/krisp-noise-filter":
optional: true
checksum: 10c0/86fc78d7b2d97540c43592435a65ac38b43fca9923af000aa90d884bd35794e18d202b7d915f44b63087abea5c94c37d6980772ed722226904262b2cddbec107
checksum: 10c0/445099ff7d335f8b9908e814e327d43477ea19fb93c428fadb0bf2aa56e323171af10fcc9493f686ad5060fb75e0d5c66632e55cd648a76fafe146827c53bd87
languageName: node
linkType: hard
@@ -9907,8 +9907,8 @@ __metadata:
linkType: hard
"livekit-client@npm:^2.13.0":
version: 2.13.8
resolution: "livekit-client@npm:2.13.8"
version: 2.14.0
resolution: "livekit-client@npm:2.14.0"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.39.2"
@@ -9921,7 +9921,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/bf1ba10891ab5beb44a30016c3923a635d0bb637d604280686b98ea366794985eec0a4a294fb1478a8a68ac90ba8ab0ea5c1e64986603d8ce53019399994385b
checksum: 10c0/2c3a134c5463fb06f7b5b191329530546cf28309c919f0cfc1fb1b334daa92e8d1ad23dba570d55ee12ad68592871ab18fcab02e784ed340773c84b19619a9ca
languageName: node
linkType: hard