Compare commits

..

32 Commits

Author SHA1 Message Date
Robin
4df74c7134 Simplify our context providers
React 19 lets you omit the '.Provider' bit.
2025-06-23 23:03:42 -04:00
Robin
d4b38563e7 Replace useContext with use
The docs recommend the use hook because it is simpler and allows itself to be called conditionally.
2025-06-23 23:03:42 -04:00
Robin
f86c9fe0a0 Merge pull request #3355 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-web to v8
2025-06-23 22:10:07 -04:00
renovate[bot]
3ae535bf31 Update dependency @vector-im/compound-web to v8 2025-06-23 00:39:22 +00:00
Robin
5e2e94d794 Refactor media devices to live outside React as Observables (#3334)
* Refactor media devices to live outside React as Observables

This moves the media devices state out of React to further our transition to a MVVM architecture in which we can more easily model and store complex application state. I have created an AppViewModel to act as the overarching state holder for any future non-React state we end up creating, and the MediaDevices reside within this. We should move more application logic (including the CallViewModel itself) there in the future.

* Address review feedback

* Fixes from ios debugging session: (#3342)

- dont use preferred vs selected concept in controlled media. Its not needed since we dont use the id for actual browser media devices (the id's are not even actual browser media devices)
  - add more logging
  - add more conditions to not accidently set a deviceId that is not a browser deviceId but one provided via controlled.

---------

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2025-06-20 18:37:25 +02:00
renovate[bot]
5bf7361d01 Update dependency livekit-client to v2.13.8 (#3352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 10:01:31 +02:00
renovate[bot]
0fe8d16740 Update dependency @livekit/components-react to v2.9.10 (#3349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:42:15 +02:00
renovate[bot]
f1a2002072 Update dependency livekit-client to v2.13.7 (#3350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:40:28 +02:00
renovate[bot]
1af1a8ed0b Update dependency livekit-client to v2.13.6 (#3348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 13:19:31 +02:00
renovate[bot]
0970db8297 Update dependency livekit-client to v2.13.5 (#3343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 13:59:35 +02:00
Robin
7f4a5a8bab Merge pull request #3338 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v4.0.4
2025-06-16 08:50:53 -04:00
renovate[bot]
442fc1fc16 Update dependency @vector-im/compound-design-tokens to v4.0.4 2025-06-16 01:46:00 +00:00
fkwp
32b6250cc3 fix: Force the docker image for lk-jwt-service to be pulled everytime (#3325)
lk-jwt-service latest-ci to be force pulled every time
2025-06-12 16:42:06 +02:00
Timo
4a6dbf350f bump js-sdk to latest develop (containing a fix for membership manager resend join event issue) (#3333)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-06-12 16:28:00 +02:00
fkwp
29968a3eba Apply new timing constants to MatrixRTC session (#3332)
Apply new timing constants
2025-06-12 14:10:54 +02:00
fkwp
aa092c2195 Merge pull request #3323 from element-hq/fkwp/dev_build
Add developer build mode to GitHub action build-element-call.yaml
2025-06-11 22:56:38 +02:00
Timo
1425de8726 Expose keep alive event configurarion in ec config and update defaults. (#3303) 2025-06-10 18:09:52 +02:00
fkwp
c1aa1888f0 Merge pull request #3327 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.13.4
2025-06-10 16:12:11 +02:00
fkwp
58ebbfeed7 Merge pull request #3326 from element-hq/fkwp/doc_self-hosting_update
Add tools section to self-hosting guide and adding Matrixtester to it
2025-06-10 15:59:12 +02:00
Timo
02f3f1b71a Improvements to audio renderer codebase. (#3308)
* Improvements to audio renderer codebase.

* review
2025-06-10 13:00:21 +00:00
renovate[bot]
f7a6e20ae4 Update dependency livekit-client to v2.13.4 2025-06-10 12:31:41 +00:00
fkwp
378d59e948 prettier 2025-06-10 14:27:15 +02:00
fkwp
a07dbe645e tools section to self-hosting guide and adding Matrixtester to it 2025-06-10 14:24:59 +02:00
Timo
a596eb71a9 add onAudioPlaybackStarted callback for controls (#3309)
* add onAudioTrackReady callback for controls

* more details in controls.md for `onAudioPlaybackStarted`
2025-06-10 12:35:04 +02:00
Robin
c8a1cae18b Merge pull request #2874 from element-hq/renovate/major-react-monorepo
Update react monorepo to v19 (major)
2025-06-09 11:59:26 -04:00
Robin
4f1450aaf0 Replace react-use-clipboard with copy-to-clipboard
We didn't need the complexity of the (admittedly very small) React hook, and the package hasn't declared compatibility with React 19, so let's just switch to copying things manually via copy-to-clipboard.
2025-06-09 11:53:58 -04:00
Robin
8704b44b78 Update test snapshots for React 19 changes 2025-06-09 11:48:01 -04:00
Robin
0ee11af3c5 Upgrade react-spring to v10
For React 19 compatibility.
2025-06-09 11:47:47 -04:00
Andrew Ferrazzutti
13fac57b01 Build Docker image on slim base (#3204)
* Build Docker image on slim base

* Run Playwright tests against Docker container

For Playwright end-to-end tests in CI, instead of running a development
webserver with `yarn dev`, build and deploy a Docker container for
Element Call and use that as the webserver to test against.

* Shut down playwright webserver gracefully

When using a containerized webserver, this stops the container once
tests finish.

* Increase Playwright timeout in CI

---------

Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-06-06 12:04:57 -04:00
Robin
e0ce58b2a2 Merge pull request #3320 from element-hq/revert-3319-revert-3313-misc/migrate-to-maven-central-portal
Upgrade Android publishing to use Maven Central Portal (again)
2025-06-06 10:18:20 -04:00
Jorge Martin Espinosa
34df584366 Revert "Revert "Upgrade Android publishing to use Maven Central Portal"" 2025-06-05 11:20:47 +02:00
renovate[bot]
789f37611b Update react monorepo to v19 2025-06-03 13:28:48 +00:00
52 changed files with 1076 additions and 914 deletions

View File

@@ -30,7 +30,7 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
timeout-minutes: 10
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -49,9 +49,9 @@ jobs:
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
docker ps
- name: Copy config file
run: cp config/config.devenv.json public/config.json
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: yarn playwright test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}

View File

@@ -6,7 +6,7 @@ COPY ./dist /dist
WORKDIR /dist/assets
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
FROM nginxinc/nginx-unprivileged:alpine
FROM nginxinc/nginx-unprivileged:alpine-slim
COPY --from=builder ./dist /app

View File

@@ -8,5 +8,12 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
}
}

View File

@@ -11,5 +11,12 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
}
}

View File

@@ -5,6 +5,14 @@
"server_name": "call-unstable.ems.host"
}
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"

View File

@@ -4,6 +4,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server
environment:
- LIVEKIT_JWT_PORT=6080

View File

@@ -19,3 +19,7 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
In particular android is using it to setup the output channel so that the call volume can
be controlled by the hardware volume rocker.

View File

@@ -255,13 +255,17 @@ self-hosters and developers working with Element Call.
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
## 🛠️ How-Tos & Tutorials
## 📝 How-Tos & Tutorials
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
## 🛠️ Tools
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
## 🤝 Want to Contribute?
Have a guide or blog post you'd like to share? Open a

View File

@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }

View File

@@ -27,7 +27,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
signAllPublications()

View File

@@ -58,7 +58,7 @@
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^9.4.4",
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
@@ -74,19 +74,20 @@
"@types/node": "^22.0.0",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.12.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
@@ -117,11 +118,10 @@
"posthog-js": "1.160.3",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "18",
"react-dom": "18",
"react": "19",
"react-dom": "19",
"react-i18next": "^15.0.0",
"react-router-dom": "^7.0.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",

View File

@@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "https://localhost:3000";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -25,7 +29,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "https://localhost:3000",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -73,9 +77,13 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn dev",
url: "https://localhost:3000",
command: "./scripts/playwright-webserver-command.sh",
url: baseURL,
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 500,
},
},
});

View File

@@ -69,16 +69,27 @@ const CONFIG_JSON = {
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
* @param page
*/
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
}
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
}
: async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
};
/**
* Registers a new user and returns page, clientHandle and mxId.

View File

@@ -0,0 +1,10 @@
#!/bin/sh
if [ -n "$USE_DOCKER" ]; then
set -ex
yarn build
docker build -t element-call:testing .
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
else
cp config/config.devenv.json public/config.json
exec yarn dev
fi

View File

@@ -19,10 +19,11 @@ import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -50,7 +51,11 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children;
};
export const App: FC = () => {
interface Props {
vm: AppViewModel;
}
export const App: FC<Props> = ({ vm }) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Initializer.init()
@@ -72,7 +77,7 @@ export const App: FC = () => {
{loaded ? (
<Suspense fallback={null}>
<ClientProvider>
<MediaDevicesProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => (
@@ -91,7 +96,7 @@ export const App: FC = () => {
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesProvider>
</MediaDevicesContext>
</ClientProvider>
</Suspense>
) : (

View File

@@ -11,7 +11,7 @@ import {
useEffect,
useState,
createContext,
useContext,
use,
useRef,
useMemo,
type JSX,
@@ -69,8 +69,7 @@ const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider;
export const useClientState = (): ClientState | undefined =>
useContext(ClientContext);
export const useClientState = (): ClientState | undefined => use(ClientContext);
export function useClient(): {
client?: MatrixClient;
@@ -350,9 +349,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
}
return (
<ClientContext.Provider value={state}>{children}</ClientContext.Provider>
);
return <ClientContext value={state}>{children}</ClientContext>;
};
export type InitResult = {

View File

@@ -0,0 +1,52 @@
/*
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, use, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type MediaDevices } from "./state/MediaDevices";
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
undefined,
);
export function useMediaDevices(): MediaDevices {
const mediaDevices = use(MediaDevicesContext);
if (mediaDevices === undefined)
throw new Error(
"useMediaDevices must be used within a MediaDevices context provider",
);
return mediaDevices;
}
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput?.virtualEarpiece ? 1 : 0),
[audioOutput?.virtualEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput?.virtualEarpiece ? 0.1 : 1),
[audioOutput?.virtualEarpiece],
);
return { pan, volume };
};

View File

@@ -2,10 +2,10 @@
exports[`the content is rendered when the modal is open 1`] = `
<div
aria-labelledby="radix-:r4:"
aria-labelledby="radix-«r4»"
class="overlay animate modal dialog _glass_sepwu_8"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
id="radix-:r4:"
id="radix-«r4»"
>
My modal
</h2>
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
exports[`the modal renders as a drawer in mobile viewports 1`] = `
<div
aria-labelledby="radix-:ra:"
aria-labelledby="radix-«ra»"
class="overlay modal drawer"
data-state="open"
data-vaul-animate="true"
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
data-vaul-drawer=""
data-vaul-drawer-direction="bottom"
data-vaul-snap-points="false"
id="radix-:r9:"
id="radix-«r9»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
class="handle"
/>
<h2
id="radix-:ra:"
id="radix-«ra»"
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
>
My modal

View File

@@ -2,10 +2,10 @@
exports[`Toast > renders 1`] = `
<button
aria-labelledby="radix-:r4:"
aria-labelledby="radix-«r4»"
class="overlay animate toast"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
>
<h3
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
id="radix-:r4:"
id="radix-«r4»"
>
Hello world!
</h3>

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":rb5:"
aria-labelledby="«rb5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r7m:"
aria-labelledby="«r7m»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -74,7 +74,7 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r36:"
aria-labelledby="«r36»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -108,7 +108,7 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r0:"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -139,7 +139,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r1j:"
aria-labelledby="«r1j»"
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"

View File

@@ -14,6 +14,7 @@ export interface ConfigOptions {
api_key: string;
api_host: string;
};
/**
* The Sentry endpoint to which crash data will be sent.
* This is only used in the full package of Element Call.
@@ -22,6 +23,7 @@ export interface ConfigOptions {
DSN: string;
environment: string;
};
/**
* The rageshake server to which feedback and debug logs will be sent.
* This is only used in the full package of Element Call.
@@ -66,6 +68,7 @@ export interface ConfigOptions {
* Allow to join group calls without audio and video.
*/
feature_group_calls_without_video_and_audio?: boolean;
/**
* Send device-specific call session membership state events instead of
* legacy user-specific call membership state events.
@@ -86,6 +89,7 @@ export interface ConfigOptions {
* Defines whether participants should start with audio enabled by default.
*/
enable_audio?: boolean;
/**
* Defines whether participants should start with video enabled by default.
*/
@@ -109,19 +113,38 @@ export interface ConfigOptions {
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/** @deprecated use wait_for_key_rotation_ms instead */
key_rotation_on_leave_delay?: number;
/**
* How often (in milliseconds) keep-alive messages should be sent to the server for
* the MatrixRTC membership event.
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** @deprecated use delayed_leave_event_restart_ms instead */
membership_keep_alive_period?: number;
/**
* How long (in milliseconds) after the last keep-alive the server should expire the
* MatrixRTC membership event.
* How long we wait before retrying after a network error on any of the requests.
*/
membership_server_side_expiry_timeout?: number;
network_error_retry_ms?: number;
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
*
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membership_event_expiry_ms?: number;
};
}

View File

@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, Subject } from "rxjs";
import { Subject } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
export interface Controls {
canEnterPip(): boolean;
@@ -20,6 +23,7 @@ export interface Controls {
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
@@ -41,12 +45,11 @@ export interface OutputDevice {
* If pipMode is enabled, EC will render a adapted call view layout.
*/
export const setPipEnabled$ = new Subject<boolean>();
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
// We want the devices that have been set during loading to be available immediately once loaded.
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
// We want the device that has been set during loading to be available immediately once loaded.
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
export const outputDevice$ = new Subject<string | undefined>();
/**
* This allows the os to mute the call if the user
* presses the volume down button when it is at the minimum volume.
@@ -54,7 +57,13 @@ export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
* 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) {
playbackStartedEmitted = true;
window.controls.onAudioPlaybackStarted?.();
}
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -68,12 +77,15 @@ window.controls = {
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices);
},
setAudioDevice(id: string): void {
logger.info("setAudioDevice called from native", id);
outputDevice$.next(id);
},
setAudioEnabled(enabled: boolean): void {
logger.info("setAudioEnabled called from native:", enabled);
if (!setAudioEnabled$.observed)
throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer",

View File

@@ -24,7 +24,7 @@ import {
createContext,
forwardRef,
memo,
useContext,
use,
useEffect,
useMemo,
useRef,
@@ -124,7 +124,7 @@ interface LayoutContext {
const LayoutContext = createContext<LayoutContext | null>(null);
function useLayoutContext(): LayoutContext {
const context = useContext(LayoutContext);
const context = use(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
return context;
@@ -532,14 +532,14 @@ export function Grid<
className={classNames(className, styles.grid)}
style={style}
>
<LayoutContext.Provider value={context}>
<LayoutContext value={context}>
<LayoutMemo
ref={setLayoutRoot}
Layout={Layout}
model={model}
Slot={Slot}
/>
</LayoutContext.Provider>
</LayoutContext>
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper
key={id}

View File

@@ -17,12 +17,14 @@ import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "./MediaDevicesContext";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockTrack } from "../utils/test";
import { mockMediaDevices, mockTrack } from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
});
@@ -51,18 +53,24 @@ vi.mocked(useTracks).mockReturnValue(tracks);
it("should render for member", () => {
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1);
});
it("should not render without member", () => {
const memberships = [
{ sender: "othermember", deviceId: "123" },
] as CallMembership[];
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "othermember", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer members={memberships} />
</MediaDevicesProvider>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0);
@@ -70,9 +78,11 @@ it("should not render without member", () => {
it("should not setup audioContext gain and pan if there is no need to.", () => {
render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
@@ -84,15 +94,18 @@ it("should not setup audioContext gain and pan if there is no need to.", () => {
expect(testAudioContext.gain.gain.value).toEqual(1);
expect(testAudioContext.pan.pan.value).toEqual(0);
});
it("should setup audioContext gain and pan", () => {
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
pan: 1,
volume: 0.1,
});
render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;

View File

@@ -14,11 +14,13 @@ import {
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
export interface MatrixAudioRendererProps {
/**
* The list of participants to render audio for.
@@ -59,6 +61,7 @@ export function MatrixAudioRenderer({
);
const loggedInvalidIdentities = useRef(new Set<string>());
/**
* Log an invalid livekit track identity.
* A invalid identity is one that does not match any of the matrix rtc members.
@@ -96,6 +99,14 @@ export function MatrixAudioRenderer({
isValid
);
});
useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
logger.debug(
`All audio tracks have a matching matrix call member identity.`,
);
loggedInvalidIdentities.current.clear();
}
}, [tracks, validIdentities]);
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
@@ -204,6 +215,7 @@ function AudioTrackWithAudioNodes({
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
);
setTrackReady(true);
controls.setPlaybackStarted();
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
return (

View File

@@ -1,445 +0,0 @@
/*
Copyright 2023-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,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
useSetting,
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
type Setting,
} from "../settings/settings";
import { outputDevice$, availableOutputDevices$ } from "../controls";
import { useUrlParams } from "../UrlParams";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
export const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "earpiece" }
| { type: "default"; name: string | null };
export interface MediaDeviceHandle {
/**
* A map from available device IDs to labels.
*/
available: Map<string, DeviceLabel>;
selectedId: string | undefined;
/**
* An additional device configuration that makes us use only one channel of the
* output device and a reduced volume.
*/
useAsEarpiece: boolean | undefined;
/**
* The group ID of the selected device.
*/
// This is exposed sort of ad-hoc because it's only needed for knowing when to
// restart the tracks of default input devices, and ideally this behavior
// would be encapsulated somehow…
selectedGroupId: string | undefined;
select: (deviceId: string) => void;
}
interface InputDevices {
audioInput: MediaDeviceHandle;
videoInput: MediaDeviceHandle;
startUsingDeviceNames: () => void;
stopUsingDeviceNames: () => void;
usingNames: boolean;
}
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
audioOutput: MediaDeviceHandle;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
map((v) => v || navigator.userAgent.includes("iPhone")),
);
function useSelectedId(
available: Map<string, DeviceLabel>,
preferredId: string | undefined,
): string | undefined {
return useMemo(() => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
}, [available, preferredId]);
}
/**
* Hook to get access to a mediaDevice handle for a kind. This allows to list
* the available devices, read and set the selected device.
* @param kind Audio input, output or video output.
* @param setting The setting this handle's selection should be synced with.
* @param usingNames If the hook should query device names for the associated
* list.
* @returns A handle for the chosen kind.
*/
function useMediaDeviceHandle(
kind: MediaDeviceKind,
setting: Setting<string | undefined>,
usingNames: boolean,
): MediaDeviceHandle {
const hasRequestedPermissions = useRef(false);
const requestPermissions = usingNames || hasRequestedPermissions.current;
// Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given
hasRequestedPermissions.current ||= usingNames;
// We use a bare device observer here rather than one of the fancy device
// selection hooks from @livekit/components-react, because
// useMediaDeviceSelect expects a room or track, which we don't have here, and
// useMediaDevices provides no way to request device names.
// Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests.
const deviceObserver$ = useMemo(
() =>
createMediaDeviceObserver(
kind,
() => logger.error("Error creating MediaDeviceObserver"),
requestPermissions,
).pipe(
startWith([]),
// This Observable emits new values whenever the browser fires a
// MediaDevices 'devicechange' event. One would think, innocently, that
// a 'devicechange' event means the devices have changed. But as of the
// time of writing, we are seeing mobile Safari firing spurious
// 'devicechange' events (where no change has actually occurred) when
// we call MediaDevices.getUserMedia. So, filter by deep equality.
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
),
[kind, requestPermissions],
);
const available = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map((availableRaw) => {
// Sometimes browsers (particularly Firefox) can return multiple device
// entries for the exact same device ID; using a map deduplicates them
let available = new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
// 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.
// We also create this if we do not have any available devices, so that
// we can use the default or the earpiece.
if (
kind === "audiooutput" &&
!available.has("") &&
!available.has("default") &&
available.size
)
available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }],
...available,
]);
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
),
[deviceObserver$, kind],
),
);
const [preferredId, select] = useSetting(setting);
const selectedId = useSelectedId(available, preferredId);
const selectedGroupId = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map(
(availableRaw) =>
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
),
),
[deviceObserver$, selectedId],
),
);
return useMemo(
() => ({
available,
selectedId,
useAsEarpiece: false,
selectedGroupId,
select,
}),
[available, selectedId, selectedGroupId, select],
);
}
export const deviceStub: MediaDeviceHandle = {
available: new Map(),
selectedId: undefined,
selectedGroupId: undefined,
select: () => {},
useAsEarpiece: false,
};
export const devicesStub: MediaDevices = {
audioInput: deviceStub,
audioOutput: deviceStub,
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
};
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
function useInputDevices(): InputDevices {
// Counts the number of callers currently using device names.
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0;
const audioInput = useMediaDeviceHandle(
"audioinput",
audioInputSetting,
usingNames,
);
const videoInput = useMediaDeviceHandle(
"videoinput",
videoInputSetting,
usingNames,
);
const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1),
[setNumCallersUsingNames],
);
const stopUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n - 1),
[setNumCallersUsingNames],
);
return {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
};
}
interface Props {
children: JSX.Element;
}
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
const {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
} = useInputDevices();
const { controlledAudioDevices } = useUrlParams();
const webViewAudioOutput = useMediaDeviceHandle(
"audiooutput",
audioOutputSetting,
usingNames,
);
const controlledAudioOutput = useControlledOutput();
const context: MediaDevices = useMemo(
() => ({
audioInput,
audioOutput: controlledAudioDevices
? controlledAudioOutput
: webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
}),
[
audioInput,
controlledAudioDevices,
controlledAudioOutput,
webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
],
);
return (
<MediaDevicesContext.Provider value={context}>
{children}
</MediaDevicesContext.Provider>
);
};
function useControlledOutput(): MediaDeviceHandle {
const { available } = useObservableEagerState(
useObservable(() => {
const outputDeviceData$ = availableOutputDevices$.pipe(
map((devices) => {
const deviceForEarpiece = devices.find((d) => d.forEarpiece);
const deviceMapTuple: [string, DeviceLabel][] = devices.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: DeviceLabel = { type: "name", name };
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
if (isSpeaker) deviceLabel = { type: "default", name };
return [id, deviceLabel];
},
);
return {
devicesMap: new Map<string, DeviceLabel>(deviceMapTuple),
deviceForEarpiece,
};
}),
);
return combineLatest(
[outputDeviceData$, iosDeviceMenu$],
({ devicesMap, deviceForEarpiece }, iosShowEarpiece) => {
let available = devicesMap;
if (iosShowEarpiece && !!deviceForEarpiece) {
available = new Map([
...devicesMap.entries(),
[EARPIECE_CONFIG_ID, { type: "earpiece" }],
]);
}
return { available, deviceForEarpiece };
},
);
}),
);
const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
useEffect(() => {
const subscription = outputDevice$.subscribe((id) => {
if (id) setPreferredId(id);
});
return (): void => subscription.unsubscribe();
}, [setPreferredId]);
const selectedId = useSelectedId(available, preferredId);
const [asEarpiece, setAsEarpiece] = useState(false);
useEffect(() => {
// 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 (selectedId) {
window.controls.onAudioDeviceSelect?.(selectedId);
// Call deprecated method for backwards compatibility.
window.controls.onOutputDeviceSelect?.(selectedId);
}
setAsEarpiece(selectedId === EARPIECE_CONFIG_ID);
}, [selectedId]);
return useMemo(
() => ({
available: available,
selectedId,
selectedGroupId: undefined,
select: setPreferredId,
useAsEarpiece: asEarpiece,
}),
[available, selectedId, setPreferredId, asEarpiece],
);
}
export const useMediaDevices = (): MediaDevices =>
useContext(MediaDevicesContext);
/**
* React hook that requests for the media devices context to be populated with
* real device names while this component is mounted. This is not done by
* default because it may involve requesting additional permissions from the
* user.
*/
export const useMediaDeviceNames = (
context: MediaDevices,
enabled = true,
): void =>
useEffect(() => {
if (enabled) {
context.startUsingDeviceNames();
return context.stopUsingDeviceNames;
}
}, [context, enabled]);
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const { audioOutput } = useMediaDevices();
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput.useAsEarpiece ? 1 : 0),
[audioOutput.useAsEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput.useAsEarpiece ? 0.1 : 1),
[audioOutput.useAsEarpiece],
);
return { pan, volume };
};

View File

@@ -10,7 +10,14 @@ import {
supportsBackgroundProcessors,
type BackgroundOptions,
} from "@livekit/track-processors";
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
import {
createContext,
type FC,
type JSX,
use,
useEffect,
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import {
@@ -27,7 +34,7 @@ type ProcessorState = {
const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
export function useTrackProcessor(): ProcessorState {
const state = useContext(ProcessorContext);
const state = use(ProcessorContext);
if (state === undefined)
throw new Error(
"useTrackProcessor must be used within a ProcessorProvider",
@@ -76,9 +83,5 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
[supported, blurActivated, blur],
);
return (
<ProcessorContext.Provider value={processorState}>
{children}
</ProcessorContext.Provider>
);
return <ProcessorContext value={processorState}>{children}</ProcessorContext>;
};

View File

@@ -19,12 +19,18 @@ import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import {
map,
NEVER,
type Observable,
type Subscription,
switchMap,
} from "rxjs";
import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates";
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import {
type ECConnectionState,
useECConnectionState,
@@ -39,6 +45,8 @@ import {
import { observeTrackReference$ } from "../state/MediaViewModel";
import { useUrlParams } from "../UrlParams";
import { useInitial } from "../useInitial";
import { getValue } from "../utils/observable";
import { type SelectedDevice } from "../state/MediaDevices";
interface UseLivekitResult {
livekitRoom?: Room;
@@ -56,7 +64,9 @@ export function useLivekit(
const initialMuteStates = useInitial(() => muteStates);
const devices = useMediaDevices();
const initialDevices = useInitial(() => devices);
const initialAudioInputId = useInitial(
() => getValue(devices.audioInput.selected$)?.id,
);
// Store if audio/video are currently updating. If to prohibit unnecessary calls
// to setMicrophoneEnabled/setCameraEnabled
@@ -94,15 +104,20 @@ export function useLivekit(
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: initialDevices.videoInput.selectedId,
deviceId: getValue(devices.videoInput.selected$)?.id,
processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: initialDevices.audioInput.selectedId,
deviceId: initialAudioInputId,
},
audioOutput: {
deviceId: initialDevices.audioOutput.selectedId,
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: getValue(devices.audioOutput.selected$)?.id,
},
e2ee,
};
@@ -157,7 +172,7 @@ export function useLivekit(
);
const connectionState = useECConnectionState(
initialDevices.audioInput.selectedId,
initialAudioInputId,
initialMuteStates.audio.enabled,
room,
sfuConfig,
@@ -312,62 +327,65 @@ export function useLivekit(
) {
const syncDevice = (
kind: MediaDeviceKind,
device: MediaDeviceHandle,
): void => {
const id = device.selectedId;
// Detect if we're trying to use chrome's default device, in which case
// we need to to see if the default device has changed to a different device
// by comparing the group ID of the device we're using against the group ID
// of what the default device is *now*.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
if (
id === "default" &&
kind === "audioinput" &&
room.options.audioCaptureDefaults?.deviceId === "default"
) {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
device.selectedGroupId !==
activeMicTrack.mediaStreamTrack.getSettings().groupId
device !== undefined &&
room.getActiveDevice(kind) !== device.id
) {
// It's different, so restart the track, ie. cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
} else {
if (id !== undefined && room.getActiveDevice(kind) !== id) {
room
.switchActiveDevice(kind, id)
.switchActiveDevice(kind, device.id)
.catch((e) =>
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
);
}
}
};
});
syncDevice("audioinput", devices.audioInput);
syncDevice("audiooutput", devices.audioOutput);
syncDevice("videoinput", devices.videoInput);
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("audiooutput", devices.audioOutput.selected$),
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
// for the sake of Chrome, which provides a "default" device that is meant
// to match the system's default audio input, whatever that may be.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
devices.audioInput.selected$
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
.subscribe(() => {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended"
) {
// Restart the track, which will cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
}),
];
return (): void => {
for (const s of subscriptions) s.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -23,6 +23,7 @@ import {
import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
window.setLKLogLevel = setLKLogLevel;
@@ -60,7 +61,7 @@ Initializer.initBeforeReact()
.then(() => {
root.render(
<StrictMode>
<App />
<App vm={new AppViewModel()} />
</StrictMode>,
);
})

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { EventType, RelationType } from "matrix-js-sdk";
import {
createContext,
useContext,
use,
type ReactNode,
useCallback,
useMemo,
@@ -34,7 +34,7 @@ const ReactionsSenderContext = createContext<
>(undefined);
export const useReactionsSender = (): ReactionsSenderContextType => {
const context = useContext(ReactionsSenderContext);
const context = use(ReactionsSenderContext);
if (!context) {
throw new Error("useReactions must be used within a ReactionsProvider");
}
@@ -157,7 +157,7 @@ export const ReactionsSenderProvider = ({
);
return (
<ReactionsSenderContext.Provider
<ReactionsSenderContext
value={{
supportsReactions,
toggleRaisedHand,
@@ -165,6 +165,6 @@ export const ReactionsSenderProvider = ({
}}
>
{children}
</ReactionsSenderContext.Provider>
</ReactionsSenderContext>
);
};

View File

@@ -32,6 +32,7 @@ import {
mockEmitter,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockRtcMembership,
MockRTCSession,
} from "../utils/test";
@@ -40,6 +41,7 @@ import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -147,20 +149,22 @@ function createGroupCallView(
const { getByText } = render(
<BrowserRouter>
<TooltipProvider>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</ProcessorProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
</BrowserRouter>,
);

View File

@@ -40,7 +40,7 @@ import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import {
@@ -197,8 +197,7 @@ export const GroupCallView: FC<Props> = ({
[memberships],
);
const deviceContext = useMediaDevices();
const latestDevices = useLatest(deviceContext);
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = useCallback(
@@ -250,7 +249,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`,
);
latestDevices.current!.audioInput.select(deviceId);
mediaDevices.audioInput.select(deviceId);
}
}
@@ -264,7 +263,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`,
);
latestDevices.current!.videoInput.select(deviceId);
mediaDevices.videoInput.select(deviceId);
}
}
};
@@ -306,7 +305,7 @@ export const GroupCallView: FC<Props> = ({
preload,
skipLobby,
perParticipantE2EE,
latestDevices,
mediaDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,

View File

@@ -31,6 +31,7 @@ import {
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockRemoteParticipant,
mockRtcMembership,
type MockRTCSession,
@@ -45,6 +46,7 @@ import {
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
@@ -147,41 +149,43 @@ function createInCallView(): RenderResult & {
rtcSession.joined = true;
const renderResult = render(
<BrowserRouter>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<TooltipProvider>
<RoomContext.Provider value={livekitRoom}>
<InCallView
client={client}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext.Provider>
</TooltipProvider>
</ReactionsSenderProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>
<InCallView
client={client}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
return {

View File

@@ -172,7 +172,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
if (livekitRoom === undefined || vm === null) return null;
return (
<RoomContext.Provider value={livekitRoom}>
<RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
@@ -181,7 +181,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
connState={connState}
/>
</ReactionsSenderProvider>
</RoomContext.Provider>
</RoomContext>
);
};

View File

@@ -19,7 +19,7 @@ import {
LinkIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import useClipboard from "react-use-clipboard";
import copy from "copy-to-clipboard";
import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../utils/matrix";
@@ -42,18 +42,17 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
[e2eeSystem, room.name, room.roomId],
);
const [, setCopied] = useClipboard(url);
const [toastOpen, setToastOpen] = useState(false);
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
const onButtonClick = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setCopied();
copy(url);
onDismiss();
setToastOpen(true);
},
[setCopied, onDismiss],
[url, onDismiss],
);
return (

View File

@@ -24,7 +24,7 @@ import {
type LocalVideoTrack,
Track,
} from "livekit-client";
import { useObservable } from "observable-hooks";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import { useNavigate } from "react-router-dom";
@@ -45,7 +45,7 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { useInitial } from "../useInitial";
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
import {
@@ -54,6 +54,7 @@ import {
} from "../livekit/TrackProcessorContext";
import { usePageTitle } from "../usePageTitle";
import { useLatest } from "../useLatest";
import { getValue } from "../utils/observable";
interface Props {
client: MatrixClient;
@@ -126,13 +127,18 @@ export const LobbyView: FC<Props> = ({
);
const devices = useMediaDevices();
const videoInputId = useObservableEagerState(
devices.videoInput.selected$,
)?.id;
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useInitial(
() =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
muteStates.audio.enabled && {
deviceId: getValue(devices.audioInput.selected$)?.id,
},
);
const { processor } = useTrackProcessor();
@@ -148,14 +154,14 @@ export const LobbyView: FC<Props> = ({
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
deviceId: videoInputId,
processor: initialProcessor,
},
}),
[
initialAudioOptions,
muteStates.video.enabled,
devices.videoInput.selectedId,
videoInputId,
initialProcessor,
],
);

View File

@@ -5,20 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
afterAll,
afterEach,
describe,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { type FC, useCallback, useState } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { of } from "rxjs";
import { useMuteStates } from "./MuteStates";
import {
type DeviceLabel,
type MediaDeviceHandle,
type MediaDevices,
MediaDevicesContext,
} from "../livekit/MediaDevicesContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
vi.mock("@livekit/components-core");
interface TestComponentProps {
isJoined?: boolean;
@@ -73,16 +82,6 @@ const mockCamera: MediaDeviceInfo = {
},
};
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
return {
available,
selectedId: "",
selectedGroupId: "",
select: (): void => {},
useAsEarpiece: false,
};
}
function mockMediaDevices(
{
microphone,
@@ -94,21 +93,21 @@ function mockMediaDevices(
camera?: boolean;
} = { microphone: true, speaker: true, camera: true },
): MediaDevices {
return {
audioInput: mockDevices(
microphone
? new Map([[mockMicrophone.deviceId, mockMicrophone]])
: new Map(),
),
audioOutput: mockDevices(
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(),
),
videoInput: mockDevices(
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(),
),
startUsingDeviceNames: (): void => {},
stopUsingDeviceNames: (): void => {},
};
vi.mocked(createMediaDeviceObserver).mockImplementation((kind) => {
switch (kind) {
case "audioinput":
return of(microphone ? [mockMicrophone] : []);
case "audiooutput":
return of(speaker ? [mockSpeaker] : []);
case "videoinput":
return of(camera ? [mockCamera] : []);
case undefined:
throw new Error("Unimplemented");
}
});
const scope = new ObservableScope();
onTestFinished(() => scope.end());
return new MediaDevices(scope);
}
describe("useMuteStates", () => {
@@ -125,14 +124,14 @@ describe("useMuteStates", () => {
render(
<MemoryRouter>
<MediaDevicesContext.Provider
<MediaDevicesContext
value={mockMediaDevices({
microphone: false,
camera: false,
})}
>
<TestComponent />
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -144,9 +143,9 @@ describe("useMuteStates", () => {
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
@@ -160,9 +159,9 @@ describe("useMuteStates", () => {
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent isJoined />
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -179,9 +178,9 @@ describe("useMuteStates", () => {
render(
<MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -193,9 +192,9 @@ describe("useMuteStates", () => {
render(
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
<MediaDevicesContext.Provider value={mockMediaDevices()}>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -206,7 +205,12 @@ describe("useMuteStates", () => {
const user = userEvent.setup();
mockConfig();
const noDevices = mockMediaDevices({ microphone: false, camera: false });
// Warm up these Observables before making further changes to the
// createMediaDevicesObserver mock
noDevices.audioInput.available$.subscribe(() => {}).unsubscribe();
noDevices.videoInput.available$.subscribe(() => {}).unsubscribe();
const someDevices = mockMediaDevices();
const ReappearanceTest: FC = () => {
const [devices, setDevices] = useState(someDevices);
const onConnectDevicesClick = useCallback(
@@ -220,13 +224,13 @@ describe("useMuteStates", () => {
return (
<MemoryRouter>
<MediaDevicesContext.Provider value={devices}>
<MediaDevicesContext value={devices}>
<TestComponent />
<button onClick={onConnectDevicesClick}>Connect devices</button>
<button onClick={onDisconnectDevicesClick}>
Disconnect devices
</button>
</MediaDevicesContext.Provider>
</MediaDevicesContext>
</MemoryRouter>
);
};

View File

@@ -14,11 +14,14 @@ import {
} from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import {
type MediaDeviceHandle,
useMediaDevices,
} from "../livekit/MediaDevicesContext";
type DeviceLabel,
type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config";
@@ -53,24 +56,24 @@ export interface MuteStates {
}
function useMuteState(
device: MediaDeviceHandle,
device: MediaDevice<DeviceLabel, SelectedDevice>,
enabledByDefault: () => boolean,
): MuteState {
const available = useObservableEagerState(device.available$);
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
// Determine the default value once devices are actually connected
(prev) =>
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
[device.available.size],
(prev) => prev ?? (available.size > 0 ? enabledByDefault() : undefined),
[available.size],
);
return useMemo(
() =>
device.available.size === 0
available.size === 0
? deviceUnavailable
: {
enabled: enabled ?? false,
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
},
[device.available.size, enabled, setEnabled],
[available.size, enabled, setEnabled],
);
}

View File

@@ -46,7 +46,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":r0:"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -69,7 +69,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="false"
aria-labelledby=":r5:"
aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -91,7 +91,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby=":ra:"
aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -112,7 +112,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby=":rf:"
aria-labelledby="«rf»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
@@ -138,7 +138,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout"
>
<input
aria-labelledby=":rk:"
aria-labelledby="«rk»"
name="layout"
type="radio"
value="spotlight"
@@ -156,7 +156,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby=":rp:"
aria-labelledby="«rp»"
checked=""
name="layout"
type="radio"

View File

@@ -22,7 +22,7 @@ import {
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { platform } from "../Platform";
import { useLatest } from "../useLatest";

View File

@@ -121,10 +121,18 @@ export async function enterRTCSession(
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
matrixRtcSessionConfig?.membership_keep_alive_period,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
networkErrorRetryMs: matrixRtcSessionConfig?.membership_keep_alive_period,
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay:
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport,
},
);

View File

@@ -21,15 +21,18 @@ import {
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import {
EARPIECE_CONFIG_ID,
type MediaDeviceHandle,
} from "../livekit/MediaDevicesContext";
type AudioOutputDeviceLabel,
type DeviceLabel,
type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import styles from "./DeviceSelection.module.css";
interface Props {
device: MediaDeviceHandle;
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
title: string;
numberedLabel: (number: number) => string;
}
@@ -41,6 +44,8 @@ export const DeviceSelection: FC<Props> = ({
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
device.select(e.target.value);
@@ -49,7 +54,7 @@ export const DeviceSelection: FC<Props> = ({
);
// There is no need to show the menu if there is no choice that can be made.
if (device.available.size <= 1) return null;
if (available.size <= 1) return null;
return (
<div className={styles.selection}>
@@ -64,7 +69,7 @@ export const DeviceSelection: FC<Props> = ({
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{[...device.available].map(([id, label]) => {
{[...available].map(([id, label]) => {
let labelText: ReactNode;
switch (label.type) {
case "name":
@@ -94,20 +99,13 @@ export const DeviceSelection: FC<Props> = ({
break;
}
let isSelected = false;
if (device.useAsEarpiece) {
isSelected = id === EARPIECE_CONFIG_ID;
} else {
isSelected = id === device.selectedId;
}
return (
<InlineField
key={id}
name={groupId}
control={
<RadioControl
checked={isSelected}
checked={id === selectedId}
onChange={onChange}
value={id}
/>

View File

@@ -5,7 +5,7 @@ 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 ReactNode, useState } from "react";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
@@ -17,11 +17,8 @@ import styles from "./SettingsModal.module.css";
import { type Tab, TabContainer } from "../tabs/Tabs";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import {
useMediaDevices,
useMediaDeviceNames,
iosDeviceMenu$,
} from "../livekit/MediaDevicesContext";
import { iosDeviceMenu$ } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { widget } from "../widget";
import {
useSetting,
@@ -98,7 +95,10 @@ export const SettingsModal: FC<Props> = ({
};
const devices = useMediaDevices();
useMediaDeviceNames(devices, open);
useEffect(() => {
if (open) devices.requestDeviceNames();
}, [open, devices]);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
const [showDeveloperSettingsTab] = useSetting(developerMode);

19
src/state/AppViewModel.ts Normal file
View File

@@ -0,0 +1,19 @@
/*
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 { MediaDevices } from "./MediaDevices";
import { ViewModel } from "./ViewModel";
/**
* The top-level state holder for the application.
*/
export class AppViewModel extends ViewModel {
public readonly mediaDevices = new MediaDevices(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
}

366
src/state/MediaDevices.ts Normal file
View File

@@ -0,0 +1,366 @@
/*
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 {
combineLatest,
filter,
map,
merge,
of,
pairwise,
startWith,
Subject,
switchMap,
type Observable,
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { logger } from "matrix-js-sdk/lib/logger";
import {
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
} from "../settings/settings";
import { type ObservableScope } from "./ObservableScope";
import {
outputDevice$ as controlledOutputSelection$,
availableOutputDevices$ as controlledAvailableOutputDevices$,
} from "../controls";
import { getUrlParams } from "../UrlParams";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "default"; name: string | null };
export type AudioOutputDeviceLabel = DeviceLabel | { type: "earpiece" };
export interface SelectedDevice {
id: string;
}
export interface SelectedAudioInputDevice extends SelectedDevice {
/**
* Emits whenever we think that this audio input device has logically changed
* to refer to a different hardware device.
*/
hardwareDeviceChange$: Observable<void>;
}
export interface SelectedAudioOutputDevice extends SelectedDevice {
/**
* Whether this device is a "virtual earpiece" device. If so, we should output
* on a single channel of the device at a reduced volume.
*/
virtualEarpiece: boolean;
}
export interface MediaDevice<Label, Selected> {
/**
* A map from available device IDs to labels.
*/
available$: Observable<Map<string, Label>>;
/**
* The selected device.
*/
selected$: Observable<Selected | undefined>;
/**
* Selects a new device.
*/
select(id: string): void;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = navigator.userAgent.includes("iPhone")
? of(true)
: alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$(
kind: MediaDeviceKind,
usingNames$: Observable<boolean>,
scope: ObservableScope,
): Observable<MediaDeviceInfo[]> {
return usingNames$.pipe(
switchMap((usingNames) =>
createMediaDeviceObserver(
kind,
(e) => logger.error("Error creating MediaDeviceObserver", e),
usingNames,
),
),
startWith([]),
scope.state(),
);
}
function buildDeviceMap(
availableRaw: MediaDeviceInfo[],
): Map<string, DeviceLabel> {
return new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
}
function selectDevice$<Label>(
available$: Observable<Map<string, Label>>,
preferredId$: Observable<string | undefined>,
): Observable<string | undefined> {
return combineLatest([available$, preferredId$], (available, preferredId) => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
});
}
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.availableRaw$.pipe(
map(buildDeviceMap),
this.scope.state(),
);
public readonly selected$ = selectDevice$(
this.available$,
audioInputSetting.value$,
).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
// We can identify when the hardware device has changed by watching for
// changes in the group ID
hardwareDeviceChange$: this.availableRaw$.pipe(
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
pairwise(),
filter(([before, after]) => before !== after),
map(() => undefined),
),
},
),
this.scope.state(),
);
public select(id: string): void {
audioInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
public readonly available$ = availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
).pipe(
map((availableRaw) => {
const available = 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.
if (available.size && !available.has("") && !available.has("default"))
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
this.scope.state(),
);
public readonly selected$ = selectDevice$(
this.available$,
audioOutputSetting.value$,
).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
virtualEarpiece: false,
},
),
this.scope.state(),
);
public select(id: string): void {
audioOutputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
public readonly available$ = combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => {
const available = new Map<string, AudioOutputDeviceLabel>(
availableRaw.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: AudioOutputDeviceLabel;
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
else if (isSpeaker) deviceLabel = { type: "default", name };
else deviceLabel = { type: "name", name };
return [id, deviceLabel];
},
),
);
// Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available;
},
).pipe(this.scope.state());
private readonly deviceSelection$ = new Subject<string>();
public select(id: string): void {
this.deviceSelection$.next(id);
}
public readonly selected$ = merge(
this.deviceSelection$,
controlledOutputSelection$,
).pipe(
startWith<string | undefined>(undefined),
map((id) =>
id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID },
),
this.scope.state(),
);
public constructor(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);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
}
});
}
}
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
public readonly available$ = availableRawDevices$(
"videoinput",
this.usingNames$,
this.scope,
).pipe(map(buildDeviceMap));
public readonly selected$ = selectDevice$(
this.available$,
videoInputSetting.value$,
).pipe(
map((id) => (id === undefined ? undefined : { id })),
this.scope.state(),
);
public select(id: string): void {
videoInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
export class MediaDevices {
private readonly deviceNamesRequest$ = new Subject<void>();
/**
* Requests that the media devices be populated with the names of each
* available device, rather than numbered identifiers. This may invoke a
* permissions pop-up, so it should only be called when there is a clear user
* intent to view the device list.
*/
public requestDeviceNames(): void {
this.deviceNamesRequest$.next();
}
// Start using device names as soon as requested. This will cause LiveKit to
// briefly request device permissions and acquire media streams for each
// device type while calling `enumerateDevices`, which is what browsers want
// you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
map(() => true),
startWith(false),
this.scope.state(),
);
public readonly audioInput: MediaDevice<
DeviceLabel,
SelectedAudioInputDevice
> = new AudioInput(this.usingNames$, this.scope);
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
public constructor(private readonly scope: ObservableScope) {}
}

View File

@@ -430,8 +430,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
}
/**
},
},
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {

View File

@@ -10,10 +10,12 @@ import { type FC } from "react";
import { render } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { of } from "rxjs";
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { useAudioContext } from "./useAudioContext";
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
import { mockMediaDevices } from "./utils/test";
const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0),
@@ -102,36 +104,38 @@ afterEach(() => {
});
test("can play a single sound", async () => {
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce();
});
test("will ignore sounds that are not registered", async () => {
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext>,
);
await user.click(await findByText("Invalid sound"));
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
});
test("will use the correct device", () => {
render(
<MediaDevicesContext.Provider
value={{
audioInput: deviceStub,
<MediaDevicesContext
value={mockMediaDevices({
audioOutput: {
selectedId: "chosen-device",
selectedGroupId: "",
available: new Map(),
available$: of(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: false }),
select: () => {},
useAsEarpiece: false,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
})}
>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
</MediaDevicesContext>,
);
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device");
@@ -139,7 +143,11 @@ test("will use the correct device", () => {
test("will use the correct volume level", async () => {
soundEffectVolumeSetting.setValue(0.33);
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith(
0.33,
@@ -150,23 +158,17 @@ test("will use the correct volume level", async () => {
test("will use the pan if earpiece is selected", async () => {
const { findByText } = render(
<MediaDevicesContext.Provider
value={{
audioInput: deviceStub,
<MediaDevicesContext
value={mockMediaDevices({
audioOutput: {
selectedId: "chosen-device",
selectedGroupId: "",
available: new Map(),
available$: of(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: true }),
select: () => {},
useAsEarpiece: true,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
})}
>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
</MediaDevicesContext>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(1, 0);

View File

@@ -7,17 +7,16 @@ Please see LICENSE in the repository root for full details.
import { logger } from "matrix-js-sdk/lib/logger";
import { useState, useEffect } from "react";
import { useObservableEagerState } from "observable-hooks";
import {
soundEffectVolume as soundEffectVolumeSetting,
useSetting,
} from "./settings/settings";
import {
useEarpieceAudioConfig,
useMediaDevices,
} from "./livekit/MediaDevicesContext";
import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext";
import { type PrefetchedSounds } from "./soundUtils";
import { useUrlParams } from "./UrlParams";
import * as controls from "./controls";
/**
* Play a sound though a given AudioContext. Will take
@@ -42,6 +41,7 @@ async function playSound(
src.buffer = buffer;
src.connect(gain).connect(pan).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
controls.setPlaybackStarted();
src.start();
return p;
}
@@ -71,8 +71,6 @@ export function useAudioContext<S extends string>(
props: Props<S>,
): UseAudioContext<S> | null {
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { audioOutput } = useMediaDevices();
const { controlledAudioDevices } = useUrlParams();
const [audioContext, setAudioContext] = useState<AudioContext>();
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
@@ -109,6 +107,11 @@ export function useAudioContext<S extends string>(
};
}, [props.sounds, props.latencyHint]);
const audioOutputId = useObservableEagerState(
useMediaDevices().audioOutput.selected$,
)?.id;
const { controlledAudioDevices } = useUrlParams();
// Update the sink ID whenever we change devices.
useEffect(() => {
if (
@@ -118,11 +121,11 @@ export function useAudioContext<S extends string>(
) {
// https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId
// @ts-expect-error - setSinkId doesn't exist yet in types, maybe because it's not supported everywhere.
audioContext.setSinkId(audioOutput.selectedId).catch((ex) => {
audioContext.setSinkId(audioOutputId).catch((ex) => {
logger.warn("Unable to change sink for audio context", ex);
});
}
}, [audioContext, audioOutput.selectedId, controlledAudioDevices]);
}, [audioContext, audioOutputId, controlledAudioDevices]);
const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig();
// Don't return a function until we're ready.

View File

@@ -38,3 +38,18 @@ export function accumulate<State, Event>(
return (events$: Observable<Event>): Observable<State> =>
events$.pipe(scan(update, initial), startWith(initial));
}
/**
* Reads the current value of a state Observable without reacting to future
* changes.
*
* This function exists to help with certain cases of bridging Observables into
* React, where an initial value is needed. You should never use it to create an
* Observable derived from another Observable; use reactive operators instead.
*/
export function getValue<T>(state$: Observable<T>): T {
let value: T | typeof nothing = nothing;
state$.subscribe((x) => (value = x)).unsubscribe();
if (value === nothing) throw new Error("Not a state Observable");
return value;
}

View File

@@ -46,6 +46,7 @@ import {
type ResolvedConfigOptions,
} from "../config/ConfigOptions";
import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices";
export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers();
@@ -332,3 +333,18 @@ export const mockTrack = (identity: string): TrackReference =>
track: {},
source: {},
}) as unknown as TrackReference;
export const deviceStub = {
available$: of(new Map<never, never>()),
selected$: of(undefined),
select(): void {},
};
export function mockMediaDevices(data: Partial<MediaDevices>): MediaDevices {
return {
audioInput: deviceStub,
audioOutput: deviceStub,
videoInput: deviceStub,
...data,
} as MediaDevices;
}

View File

@@ -18,9 +18,9 @@ export default defineConfig((env) =>
output: "./config.json",
data: {
matrix_rtc_session: {
key_rotation_on_leave_delay: 15000,
membership_keep_alive_period: 5000,
membership_server_side_expiry_timeout: 15000,
wait_for_key_rotation_ms: 5000,
delayed_leave_event_restart_ms: 4000,
delayed_leave_event_delay_ms: 18000,
},
},
},

219
yarn.lock
View File

@@ -2717,8 +2717,8 @@ __metadata:
linkType: hard
"@livekit/components-react@npm:^2.0.0":
version: 2.9.9
resolution: "@livekit/components-react@npm:2.9.9"
version: 2.9.10
resolution: "@livekit/components-react@npm:2.9.10"
dependencies:
"@livekit/components-core": "npm:0.12.7"
clsx: "npm:2.1.1"
@@ -2732,7 +2732,7 @@ __metadata:
peerDependenciesMeta:
"@livekit/krisp-noise-filter":
optional: true
checksum: 10c0/7b3dad637b0bfd91c5636b1bb2c72ecde6136a4222d552690a3b3088c6fe4a28fbc30d2e8fec643c7e90316d8419393e7636001d5d192f5f1c9254df1307df0d
checksum: 10c0/86fc78d7b2d97540c43592435a65ac38b43fca9923af000aa90d884bd35794e18d202b7d915f44b63087abea5c94c37d6980772ed722226904262b2cddbec107
languageName: node
linkType: hard
@@ -2743,12 +2743,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.38.0":
version: 1.38.0
resolution: "@livekit/protocol@npm:1.38.0"
"@livekit/protocol@npm:1.39.2":
version: 1.39.2
resolution: "@livekit/protocol@npm:1.39.2"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/ca64d4f984853054ff60574730b08a761afcd3bdc084e5218663e54b0e7f395aa2022d9d15d982fa094bbc0179cb19ef6a96ec74b1aa3265d118a85d1a4fde33
checksum: 10c0/ce5f3ee3ab10ea1578fb40c1d16224879e48143a1bbbfb5e36a080fa5468892eaccfe21aad1166b5443f67e2aa54236facafd4b3235e41b331e91ca405379368
languageName: node
linkType: hard
@@ -2780,13 +2780,6 @@ __metadata:
languageName: node
linkType: hard
"@matrix-org/olm@npm:3.2.15":
version: 3.2.15
resolution: "@matrix-org/olm@npm:3.2.15"
checksum: 10c0/82a40d6e4e632a90670d4f15e8962272e302f4b9deed4fc78455c5ca78422c13bde6b53ebfc406630336926c9574386c9d9069c9c023db1c3d143117985c1e50
languageName: node
linkType: hard
"@mediapipe/tasks-vision@npm:^0.10.18":
version: 0.10.21
resolution: "@mediapipe/tasks-vision@npm:0.10.21"
@@ -4391,69 +4384,69 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/animated@npm:9.7.5"
"@react-spring/animated@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/animated@npm:10.0.1"
dependencies:
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/aaccd4a8b0280ac846d463b253ad8f092ee4afc9dbedc8e77616adf5399ffec755344f09fdd8487cadaf815840dff84d354d1143579c27c2fcd6937549b5fc40
languageName: node
linkType: hard
"@react-spring/core@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/core@npm:9.7.5"
"@react-spring/core@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/core@npm:10.0.1"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/animated": "npm:~10.0.1"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/059b122dda4138e5e7e461abd49350921e326735ca9a1d8aa19b1fdbae0937661b5f71af6fe82fd8f59e8db5549627849b38cc3f7ef2ec7ee9c93c3d6225174f
languageName: node
linkType: hard
"@react-spring/rafz@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/rafz@npm:9.7.5"
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
"@react-spring/rafz@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/rafz@npm:10.0.1"
checksum: 10c0/cba76f143d3a06f79dd0c09f7aefd17df9cca9b2c1ef7f9103255e5351326f4a42a5a1366f731a78f74380d96ba683bcc2a49312ed1e4b9e9e249e72c9ff68cb
languageName: node
linkType: hard
"@react-spring/shared@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/shared@npm:9.7.5"
"@react-spring/shared@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/shared@npm:10.0.1"
dependencies:
"@react-spring/rafz": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/rafz": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/f056aaa018b3744afd8244e8eea24534d32f92fad9ace815b80e159b296fb5db148e2c9bd840ad9a5188e7a3c0778fd564b8af9ae02cd500e019a228398fb3cf
languageName: node
linkType: hard
"@react-spring/types@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/types@npm:9.7.5"
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
"@react-spring/types@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/types@npm:10.0.1"
checksum: 10c0/260890f9c156dc69b77c846510017156d8c0a07cce70edc7c108e57b0cf4122b26a15e724b191481a51b2c914296de9e81d56618b2c339339d4b221930691baa
languageName: node
linkType: hard
"@react-spring/web@npm:^9.4.4":
version: 9.7.5
resolution: "@react-spring/web@npm:9.7.5"
"@react-spring/web@npm:^10.0.0":
version: 10.0.1
resolution: "@react-spring/web@npm:10.0.1"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/core": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/animated": "npm:~10.0.1"
"@react-spring/core": "npm:~10.0.1"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/a0c788c9fd881ccb834feb22fc0694e74e59f7b76e498f0096f5b65e2c9812513955bf45ee27d7c5348f56a7bba7c5a6961d4be663728bb2172fe5aa6b6bdfc4
languageName: node
linkType: hard
@@ -5224,13 +5217,6 @@ __metadata:
languageName: node
linkType: hard
"@types/prop-types@npm:*":
version: 15.7.14
resolution: "@types/prop-types@npm:15.7.14"
checksum: 10c0/1ec775160bfab90b67a782d735952158c7e702ca4502968aa82565bd8e452c2de8601c8dfe349733073c31179116cf7340710160d3836aa8a1ef76d1532893b1
languageName: node
linkType: hard
"@types/qrcode@npm:^1.5.5":
version: 1.5.5
resolution: "@types/qrcode@npm:1.5.5"
@@ -5240,22 +5226,21 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:^18.3.0":
version: 18.3.7
resolution: "@types/react-dom@npm:18.3.7"
"@types/react-dom@npm:^19.0.0":
version: 19.1.5
resolution: "@types/react-dom@npm:19.1.5"
peerDependencies:
"@types/react": ^18.0.0
checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e
"@types/react": ^19.0.0
checksum: 10c0/2a29e77cf6bb6e9f57bcfa54509c216cad2e16e244f0bd56369966ec88c072b9c91f6011d14f9e18fbfe2b801b18b86f616de75e5c8aef0be73c1f74abb33b49
languageName: node
linkType: hard
"@types/react@npm:^18.3.0":
version: 18.3.23
resolution: "@types/react@npm:18.3.23"
"@types/react@npm:^19.0.0":
version: 19.1.6
resolution: "@types/react@npm:19.1.6"
dependencies:
"@types/prop-types": "npm:*"
csstype: "npm:^3.0.2"
checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed
checksum: 10c0/8b10b198e28997b3c57559750f8bcf5ae7b33c554b16b6f4fe2ece1d4de6a2fc8cb53e7effe08ec9cb939d2f479eb97c5e08aac2cf83b10a90164fe451cc8ea2
languageName: node
linkType: hard
@@ -5611,8 +5596,8 @@ __metadata:
linkType: hard
"@vector-im/compound-design-tokens@npm:^4.0.0":
version: 4.0.3
resolution: "@vector-im/compound-design-tokens@npm:4.0.3"
version: 4.0.4
resolution: "@vector-im/compound-design-tokens@npm:4.0.4"
peerDependencies:
"@types/react": "*"
react: ^17 || ^18 || ^19.0.0
@@ -5621,13 +5606,13 @@ __metadata:
optional: true
react:
optional: true
checksum: 10c0/4e32e46b4f0afef463ab7827c7bd1e0369bf83bab2cf86f182a5052b409c8b64e6fea84f818eee4d3f10e51be996e550d2b2dc664d7444f6685502f517d9a754
checksum: 10c0/e6ff6a956082f4a288237e7c7e60044319d7195cad0d5175dad7115270119f80c43252520db8f1a514b762f92dd5b7059c1217d7ccbe81daf71c426cbfeaf3dd
languageName: node
linkType: hard
"@vector-im/compound-web@npm:^7.12.0":
version: 7.12.0
resolution: "@vector-im/compound-web@npm:7.12.0"
"@vector-im/compound-web@npm:^8.0.0":
version: 8.1.2
resolution: "@vector-im/compound-web@npm:8.1.2"
dependencies:
"@floating-ui/react": "npm:^0.27.0"
"@radix-ui/react-context-menu": "npm:^2.2.1"
@@ -5647,7 +5632,7 @@ __metadata:
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/e5546a5b8ca1a5e718f31acf9a515af4b76877d5526f2b7f5d8fb1fa9382a9a375fc0dd7fc6d3f6e9fc2e2bb0109ca99fc2a63552fc5c33bfa86e33f96204931
checksum: 10c0/4d16794f20b5577bb32442a8261ccad87d83ef91d9212e4ff12af90c4423790859b9a712764211c751079d384bf4dc88522961e008a9bd9f46653fdd75cdc195
languageName: node
linkType: hard
@@ -6867,7 +6852,7 @@ __metadata:
languageName: node
linkType: hard
"copy-to-clipboard@npm:^3.3.1":
"copy-to-clipboard@npm:^3.3.3":
version: 3.3.3
resolution: "copy-to-clipboard@npm:3.3.3"
dependencies:
@@ -7457,7 +7442,7 @@ __metadata:
"@radix-ui/react-dialog": "npm:^1.0.4"
"@radix-ui/react-slider": "npm:^1.1.2"
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
"@react-spring/web": "npm:^9.4.4"
"@react-spring/web": "npm:^10.0.0"
"@sentry/react": "npm:^8.0.0"
"@sentry/vite-plugin": "npm:^3.0.0"
"@stylistic/eslint-plugin": "npm:^3.0.0"
@@ -7473,19 +7458,20 @@ __metadata:
"@types/node": "npm:^22.0.0"
"@types/pako": "npm:^2.0.3"
"@types/qrcode": "npm:^1.5.5"
"@types/react": "npm:^18.3.0"
"@types/react-dom": "npm:^18.3.0"
"@types/react": "npm:^19.0.0"
"@types/react-dom": "npm:^19.0.0"
"@types/sdp-transform": "npm:^2.4.5"
"@types/uuid": "npm:10"
"@typescript-eslint/eslint-plugin": "npm:^8.31.0"
"@typescript-eslint/parser": "npm:^8.31.0"
"@use-gesture/react": "npm:^10.2.11"
"@vector-im/compound-design-tokens": "npm:^4.0.0"
"@vector-im/compound-web": "npm:^7.12.0"
"@vector-im/compound-web": "npm:^8.0.0"
"@vitejs/plugin-react": "npm:^4.0.1"
"@vitest/coverage-v8": "npm:^3.0.0"
babel-plugin-transform-vite-meta-env: "npm:^1.0.3"
classnames: "npm:^2.3.1"
copy-to-clipboard: "npm:^3.3.3"
eslint: "npm:^8.14.0"
eslint-config-google: "npm:^0.14.0"
eslint-config-prettier: "npm:^10.0.0"
@@ -7516,11 +7502,10 @@ __metadata:
posthog-js: "npm:1.160.3"
prettier: "npm:^3.0.0"
qrcode: "npm:^1.5.4"
react: "npm:18"
react-dom: "npm:18"
react: "npm:19"
react-dom: "npm:19"
react-i18next: "npm:^15.0.0"
react-router-dom: "npm:^7.0.0"
react-use-clipboard: "npm:^1.0.7"
react-use-measure: "npm:^2.1.1"
rxjs: "npm:^7.8.1"
sass: "npm:^1.42.1"
@@ -9922,11 +9907,11 @@ __metadata:
linkType: hard
"livekit-client@npm:^2.13.0":
version: 2.13.3
resolution: "livekit-client@npm:2.13.3"
version: 2.13.8
resolution: "livekit-client@npm:2.13.8"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.38.0"
"@livekit/protocol": "npm:1.39.2"
events: "npm:^3.3.0"
loglevel: "npm:^1.9.2"
sdp-transform: "npm:^2.15.0"
@@ -9936,7 +9921,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/9eadb9835514551c8e834cebb607999e632bc4b3b3e5d5e206db90b66e2e5467b8a1f76a03c648e00cc96c87256b4eece58dbb2b92dfcafa84f4798bbb305060
checksum: 10c0/bf1ba10891ab5beb44a30016c3923a635d0bb637d604280686b98ea366794985eec0a4a294fb1478a8a68ac90ba8ab0ea5c1e64986603d8ce53019399994385b
languageName: node
linkType: hard
@@ -10007,7 +9992,7 @@ __metadata:
languageName: node
linkType: hard
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
"loose-envify@npm:^1.4.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
@@ -10148,12 +10133,11 @@ __metadata:
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
version: 37.6.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=bf6dc16ad32d47f2c6e167236f4c853ceef01d4f"
version: 37.8.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=adaf92162377414df620e004d8c58c67118490ca"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^14.2.0"
"@matrix-org/olm": "npm:3.2.15"
another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4"
@@ -10166,7 +10150,7 @@ __metadata:
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/2877e7c5b2779200b48f3152bb7b510e58899b1e779e7e6d2bc1a236ed178fa8858f0547b644a2029f48f55dc7cc3954f48e2598c8963d45293c8280ccc23039
checksum: 10c0/8e842056ff926c615d53a4a333c9e54d0fbe7f88b97b55629464e67850cc512606accde6a8724dd48ac82714483f685515913b5556e9824c4d63c443317be1ef
languageName: node
linkType: hard
@@ -11577,15 +11561,14 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:18":
version: 18.3.1
resolution: "react-dom@npm:18.3.1"
"react-dom@npm:19":
version: 19.1.0
resolution: "react-dom@npm:19.1.0"
dependencies:
loose-envify: "npm:^1.1.0"
scheduler: "npm:^0.23.2"
scheduler: "npm:^0.26.0"
peerDependencies:
react: ^18.3.1
checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85
react: ^19.1.0
checksum: 10c0/3e26e89bb6c67c9a6aa86cb888c7a7f8258f2e347a6d2a15299c17eb16e04c19194e3452bc3255bd34000a61e45e2cb51e46292392340432f133e5a5d2dfb5fc
languageName: node
linkType: hard
@@ -11729,18 +11712,6 @@ __metadata:
languageName: node
linkType: hard
"react-use-clipboard@npm:^1.0.7":
version: 1.0.9
resolution: "react-use-clipboard@npm:1.0.9"
dependencies:
copy-to-clipboard: "npm:^3.3.1"
peerDependencies:
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
checksum: 10c0/f75ba85b885ae45d0c99a09a0652a2f64c1d9d6d015293bf01b2e6a9f50ff2a6b1830f6e60c1f639b9ae33fa434bb31adbf43f613df2402438204ea7456cf86a
languageName: node
linkType: hard
"react-use-measure@npm:^2.1.1":
version: 2.1.7
resolution: "react-use-measure@npm:2.1.7"
@@ -11754,12 +11725,10 @@ __metadata:
languageName: node
linkType: hard
"react@npm:18":
version: 18.3.1
resolution: "react@npm:18.3.1"
dependencies:
loose-envify: "npm:^1.1.0"
checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3
"react@npm:19":
version: 19.1.0
resolution: "react@npm:19.1.0"
checksum: 10c0/530fb9a62237d54137a13d2cfb67a7db6a2156faed43eecc423f4713d9b20c6f2728b026b45e28fcd72e8eadb9e9ed4b089e99f5e295d2f0ad3134251bdd3698
languageName: node
linkType: hard
@@ -12352,12 +12321,10 @@ __metadata:
languageName: node
linkType: hard
"scheduler@npm:^0.23.2":
version: 0.23.2
resolution: "scheduler@npm:0.23.2"
dependencies:
loose-envify: "npm:^1.1.0"
checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78
"scheduler@npm:^0.26.0":
version: 0.26.0
resolution: "scheduler@npm:0.26.0"
checksum: 10c0/5b8d5bfddaae3513410eda54f2268e98a376a429931921a81b5c3a2873aab7ca4d775a8caac5498f8cbc7d0daeab947cf923dbd8e215d61671f9f4e392d34356
languageName: node
linkType: hard